diff --git a/.github/issue86/check_matrix.py b/.github/issue86/check_matrix.py new file mode 100644 index 00000000..61ab02fb --- /dev/null +++ b/.github/issue86/check_matrix.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +"""Issue 86: clangd --check on the reporter's file, as a matrix of experiments. + +Each experiment is one `okrun clangd.okb --check=` with a variant of the file, of the arguments, +of the environment or of the project's .clangd, and records how clangd ended. An experiment that +ends by an exception (an NTSTATUS exit on Windows, a signal elsewhere) is run again under cdb, which +writes the registers, the bytes at the instruction pointer, the frame-pointer chain, the raw stack +and a full dump; symbolize.py then names the frames from the image's own symbol table. + +The variants are the reporter's reduction (issue 86, comment 6816991554), so the run confirms or +refutes each of its rows on a clean machine, plus the switches that tell which part of clangd traps. +""" +import argparse +import json +import os +import shutil +import subprocess +import sys +import time + +GUARDED = "#ifndef ISSUE86_SYNTHETIC_HPP\n#define ISSUE86_SYNTHETIC_HPP\nstruct Issue86Synthetic {};\n#endif\n" +UNGUARDED = "struct Issue86Synthetic {};\n" +PRAGMA_ONCE = "#pragma once\nstruct Issue86Synthetic {};\n" +HEADER_NAME = "issue86_synthetic.hpp" + + +def unit(include, imports, body): + return "module;\n" + include + "\nexport module systop:monitor_view;\n" + imports + "\nexport namespace monitor {\n" + body + "\n}\n" + + +FTXUI = "#include " +SYNTHETIC = f'#include "{HEADER_NAME}"' +STD = "import std;" +PARTITION = "import :monitor_state;" + +# name -> (file text or None for the project's own, synthetic header text or None, why) +VARIANTS = { + "original": (None, None, "the project's own src/ui/monitor_view.cppm"), + "ftxui-used": (unit(FTXUI, STD, " auto f(ftxui::Box&) -> int;"), None, "one ftxui header, import std, a purview use of its type"), + "ftxui-unused": (unit(FTXUI, STD, " auto f() -> int;"), None, "the same without the purview use"), + "guarded-used": (unit(SYNTHETIC, STD, " auto f(Issue86Synthetic&) -> int;"), GUARDED, "a one-struct header with an include guard, used"), + "guarded-unused": (unit(SYNTHETIC, STD, " auto f() -> int;"), GUARDED, "the guarded header, not used"), + "unguarded-used": (unit(SYNTHETIC, STD, " auto f(Issue86Synthetic&) -> int;"), UNGUARDED, "the header without its guard, used"), + "pragma-once-used": (unit(SYNTHETIC, STD, " auto f(Issue86Synthetic&) -> int;"), PRAGMA_ONCE, "the header under #pragma once, used"), + "guarded-used-partition-import": (unit(SYNTHETIC, PARTITION, " auto f(Issue86Synthetic&) -> int;"), GUARDED, "imports a partition of the project, not std"), + "guarded-used-no-import": (unit(SYNTHETIC, "", " auto f(Issue86Synthetic&) -> int;"), GUARDED, "imports nothing: the textual-preamble path"), + "guarded-used-in-variable": (unit(SYNTHETIC, STD, " inline Issue86Synthetic value;"), GUARDED, "the type used by a variable, not a parameter"), + # The same header, reached by a path that is not its canonical one: clangd records a header by the identity of the + # file it opened and looks it up again by the identity of its canonical name. + "guarded-used-other-spelling": (unit('#include "../ui/' + HEADER_NAME + '"', STD, " auto f(Issue86Synthetic&) -> int;"), GUARDED, "the guarded header through ../ui/, used"), + "unguarded-used-other-spelling": (unit('#include "../ui/' + HEADER_NAME + '"', STD, " auto f(Issue86Synthetic&) -> int;"), UNGUARDED, "the unguarded header through ../ui/, used"), + "guarded-unused-other-spelling": (unit('#include "../ui/' + HEADER_NAME + '"', STD, " auto f() -> int;"), GUARDED, "the guarded header through ../ui/, not used"), + "guarded-used-other-spelling-no-import": (unit('#include "../ui/' + HEADER_NAME + '"', "", " auto f(Issue86Synthetic&) -> int;"), GUARDED, "through ../ui/, used, no import: the textual-preamble path"), + "std-type-used": (unit(SYNTHETIC, STD, " auto f(std::string&) -> int;"), GUARDED, "the guarded header included, a std type used"), +} + +NO_INCLUDE_ANALYSIS = "Diagnostics:\n UnusedIncludes: None\n MissingIncludes: None\n" + +# name -> (variant, extra arguments, arguments removed, environment, .clangd text or None, why) +SWITCHES = { + "no-include-analysis": ("{crash}", [], [], {}, NO_INCLUDE_ANALYSIS, ".clangd turns unused/missing include analysis off"), + # The same fragment without touching the project: clangd's user configuration, whose directory the image cannot + # determine on Windows by itself ("Couldn't determine user config file"), named by the environment. + "user-config-xdg": ("{crash}", [], [], {"XDG_CONFIG_HOME": "{user-config}"}, None, "include analysis off by a user config under XDG_CONFIG_HOME"), + "user-config-home": ("{crash}", [], [], {"HOME": "{user-home}"}, None, "include analysis off by a user config under HOME/.config"), + "user-config-appdata": ("{crash}", [], [], {"LOCALAPPDATA": "{user-config}"}, None, "include analysis off by a user config under LOCALAPPDATA"), + "no-config": ("{crash}", ["--enable-config=0"], [], {}, None, "no configuration files read"), + "no-clang-tidy": ("{crash}", ["--clang-tidy=0"], [], {}, None, "clang-tidy off"), + "no-modules-support": ("{crash}", [], ["--experimental-modules-support"], {}, None, "without --experimental-modules-support"), + "pch-memory": ("{crash}", ["--pch-storage=memory"], [], {}, None, "the preamble kept in memory"), + "pch-disk": ("{crash}", ["--pch-storage=disk"], [], {}, None, "the preamble kept on disk"), + "in-process-modules": ("{crash}", ["--modules-builder-worker-policy=in-process"], [], {}, None, "module builds in clangd's own process"), + "okrun-no-snapshot": ("{crash}", [], [], {"OKRUN_SNAPSHOT": "0"}, None, "the runner maps no initialiser snapshot"), + "okrun-place-anywhere": ("{crash}", [], [], {"OKRUN_PLACE": "0"}, None, "the runner places the image anywhere"), + "fresh-module-cache": ("{crash}", [], [], {}, None, "the module cache of the database removed first"), +} + +CDB_SCRIPT = r"""sxd av +sxe ii +g +.echo ===BREAK=== +.lastevent +.exr -1 +.echo ===REGS=== +r +.echo ===ADDR=== +!address @rip +.echo ===BYTES=== +db @rip-0x40 L0x80 +.echo ===DISASM=== +ub @rip L12 +u @rip L6 +.echo ===K=== +kn 0x40 +.echo ===STACK=== +dps @rsp L0x600 +.echo ===MODS=== +lm +.echo ===THREADS=== +~ +.echo ===DUMP=== +{dump} +.echo ===CHAIN=== +r $t1 = 0 +.for (r $t0 = @rbp; (@$t0 != 0) & (@$t1 < 0x100); r $t0 = poi(@$t0); r $t1 = @$t1 + 1) { .printf "FRAME %p RET %p\n", @$t0, poi(@$t0+8) } +.echo ===END=== +q +""" + + +# The same questions asked of a dump (Windows Error Reporting's, or procdump's): no `g`, the exception's own context. +CDB_DUMP_SCRIPT = CDB_SCRIPT.replace("sxd av\nsxe ii\ng\n", ".ecxr\n").replace("{dump}", ".echo a dump already") + + +def status_text(code): + if code is None: + return "timeout" + if os.name == "nt": + unsigned = code & 0xFFFFFFFF + return f"0x{unsigned:08X}" if unsigned >= 0x80000000 else str(code) + return f"signal {-code}" if code < 0 else str(code) + + +def is_exception(code): + if code is None: + return False + if os.name == "nt": + return (code & 0xFFFFFFFF) >= 0x80000000 + return code < 0 or code >= 128 + + +def tail(path, count): + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + return handle.read().splitlines()[-count:] + except OSError: + return [] + + +class Matrix: + def __init__(self, args): + self.args = args + self.out = os.path.abspath(args.out) + os.makedirs(self.out, exist_ok=True) + self.file = os.path.abspath(os.path.join(args.project, args.file)) + self.header = os.path.join(os.path.dirname(self.file), HEADER_NAME) + self.config = os.path.join(os.path.abspath(args.project), ".clangd") + with open(self.file, "r", encoding="utf-8", newline="") as handle: + self.original = handle.read() + self.base_arguments = [f"--check={self.file}", f"--compile-commands-dir={args.database}", "--experimental-modules-support", "--log=verbose"] + self.results = [] + + def command(self, extra, removed): + arguments = [argument for argument in self.base_arguments if argument not in removed] + list(extra) + return [self.args.okrun, self.args.image] + arguments + + def stage(self, variant, config): + text, header, _why = VARIANTS[variant] + with open(self.file, "w", encoding="utf-8", newline="") as handle: + handle.write(self.original if text is None else text) + if os.path.exists(self.header): + os.remove(self.header) + if header is not None: + with open(self.header, "w", encoding="utf-8", newline="") as handle: + handle.write(header) + if os.path.exists(self.config): + os.remove(self.config) + if config is not None: + with open(self.config, "w", encoding="utf-8", newline="") as handle: + handle.write(config) + + def restore(self): + with open(self.file, "w", encoding="utf-8", newline="") as handle: + handle.write(self.original) + for path in (self.header, self.config): + if os.path.exists(path): + os.remove(path) + + def run(self, name, variant, extra=(), removed=(), environment=None, config=None, why="", repeat=1): + self.stage(variant, config) + if name == "fresh-module-cache": + shutil.rmtree(os.path.join(self.args.database, ".cache"), ignore_errors=True) + env = dict(os.environ) + for key, value in (environment or {}).items(): + if value in ("{user-config}", "{user-home}"): + # /clangd/config.yaml, or /.config/clangd/config.yaml + value = os.path.join(self.out, name + "-dir") + folder = os.path.join(value, "clangd") if environment[key] == "{user-config}" else os.path.join(value, ".config", "clangd") + os.makedirs(folder, exist_ok=True) + with open(os.path.join(folder, "config.yaml"), "w", encoding="utf-8", newline="") as handle: + handle.write(NO_INCLUDE_ANALYSIS) + env[key] = value + command = self.command(extra, removed) + runs = [] + for attempt in range(repeat): + log = os.path.join(self.out, f"{name}.{attempt}.log") + started = time.time() + try: + with open(log, "wb") as sink: + done = subprocess.run(command, cwd=self.args.project, env=env, stdin=subprocess.DEVNULL, stdout=sink, stderr=subprocess.STDOUT, timeout=self.args.timeout) + code = done.returncode + except subprocess.TimeoutExpired: + code = None + lines = tail(log, 400) + runs.append({ + "exit": status_text(code), "exception": is_exception(code), "seconds": round(time.time() - started, 1), "log": os.path.basename(log), + "preamble": next((line for line in reversed(lines) if "Built preamble of size" in line), "")[:200], + "lastStage": next((line for line in reversed(lines) if any(stage in line for stage in ("Building preamble", "Building AST", "Indexing AST", "Building inlay", "Testing features", "All checks completed"))), "")[:160], + "errors": [line[:240] for line in lines if line.startswith("E[")][:12], + "tail": [line[:240] for line in lines[-6:]], + }) + result = {"name": name, "variant": variant, "why": why or VARIANTS[variant][2], "extra": list(extra), "removed": list(removed), "environment": environment or {}, + "config": config, "command": command, "runs": runs} + self.results.append(result) + print(f"[{name}] variant={variant} -> " + ", ".join(f"{run['exit']} ({run['seconds']} s; {run['lastStage'][15:] or 'no stage'})" for run in runs), flush=True) + return result + + def debug(self, name, variant, extra=(), removed=(), environment=None, config=None, dump=False): + """The same run under cdb: what the trap is, and where.""" + if not self.args.cdb: + return None + self.stage(variant, config) + output = os.path.join(self.out, f"{name}.cdb.txt") + script = os.path.join(self.out, f"{name}.cdb.script") + dump_path = os.path.join(self.args.dumps, f"{name}.dmp") + os.makedirs(self.args.dumps, exist_ok=True) + with open(script, "w", encoding="ascii", newline="\r\n") as handle: + handle.write(CDB_SCRIPT.replace("{dump}", f".dump /ma {dump_path}" if dump else ".echo no dump for this run")) + env = dict(os.environ) + env.update(environment or {}) + env["_NT_SYMBOL_PATH"] = "" + command = [self.args.cdb, "-cf", script] + self.command(extra, removed) + started = time.time() + try: + with open(output, "wb") as sink: + done = subprocess.run(command, cwd=self.args.project, env=env, stdin=subprocess.DEVNULL, stdout=sink, stderr=subprocess.STDOUT, timeout=self.args.timeout * 3) + code = status_text(done.returncode) + except subprocess.TimeoutExpired: + code = "timeout" + stack = os.path.join(self.out, f"{name}.stack.txt") + symbolized = None + if self.args.elf: + symbolizer = os.path.join(os.path.dirname(os.path.abspath(__file__)), "symbolize.py") + done = subprocess.run([sys.executable, symbolizer, "--elf", self.args.elf, "--cdb-output", output, "--out", stack], capture_output=True, text=True) + symbolized = done.returncode + print(f"--- {name}: the trap, named from the image's symbol table (cdb ended {code} in {round(time.time() - started, 1)} s) ---", flush=True) + print(done.stdout[-12000:], flush=True) + if done.stderr: + print(done.stderr[-2000:], flush=True) + return {"name": name, "cdb": os.path.basename(output), "stack": os.path.basename(stack), "cdbExit": code, "symbolized": symbolized, + "dump": os.path.basename(dump_path) if dump and os.path.exists(dump_path) else None} + + def procdump(self, name, variant): + """Without cdb: procdump writes a full dump at the first illegal instruction, for analysis elsewhere.""" + if not self.args.procdump: + return None + self.stage(variant, None) + os.makedirs(self.args.dumps, exist_ok=True) + output = os.path.join(self.out, f"{name}.procdump.txt") + command = [self.args.procdump, "-accepteula", "-ma", "-e", "1", "-f", "C000001D", "-x", self.args.dumps] + self.command((), ()) + try: + with open(output, "wb") as sink: + done = subprocess.run(command, cwd=self.args.project, stdin=subprocess.DEVNULL, stdout=sink, stderr=subprocess.STDOUT, timeout=self.args.timeout * 3) + code = status_text(done.returncode) + except subprocess.TimeoutExpired: + code = "timeout" + print(f"--- {name}: procdump ended {code} ---", flush=True) + print("\n".join(tail(output, 30)), flush=True) + return {"name": name, "procdump": os.path.basename(output), "exit": code} + + def analyze_dumps(self): + """Dumps Windows Error Reporting wrote for okrun.exe during the replayed session: the same capture, from each.""" + found = [] + if not self.args.cdb or not self.args.wer or not os.path.isdir(self.args.wer): + return found + dumps = sorted(os.path.join(self.args.wer, entry) for entry in os.listdir(self.args.wer) if entry.lower().endswith(".dmp")) + for index, dump in enumerate(dumps[:3]): + name = f"wer-{index}" + output = os.path.join(self.out, f"{name}.cdb.txt") + script = os.path.join(self.out, f"{name}.cdb.script") + with open(script, "w", encoding="ascii", newline="\r\n") as handle: + handle.write(CDB_DUMP_SCRIPT) + env = dict(os.environ) + env["_NT_SYMBOL_PATH"] = "" + try: + with open(output, "wb") as sink: + subprocess.run([self.args.cdb, "-z", dump, "-cf", script], env=env, stdin=subprocess.DEVNULL, stdout=sink, stderr=subprocess.STDOUT, timeout=600) + except subprocess.TimeoutExpired: + pass + stack = os.path.join(self.out, f"{name}.stack.txt") + if self.args.elf: + symbolizer = os.path.join(os.path.dirname(os.path.abspath(__file__)), "symbolize.py") + done = subprocess.run([sys.executable, symbolizer, "--elf", self.args.elf, "--cdb-output", output, "--out", stack], capture_output=True, text=True) + print(f"--- {name}: {os.path.basename(dump)} ({os.path.getsize(dump)} bytes), a clangd of the replayed session ---", flush=True) + print(done.stdout[-12000:], flush=True) + found.append({"name": name, "dump": os.path.basename(dump), "cdb": os.path.basename(output), "stack": os.path.basename(stack)}) + return found + + def server_arguments(self): + """The arguments the server gave the clangd of the replayed session (drive_lsp.py's processes), for --check.""" + try: + with open(self.args.session, "r", encoding="utf-8") as handle: + lines = list(json.load(handle).get("processes", {})) + except (OSError, ValueError, TypeError): + return None + for line in lines: + if "clangd.okb" not in line or "--compile-commands-dir" not in line: + continue + if os.name == "nt": + import ctypes + count = ctypes.c_int(0) + ctypes.windll.shell32.CommandLineToArgvW.restype = ctypes.POINTER(ctypes.c_wchar_p) + parsed = ctypes.windll.shell32.CommandLineToArgvW(ctypes.c_wchar_p(line), ctypes.byref(count)) + words = [parsed[i] for i in range(count.value)] + else: + import shlex + words = shlex.split(line) + arguments = [word for word in words[2:] if word not in ("--background-index", "--pretty=false") and not word.startswith("--compile-commands-dir")] + return arguments + return None + + def main(self): + debugged = [] + crashed = [] + subject = "original" + try: + baseline = self.run("original", "original", repeat=3) + if any(run["exception"] for run in baseline["runs"]): + crashed.append("original") + for variant in ([] if self.args.only_original else VARIANTS): + if variant == "original": + continue + result = self.run(variant, variant, repeat=2) + if any(run["exception"] for run in result["runs"]): + crashed.append(variant) + # The switches are thrown on the smallest variant that traps, else on the project's own file. + preferred = [name for name in ("guarded-used", "ftxui-used", "original") if name in crashed] + subject = preferred[0] if preferred else (crashed[0] if crashed else "original") + for name, (variant, extra, removed, environment, config, why) in ({} if self.args.only_original and not crashed else SWITCHES).items(): + self.run(name, subject if variant == "{crash}" else variant, extra=extra, removed=removed, environment=environment, config=config, why=why, repeat=2) + given = self.server_arguments() + if given is not None: + kept = self.base_arguments + self.base_arguments = [kept[0], kept[1]] + given + try: + self.run("as-the-server-starts-it", subject, why="clangd's arguments as the server gave them in the replayed session", repeat=2) + self.run("as-the-server-starts-it-original", "original", why="the same, on the project's own file", repeat=2) + finally: + self.base_arguments = kept + # Under the debugger: the project's own file (with a dump), and the smallest variant that traps. + for index, name in enumerate(dict.fromkeys(["original"] + preferred[:1] + crashed[:2])): + if name in crashed or name == "original": + # A full dump only of a run that traps: one at a normal exit says nothing. + found = self.debug(name, name, dump=(name in crashed and index == 0)) + if found: + debugged.append(found) + if not self.args.cdb and crashed: + self.procdump(crashed[0], crashed[0]) + finally: + self.restore() + debugged += self.analyze_dumps() + summary = {"file": self.file, "database": self.args.database, "image": self.args.image, "crashed": crashed, "subject": subject, + "experiments": self.results, "debugged": debugged} + with open(os.path.join(self.out, "matrix.json"), "w", encoding="utf-8") as handle: + json.dump(summary, handle, indent=1) + lines = [f"## clangd --check on `{self.args.file}` (issue 86)", "", f"Database `{self.args.database}`.", "", + "| experiment | variant | what it changes | exits | last stage | preamble | first error |", "|---|---|---|---|---|---|---|"] + for result in self.results: + exits = ", ".join(run["exit"] for run in result["runs"]) + stage = result["runs"][-1]["lastStage"].split("] ", 1)[-1] + preamble = result["runs"][-1]["preamble"].split("Built preamble of size ", 1)[-1].split(" for file", 1)[0] + error = (result["runs"][-1]["errors"] or [""])[0].split("] ", 1)[-1][:110].replace("|", "/") + lines.append(f"| {result['name']} | {result['variant']} | {result['why']} | {exits} | {stage} | {preamble} | {error} |") + lines += ["", f"Variants that ended by an exception: {', '.join(crashed) or 'none'}.", ""] + for found in debugged: + lines += [f"### {found['name']} under cdb", "", "```"] + lines += tail(os.path.join(self.out, found["stack"]), 400)[:90] + lines += ["```", ""] + with open(os.path.join(self.out, "matrix.md"), "w", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + return 0 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--okrun", required=True) + parser.add_argument("--image", required=True, help="clangd.okb") + parser.add_argument("--elf", help="the image's ELF for this architecture (x86_64/clangd.oke)") + parser.add_argument("--project", required=True) + parser.add_argument("--file", default="src/ui/monitor_view.cppm") + parser.add_argument("--database", required=True, help="the directory of the engine database (compile_commands.json)") + parser.add_argument("--session", default="", help="drive_lsp.py's lsp-summary.json, for the arguments the server gave clangd") + parser.add_argument("--cdb", default="") + parser.add_argument("--procdump", default="") + parser.add_argument("--wer", default="", help="the directory Windows Error Reporting writes okrun.exe's dumps to") + parser.add_argument("--dumps", default="dumps") + parser.add_argument("--out", required=True) + parser.add_argument("--timeout", type=int, default=300) + parser.add_argument("--only-original", action="store_true", help="the file as it is, no variants; the switches only when it traps") + args = parser.parse_args() + args.project = os.path.abspath(args.project) + args.database = os.path.abspath(args.database) + args.dumps = os.path.abspath(args.dumps) + return Matrix(args).main() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/issue86/drive_lsp.py b/.github/issue86/drive_lsp.py new file mode 100644 index 00000000..42cbf916 --- /dev/null +++ b/.github/issue86/drive_lsp.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""Issue 86: the reporter's session, without an editor. + +Starts the released server the way the VS Code extension does (`okrun mcppls.okb serve --payload ...`), +opens the project and the files the reporter had open, and then behaves as an editor at rest does: +semantic tokens, document symbols, folding ranges and links for each open file, and a small edit now +and then. It watches the server's own log for clangd's exits and starts, records the command line of +every okrun process (the clangd the server started, with its arguments), and, when asked, restarts the +engine in a loop until the sandbox's grant table refuses a start. + +What it leaves in --out: the LSP trace, the diagnostics each file ended with, the processes seen, and a +summary; the server's logs, incidents and bundles stay in the cache directory for the workflow to copy. +""" +import argparse +import glob +import json +import os +import re +import subprocess +import sys +import threading +import time +from urllib.parse import quote + +# The reporter's initialization options (environment.json of bundle auto-engine-start-failed-20261112T065515Z). +INITIALIZATION_OPTIONS = { + "answer.agent": "cached", "answer.budget": "450", "answer.cache": "on", "answer.prefetch": True, + "buildDiscovery.askBeforeDownload": True, "buildDiscovery.mcppTarget": "auto", "buildDiscovery.mode": "auto", + "buildDiscovery.providers": ["mcpp", "cmake", "xmake", "meson", "compile-commands"], "buildProgram.apply": "auto", + "buildTool": "offline", "compiler": None, "completion": {"provenance": "subtle", "triggerOnSpace": True}, + "conflictArbitration": "client", "engine.confine": False, "engine.diagnosticsWhileTyping": "auto", "engine.name": "clangd", + "engine.preambleStorage": "auto", "engine.workers": "auto", "extensionVersion": "0.22.0", "format.fallbackStyle": "auto", + "grants": [], "index.primeImplementationUnits": "auto", "plugin": {"name": "vscode", "version": "0.22.0"}, + "resources": "balanced", "semanticKit": "auto", "semanticTokens": {"moduleType": True, "modules": True}, "toolEnvironment": "auto", +} + +CAPABILITIES = { + "workspace": {"configuration": True, "workspaceFolders": True, "didChangeWatchedFiles": {"dynamicRegistration": True}, + "semanticTokens": {"refreshSupport": True}, "executeCommand": {"dynamicRegistration": True}}, + "window": {"workDoneProgress": True, "showMessage": {}, "showDocument": {"support": True}}, + "textDocument": { + "synchronization": {"didSave": True}, + "publishDiagnostics": {"relatedInformation": True, "versionSupport": True, "codeDescriptionSupport": True, "dataSupport": True}, + "completion": {"completionItem": {"snippetSupport": True, "insertReplaceSupport": True, "labelDetailsSupport": True}}, + "hover": {"contentFormat": ["markdown", "plaintext"]}, + "documentSymbol": {"hierarchicalDocumentSymbolSupport": True}, + "foldingRange": {"lineFoldingOnly": True}, + "documentLink": {"tooltipSupport": True}, + "semanticTokens": {"requests": {"full": {"delta": True}, "range": True}, "formats": ["relative"], + "tokenTypes": ["namespace", "type", "class", "enum", "interface", "struct", "typeParameter", "parameter", "variable", + "property", "enumMember", "event", "function", "method", "macro", "keyword", "modifier", "comment", + "string", "number", "regexp", "operator", "decorator"], + "tokenModifiers": ["declaration", "definition", "readonly", "static", "deprecated", "abstract", "async", + "modification", "documentation", "defaultLibrary"]}, + }, +} + + +def uri_of(path): + path = os.path.abspath(path).replace("\\", "/") + if os.name == "nt" and len(path) > 1 and path[1] == ":": + # VS Code's spelling: a lower-case drive letter and an encoded colon. + return "file:///" + path[0].lower() + "%3A" + quote(path[2:]) + return "file://" + quote(path) + + +class Session: + def __init__(self, args): + self.args = args + self.out = os.path.abspath(args.out) + os.makedirs(self.out, exist_ok=True) + self.trace = open(os.path.join(self.out, "lsp-trace.jsonl"), "w", encoding="utf-8") + self.lock = threading.Lock() + self.next_id = 1 + self.pending = {} + self.diagnostics = {} + self.messages = [] + self.processes = {} + self.started = time.time() + self.closed = False + env = dict(os.environ) + env["MCPPLS_CACHE_DIR"] = os.path.abspath(args.cache) + if args.engine_arguments: + env["MCPPLS_ENGINE_ARGUMENTS"] = args.engine_arguments + command = [args.okrun, args.server, "serve", "--payload", os.path.abspath(args.payload), "--log-level", args.log_level] + print("server:", " ".join(command), flush=True) + self.process = subprocess.Popen(command, cwd=args.root, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=open(os.path.join(self.out, "server-stderr.log"), "wb")) + threading.Thread(target=self.read, daemon=True).start() + + def record(self, direction, message): + text = json.dumps(message, ensure_ascii=False) + with self.lock: + self.trace.write(json.dumps({"t": round(time.time() - self.started, 3), "dir": direction, "len": len(text), "msg": text[:4000]}) + "\n") + self.trace.flush() + + def send(self, message): + body = json.dumps(message).encode("utf-8") + self.record("out", message) + try: + with self.lock: + self.process.stdin.write(b"Content-Length: %d\r\n\r\n" % len(body) + body) + self.process.stdin.flush() + except (OSError, ValueError): + self.closed = True + + def request(self, method, params): + with self.lock: + identifier = self.next_id + self.next_id += 1 + self.pending[identifier] = method + self.send({"jsonrpc": "2.0", "id": identifier, "method": method, "params": params}) + return identifier + + def notify(self, method, params): + self.send({"jsonrpc": "2.0", "method": method, "params": params}) + + def read(self): + stream = self.process.stdout + while True: + header = b"" + while not header.endswith(b"\r\n\r\n"): + byte = stream.read(1) + if not byte: + self.closed = True + return + header += byte + length = 0 + for line in header.split(b"\r\n"): + if line.lower().startswith(b"content-length:"): + length = int(line.split(b":", 1)[1]) + try: + message = json.loads(stream.read(length)) + except ValueError: + continue + self.record("in", message) + method = message.get("method") + if method and "id" in message: + # A request of the server's: configuration is answered with nothing set, the rest with null. + result = None + if method == "workspace/configuration": + result = [None for _ in message.get("params", {}).get("items", [])] + self.send({"jsonrpc": "2.0", "id": message["id"], "result": result}) + elif method == "textDocument/publishDiagnostics": + params = message["params"] + self.diagnostics[params["uri"]] = [ + {"line": d["range"]["start"]["line"], "severity": d.get("severity"), "code": d.get("code"), "source": d.get("source"), "message": d.get("message", "")[:300]} + for d in params.get("diagnostics", [])] + elif method in ("window/showMessage", "window/logMessage"): + self.messages.append({"t": round(time.time() - self.started, 1), "method": method, "type": message["params"].get("type"), "message": message["params"].get("message", "")[:600]}) + elif method and method.startswith("mcppls/"): + self.messages.append({"t": round(time.time() - self.started, 1), "method": method, "params": json.dumps(message.get("params"))[:600]}) + elif "id" in message: + with self.lock: + self.pending.pop(message["id"], None) + + def log_counts(self): + """What the server's own log says so far: clangd's starts and exits, a refused start.""" + counts = {"starts": 0, "exits": 0, "refused": 0, "setAside": 0, "exitCodes": [], "refusal": "", "model": "", "producer": ""} + # Rotated files too (server-*.log.1, .2): a count that forgets them goes backwards. + for path in glob.glob(os.path.join(os.path.abspath(self.args.cache), "logs", "*.log*")): + try: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + for line in handle: + if "is given" in line and "directories" in line: + counts["starts"] += 1 + match = re.search(r"clangd exited with (-?\d+)", line) + if match: + counts["exits"] += 1 + counts["exitCodes"].append(int(match.group(1))) + if "could not be started" in line: + counts["refused"] += 1 + counts["refusal"] = line.strip()[:700] + if "aside from clangd" in line: + counts["setAside"] += 1 + if "project model (" in line and "source" in line: + counts["model"] = line.split("): ", 1)[-1].strip()[:200] + if "emit build-database" in line: + counts["producer"] = line.split("] ", 1)[-1].strip()[:400] + except OSError: + pass + return counts + + def snapshot_processes(self): + """The command line of every okrun process: the clangd the server started, with its arguments.""" + try: + if os.name == "nt": + script = "Get-CimInstance Win32_Process -Filter \"Name='okrun.exe'\" | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress" + done = subprocess.run(["powershell", "-NoProfile", "-Command", script], capture_output=True, text=True, timeout=30) + found = json.loads(done.stdout) if done.stdout.strip() else [] + found = found if isinstance(found, list) else [found] + for process in found: + line = process.get("CommandLine") or "" + if line and line not in self.processes: + self.processes[line] = {"pid": process.get("ProcessId"), "parent": process.get("ParentProcessId"), "t": round(time.time() - self.started, 1)} + else: + done = subprocess.run(["ps", "-eo", "pid,ppid,args"], capture_output=True, text=True, timeout=30) + for row in done.stdout.splitlines(): + pid, parent, line = (row.split(None, 2) + ["", "", ""])[:3] + if line.split(" ", 1)[0].endswith("/okrun") and ".okb" in line: + if line not in self.processes: + self.processes[line] = {"pid": int(pid), "parent": int(parent), "t": round(time.time() - self.started, 1)} + except (OSError, ValueError, subprocess.SubprocessError): + pass + + def run(self): + args = self.args + root = os.path.abspath(args.root) + self.request("initialize", { + "processId": os.getpid(), "clientInfo": {"name": "Visual Studio Code", "version": "1.146.0"}, "locale": "en", + "rootPath": root, "rootUri": uri_of(root), "workspaceFolders": [{"uri": uri_of(root), "name": os.path.basename(root)}], + "capabilities": CAPABILITIES, "initializationOptions": INITIALIZATION_OPTIONS, "trace": "off"}) + deadline = time.time() + 60 + while "initialize" in self.pending.values() and time.time() < deadline and not self.closed: + time.sleep(0.1) + self.notify("initialized", {}) + documents = {} + for relative in args.open: + path = os.path.join(root, relative) + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + documents[relative] = {"uri": uri_of(path), "version": 1, "text": text} + self.notify("textDocument/didOpen", {"textDocument": {"uri": uri_of(path), "languageId": "cpp", "version": 1, "text": text}}) + time.sleep(0.3) + + edited = args.open[0] if args.open else None + last_edit = time.time() + last_report = 0.0 + last_snapshot = 0.0 + end = time.time() + args.duration + counts = self.log_counts() + while time.time() < end and not self.closed: + for relative, document in documents.items(): + identifier = {"textDocument": {"uri": document["uri"]}} + self.request("textDocument/semanticTokens/full", identifier) + self.request("textDocument/documentSymbol", identifier) + self.request("textDocument/foldingRange", identifier) + self.request("textDocument/documentLink", identifier) + if edited and time.time() - last_edit > args.edit_every: + # A person's edit: a comment at the end of the file, replaced each time (full-document sync is always accepted). + document = documents[edited] + document["version"] += 1 + text = document["text"] + f"\n// issue 86: edit {document['version']}\n" + self.notify("textDocument/didChange", {"textDocument": {"uri": document["uri"], "version": document["version"]}, "contentChanges": [{"text": text}]}) + last_edit = time.time() + if time.time() - last_snapshot > 4: + self.snapshot_processes() + last_snapshot = time.time() + counts = self.log_counts() + if time.time() - last_report > 15: + print(f"[{int(time.time() - self.started):4d} s] clangd starts {counts['starts']}, exits {counts['exits']} {counts['exitCodes'][-5:]}, set aside {counts['setAside']}, refused {counts['refused']}; model: {counts['model']}", flush=True) + last_report = time.time() + if counts["refused"] or (args.stop_after_exits and counts["exits"] >= args.stop_after_exits): + break + time.sleep(args.poll) + + # The grant table: restart the engine until a start is refused, or the count is spent. + restarts = 0 + while restarts < args.restarts and not self.closed and not counts["refused"]: + self.request("workspace/executeCommand", {"command": "mcppls.restartEngine", "arguments": []}) + restarts += 1 + waited = time.time() + args.restart_wait + before = counts["starts"] + while time.time() < waited: + time.sleep(1) + counts = self.log_counts() + if counts["refused"] or counts["starts"] > before: + break + time.sleep(2) + print(f"restart {restarts}: clangd starts {counts['starts']}, exits {counts['exits']}, refused {counts['refused']}", flush=True) + counts = self.log_counts() + self.snapshot_processes() + + self.request("workspace/executeCommand", {"command": "mcppls.exportBundle", "arguments": []}) + time.sleep(20) + self.request("shutdown", None) + time.sleep(3) + self.notify("exit", None) + try: + self.process.wait(timeout=15) + except subprocess.TimeoutExpired: + self.process.kill() + summary = {"seconds": round(time.time() - self.started, 1), "counts": counts, "restartsAsked": restarts, "serverExit": self.process.poll(), + "diagnostics": self.diagnostics, "messages": self.messages[-200:], "processes": self.processes, "unanswered": sorted(set(self.pending.values()))} + with open(os.path.join(self.out, "lsp-summary.json"), "w", encoding="utf-8") as handle: + json.dump(summary, handle, indent=1, ensure_ascii=False) + lines = ["## The reporter's session, replayed (issue 86)", "", + f"- {summary['seconds']} s; clangd starts **{counts['starts']}**, exits **{counts['exits']}** (codes the server recorded: {sorted(set(counts['exitCodes']))}), files set aside {counts['setAside']}, engine restarts asked {restarts}", + f"- a start refused: **{'yes' if counts['refused'] else 'no'}** {('`' + counts['refusal'] + '`') if counts['refusal'] else ''}", + f"- the project model the session ended with: `{counts['model']}` {('; the producer: `' + counts['producer'] + '`') if counts['producer'] else ''}", ""] + for uri, found in self.diagnostics.items(): + lines.append(f"- `{uri.rsplit('/', 1)[-1]}`: {len(found)} diagnostics" + (": " + "; ".join(f"L{d['line'] + 1} {d['code']} {d['message'][:80]}" for d in found[:4]) if found else "")) + lines.append("") + for line in list(self.processes)[:8]: + lines.append(f"- process: `{line[:1500]}`") + with open(os.path.join(self.out, "lsp-summary.md"), "w", encoding="utf-8") as handle: + handle.write("\n".join(lines) + "\n") + print("\n".join(lines), flush=True) + return 0 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--okrun", required=True) + parser.add_argument("--server", required=True, help="payload/bin/mcppls.okb") + parser.add_argument("--payload", required=True) + parser.add_argument("--root", required=True) + parser.add_argument("--cache", required=True) + parser.add_argument("--out", required=True) + parser.add_argument("--open", nargs="*", default=[]) + parser.add_argument("--duration", type=int, default=300) + parser.add_argument("--poll", type=float, default=2.0) + parser.add_argument("--edit-every", type=float, default=20.0) + parser.add_argument("--stop-after-exits", type=int, default=0) + parser.add_argument("--restarts", type=int, default=0) + parser.add_argument("--restart-wait", type=int, default=25) + parser.add_argument("--engine-arguments", default="") + parser.add_argument("--log-level", default="info", help="the reporter's is info; debug also makes clangd's log verbose") + parser.add_argument("--settle", type=int, default=0, help="seconds to wait, after the files are opened, for the build tool's model") + return Session(parser.parse_args()).run() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/issue86/symbolize.py b/.github/issue86/symbolize.py new file mode 100644 index 00000000..4afc7cb3 --- /dev/null +++ b/.github/issue86/symbolize.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Issue 86: name the frames of a trap inside an ok image. + +An ok image (clangd.okb) is a zip of one static-pie ELF per architecture, not stripped. okrun maps it +itself, so a Windows debugger sees no module there: only raw addresses. This script takes what cdb +printed at the trap (capture.cdb) and the image's ELF, finds where the image was mapped by matching +the bytes around the instruction pointer against the ELF's text, and names each return address from +the ELF's own symbol table. + + symbolize.py --elf x86_64/clangd.oke --cdb-output run.cdb.txt [--out stack.txt] +""" +import argparse +import bisect +import re +import shutil +import struct +import subprocess +import sys + + +class Elf: + def __init__(self, path): + with open(path, "rb") as handle: + self.data = handle.read() + data = self.data + if data[:4] != b"\x7fELF" or data[4] != 2 or data[5] != 1: + raise ValueError(f"{path}: not a little-endian ELF64") + (self.phoff, self.shoff) = struct.unpack_from("= value + size + 16: + return None + return name, vaddr - value + + +def number(text): + return int(text.replace("`", ""), 16) + + +def parse_capture(text): + capture = {"rip": None, "rbp": None, "rsp": None, "bytes": {}, "frames": [], "stack": [], "exception": None} + for match in re.finditer(r"\b(rip|rbp|rsp)=([0-9a-fA-F`]{8,17})", text): + if capture[match.group(1)] is None: + capture[match.group(1)] = number(match.group(2)) + match = re.search(r"ExceptionCode:\s*([0-9a-fA-F]+)", text) + if match: + capture["exception"] = match.group(1) + section = None + for line in text.splitlines(): + marker = re.match(r"^===([A-Z]+)===\s*$", line.strip()) + if marker: + section = marker.group(1) + continue + if section == "BYTES": + match = re.match(r"^([0-9a-fA-F`]{8,17})\s+((?:[0-9a-fA-F]{2}[ -])+)", line) + if match: + address = number(match.group(1)) + for offset, byte in enumerate(re.findall(r"[0-9a-fA-F]{2}", match.group(2))[:16]): + capture["bytes"][address + offset] = int(byte, 16) + elif section == "CHAIN": + match = re.match(r"^FRAME\s+([0-9a-fA-F`]+)\s+RET\s+([0-9a-fA-F`]+)", line.strip()) + if match: + capture["frames"].append((number(match.group(1)), number(match.group(2)))) + elif section == "STACK": + match = re.match(r"^([0-9a-fA-F`]{8,17})\s+([0-9a-fA-F`]{8,17})", line) + if match: + capture["stack"].append((number(match.group(1)), number(match.group(2)))) + return capture + + +def locate_base(elf, capture): + """The address the image's virtual address 0 was mapped at: the bytes around rip, found in the ELF.""" + rip = capture["rip"] + if rip is None or not capture["bytes"]: + return None, "no instruction pointer or bytes in the capture" + addresses = sorted(capture["bytes"]) + for length in (64, 48, 32, 24, 16): + # A window that ends just past the trapping instruction. + start = max(addresses[0], rip + 2 - length) + window = bytes(capture["bytes"].get(start + i, -1) & 0xFF for i in range(length)) if all((start + i) in capture["bytes"] for i in range(length)) else None + if window is None: + continue + found = elf.find_bytes(window) + if len(found) == 1: + return start - found[0], f"{length} bytes around rip match the image's text once" + if len(found) > 1: + bases = sorted({start - vaddr for vaddr in found}) + aligned = [base for base in bases if base % 0x1000 == 0] + if len(aligned) == 1: + return aligned[0], f"{length} bytes around rip match {len(found)} places, one page-aligned" + return None, f"{length} bytes around rip match {len(found)} places in the image" + return None, "the bytes around rip are nowhere in the image's text (the trap is outside the image)" + + +def demangle(names): + tool = shutil.which("llvm-cxxfilt") or shutil.which("c++filt") + if not tool or not names: + return {} + try: + done = subprocess.run([tool], input="\n".join(names), capture_output=True, text=True, timeout=60) + lines = done.stdout.splitlines() + if len(lines) == len(names): + return dict(zip(names, lines)) + except (OSError, subprocess.SubprocessError): + pass + return {} + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--elf", required=True) + parser.add_argument("--cdb-output", required=True) + parser.add_argument("--out") + parser.add_argument("--base", help="the image's base, when it is known (hex)") + args = parser.parse_args() + + with open(args.cdb_output, "r", encoding="utf-8", errors="replace") as handle: + capture = parse_capture(handle.read()) + elf = Elf(args.elf) + lines = [] + emit = lines.append + emit(f"exception {capture['exception']} rip {capture['rip'] and hex(capture['rip'])} rsp {capture['rsp'] and hex(capture['rsp'])} rbp {capture['rbp'] and hex(capture['rbp'])}") + if capture["rip"] is not None and capture["rip"] in capture["bytes"]: + at = capture["rip"] + emit("bytes at rip: " + " ".join(f"{capture['bytes'].get(at + i, 0):02x}" for i in range(8) if (at + i) in capture["bytes"])) + if args.base: + base, how = int(args.base, 16), "given" + else: + base, how = locate_base(elf, capture) + emit(f"image base: {hex(base) if base is not None else 'unknown'} ({how}); image span {hex(elf.span)}; {len(elf.symbols)} function symbols") + if base is None: + text = "\n".join(lines) + "\n" + sys.stdout.write(text) + if args.out: + open(args.out, "w", encoding="utf-8").write(text) + return 1 + + def inside(address): + return base <= address < base + elf.span and elf.executable(address - base) + + wanted = [] + + def describe(address): + if not inside(address): + return None + named = elf.name(address - base) + if not named: + return f"image+{address - base:#x}" + wanted.append(named[0]) + return (named[0], named[1], address - base) + + rows = [] + rows.append(("TRAP", capture["rip"], describe(capture["rip"]))) + for frame, ret in capture["frames"]: + rows.append((f"fp {frame:#x}", ret, describe(ret))) + scan = [] + for slot, value in capture["stack"]: + described = describe(value) + if described: + scan.append((f"sp {slot:#x}", value, described)) + pretty = demangle(sorted(set(wanted))) + + def show(row): + where, address, described = row + if described is None: + return f" {where:<22} {address:#018x} (outside the image)" + if isinstance(described, str): + return f" {where:<22} {address:#018x} {described}" + name, offset, image_offset = described + return f" {where:<22} {address:#018x} image+{image_offset:#x} {pretty.get(name, name)}+{offset:#x}" + + emit("") + emit("== the trap and its frame-pointer chain (innermost first) ==") + for row in rows: + emit(show(row)) + emit("") + emit(f"== every stack slot that points into the image's text ({len(scan)} of {len(capture['stack'])} slots; a superset of the frames) ==") + for row in scan[:400]: + emit(show(row)) + text = "\n".join(lines) + "\n" + sys.stdout.write(text) + if args.out: + with open(args.out, "w", encoding="utf-8") as handle: + handle.write(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index e8164bfc..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,2279 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - inputs: - mcpp_ref: - description: "A branch of mcpp-community/mcpp to build and test with (empty: .github/versions.env's MCPP_SOURCE_REF, else the released mcpp)" - required: false - default: '' - native: - description: "Also build and test the native server of every platform (it is not distributed)" - type: boolean - required: false - default: false - workflow_call: - inputs: - mcpp_ref: - description: "A branch of mcpp-community/mcpp to build and test with (empty: .github/versions.env's)" - type: string - required: false - default: '' - native: - description: "Also build and test the native server of every platform (it is not distributed)" - type: boolean - required: false - default: false - -# The caller's name is part of the group on purpose. `github.workflow` is the CALLING workflow's -# name inside a `workflow_call`, so Release's copy of these jobs groups as `ci-Release-` and a -# push-triggered run as `ci-CI-`. Without that they share one group on `main` and, with -# cancel-in-progress, the release's own verification and the merge's CI cancel each other — -# whichever started second wins, and a release that lost is a release with nothing verified. -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - # The build pins live in .github/versions.env and are exported by .github/actions/setup-mcpp, - # so a bump lands in one file instead of three. A job may still set one to override it. - XLINGS_NON_INTERACTIVE: '1' - PAYLOAD_CACHE: ${{ github.workspace }}/.payload-cache - # A branch of mcpp under review: a dispatch's `mcpp_ref`, else the repository variable, else versions.env's - # MCPP_SOURCE_REF (setup-mcpp reads it when this is empty). - MCPP_SOURCE_REF: ${{ inputs.mcpp_ref || vars.MCPP_SOURCE_REF || '' }} - # The tools mcpp.toml's [xlings.workspace] declares for packaging (`when = "dev"`: Node for the VS Code - # extension, Rust for Zed) are the runner's in this workflow (mcpp's `MCPP_XLINGS_OVERRIDE__`): - # no job here packages through `mcpp run -p devtools -- extension` -- the ones that run npm set Node up - # -- yet every `mcpp build` and `mcpp pack` at the root provisioned both, and the download of Node - # stalled past xlings' deadline on job after job (2026-10-28). release-checks' plugins job, a workflow - # of its own, still provisions them: that is what it shows. - MCPP_XLINGS_OVERRIDE_XIM_NODE: path:node - MCPP_XLINGS_OVERRIDE_XIM_RUST: path:cargo - -jobs: - # --------------------------------------------------------------------------- - # WHAT THE CHANGE REACHES (docs/92-release.md "Versions"; .github/components.tsv). A pull request runs - # the jobs of the components it changes: the server's change runs everything, a plugin's runs that - # plugin's jobs, the documentation's its checks. A push, a dispatch, a release or a pull request from - # or into a release branch runs everything. A plugin's jobs test it over the payload of the server it - # is changed against: the payload main built from the same server inputs (`digest`), lent by that - # run (`lend`, its artifacts kept 7 days); when there is none, the server is built as well. - changes: - name: what the change reaches - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: - contents: read - actions: read - outputs: - server: ${{ steps.classify.outputs.server }} - vscode: ${{ steps.classify.outputs.vscode }} - clion: ${{ steps.classify.outputs.clion }} - zed: ${{ steps.classify.outputs.zed }} - nvim: ${{ steps.classify.outputs.nvim }} - agents: ${{ steps.classify.outputs.agents }} - zed_real: ${{ steps.classify.outputs.zed_real }} - packaging: ${{ steps.classify.outputs.packaging }} - digest: ${{ steps.classify.outputs.digest }} - lend: ${{ steps.classify.outputs.lend }} - steps: - # The merge commit and its first parent, the base it merges into: their difference is what the pull - # request changes, with no API call. - - uses: actions/checkout@v7 - with: - fetch-depth: 2 - - name: The classification's own cases - run: bash .github/scripts/test_changes.sh - - id: classify - env: - EVENT: ${{ github.event_name }} - BASE_REF: ${{ github.base_ref }} - HEAD_REF: ${{ github.head_ref }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - changed="$RUNNER_TEMP/changed.txt" - if [ "$EVENT" = pull_request ]; then git diff --name-only HEAD^1 HEAD > "$changed"; else : > "$changed"; fi - bash .github/scripts/changes.sh "$changed" > "$RUNNER_TEMP/reached.txt" - cat "$RUNNER_TEMP/reached.txt" - get() { sed -n "s/^$1=//p" "$RUNNER_TEMP/reached.txt"; } - lend="" - if [ "$(get server)" = false ]; then - plugins=false - for c in vscode clion zed nvim agents; do [ "$(get "$c")" = true ] && plugins=true; done - if [ "$plugins" = true ]; then - # The newest main run that built the payload from these server inputs, still within its artifacts' days. - lend=$(gh api "repos/${{ github.repository }}/actions/artifacts?name=server-digest-$(get digest)&per_page=20" \ - --jq '[.artifacts[] | select(.expired == false and .workflow_run.head_branch == "main")][0].workflow_run.id // ""') - if [ -z "$lend" ]; then - echo "no main run lends the payload of these server inputs: the server is built as well" - sed -i 's/^\(server\|vscode\|clion\|zed\|nvim\|agents\)=.*/\1=true/' "$RUNNER_TEMP/reached.txt" - else - echo "the payload of run $lend, built from the same server inputs" - fi - fi - fi - cat "$RUNNER_TEMP/reached.txt" >> "$GITHUB_OUTPUT" - echo "lend=$lend" >> "$GITHUB_OUTPUT" - - # The repository's invariants (docs/93-devtools.md) when the server is not built: the build job checks - # them where it builds devtools anyway. - invariants: - name: repository invariants - needs: changes - if: needs.changes.outputs.server != 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 30 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - run: | - set -euo pipefail - mcpp build -p devtools - devtools=$(find target -type f -name mcppls-devtools -path '*/bin/*' | head -1) - "$devtools" check all - - # --------------------------------------------------------------------------- - # The specifications' schemas, examples and the fixtures' simulated producer data (docs/specs/tools/validate.py). - # The one Python this repository keeps (docs/93-devtools.md "What is not C++"): JSON Schema 2020-12 - # validation, which no C++ package in the mcpp index provides yet. - specifications: - name: specifications - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7 - - name: Schemas, examples, text blocks and links - shell: bash - run: | - python3 -m venv "$RUNNER_TEMP/specs" - "$RUNNER_TEMP/specs/bin/pip" install --quiet 'jsonschema>=4.18' - "$RUNNER_TEMP/specs/bin/python" docs/specs/tools/validate.py | grep -v '^PASS' - - name: Every action a workflow names resolves - shell: bash - run: python3 .github/scripts/check-action-refs.py - - # The server and its tests can compile while a candidate engine finishes. Only - # payload assembly waits: every part and its provenance must be ready first. - engine-candidate-parts: - name: engine candidate parts - needs: changes - if: needs.changes.outputs.server == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 100 - steps: - - uses: actions/checkout@v7 - - name: Candidate admission controls - run: python3 .github/scripts/test_wait_engine_candidate.py - - name: Complete, verified candidate parts - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - [ -f packaging/engine-candidate.json ] || exit 0 - python3 .github/scripts/wait-engine-candidate.py \ - --candidate packaging/engine-candidate.json \ - --output "$RUNNER_TEMP/engine-candidate-admission.json" - - uses: actions/upload-artifact@v7 - if: always() - with: - name: engine-candidate-admission - path: ${{ runner.temp }}/engine-candidate-admission.json - if-no-files-found: ignore - retention-days: 7 - - # --------------------------------------------------------------------------- - # The mcpp every job builds with, on each system: the pinned release, or a branch of mcpp under review - # (MCPP_SOURCE_REF). Each consumer also calls setup-mcpp and restores its own compiler cache. - # Keep these per-host warmers independent: a queued host must not serialize other hosts' builds. - mcpp: - name: mcpp (${{ matrix.os }}) - needs: changes - if: needs.changes.outputs.server == 'true' - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - os: [ubuntu-24.04, ubuntu-24.04-arm, macos-15, windows-2022] - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - run: mcpp --version - - # --------------------------------------------------------------------------- - # BUILT ONCE, ON LINUX (ecosystem design 2026-10-29 §3.4): every platform's server program, tools and unit - # tests are built here, the tests written as archives (`mcpp test --no-run --archive`, mcpp SPEC-015 §4) that - # the `test` job runs on the platform's own system. The release rows also give the platform's test tools - # (stage/: the conformance runner and its cases, the mocks, devtools) that every later job of that platform runs. The - # targets are packaging/payload.lock.json's `server-target`s; `mcppls-devtools check platforms` holds this - # matrix to them. `--workspace` cannot write an archive, so each package with tests writes its own. - build: - name: build (${{ matrix.platform }}, ${{ matrix.profile }}) - needs: changes - if: needs.changes.outputs.server == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 120 - strategy: - fail-fast: false - # Every platform's release row builds its test tools (stage/), which every later job of the platform - # runs against the okb; the dev rows, and the native server's tests outside Linux x64, are a run's that - # asks for `native` (the server is distributed only as the ok image). The release rows are the base; - # the Linux x64 dev row is added by its own include, which merges into the base when `native` adds - # every dev row. - matrix: - platform: - - linux-x64 - - linux-arm64 - - darwin-arm64 - - win32-x64 - profile: ${{ inputs.native && fromJSON('["dev","release"]') || fromJSON('["release"]') }} - include: - - { platform: linux-x64, target: x86_64-linux-gnu, exe: '' } - - { platform: linux-arm64, target: aarch64-linux-musl, exe: '' } - - { platform: darwin-arm64, target: aarch64-macos, exe: '' } - - { platform: win32-x64, target: x86_64-windows-gnu, exe: '.exe' } - - { platform: linux-x64, target: x86_64-linux-gnu, exe: '', profile: dev } - env: - # Whether this row builds its native server's tests for the `test` job. - NATIVE_TESTS: ${{ (inputs.native || matrix.platform == 'linux-x64') && 'yes' || 'no' }} - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: Build the server, its tools and devtools for ${{ matrix.target }} - run: | - set -euo pipefail - mcpp build --profile ${{ matrix.profile }} --target ${{ matrix.target }} - mcpp build -p devtools --profile ${{ matrix.profile }} --target ${{ matrix.target }} - mcpp build -p mcppls-cts --profile ${{ matrix.profile }} --target ${{ matrix.target }} - mcpp build -p mcppls-bench --profile ${{ matrix.profile }} --target ${{ matrix.target }} - - name: The executables are programs of ${{ matrix.target }} - run: | - set -euo pipefail - mkdir -p stage - # The request selects an identity (mcpp 2026.11.9.1, SPEC-016 §4): `aarch64-macos` from a Linux host is - # `aarch64-macos-musl`, served by the platform's hosted arrangement, and its outputs are under that name. - identity=$(mcpp why target --target ${{ matrix.target }} --format json | jq -r '.data.target.identity') - [ -n "$identity" ] && [ "$identity" != null ] || { echo "::error::no identity for ${{ matrix.target }}"; exit 1; } - echo "${{ matrix.target }} -> $identity" - for exe in mcppls mcppls-conformance mcppls-cts mcppls-bench mcppls-lspgen mcppls-mock-mcpp mcppls-mock-model mcppls-devtools; do - path=$(find "target/$identity" -type f -name "$exe${{ matrix.exe }}" -path '*/bin/*' | head -1) - [ -n "$path" ] || { echo "::error::no $exe was built"; exit 1; } - file "$path" - case "${{ matrix.target }}" in - *windows*) file "$path" | grep -q 'PE32+ executable' ;; - *macos*) file "$path" | grep -q 'Mach-O 64-bit arm64' ;; - aarch64-linux*) file "$path" | grep -q 'ELF 64-bit LSB .*ARM aarch64' ;; - x86_64-linux*) file "$path" | grep -q 'ELF 64-bit LSB .*x86-64' ;; - esac - case "$exe" in mcppls|mcppls-lspgen) ;; *) cp "$path" stage/ ;; esac - done - (cd stage && sha256sum * | tee SHA256SUMS) - # Reuse this native build for early CLion runtime regression. It must not - # wait for candidate engine admission or rebuild LLVM for an adapter test. - - name: The current native Linux server - if: matrix.platform == 'linux-x64' && matrix.profile == 'release' - run: | - set -euo pipefail - mkdir -p native-server - server=$(find target/${{ matrix.target }} -type f -name mcppls -path '*/bin/*' -print -quit) - test -n "$server" - cp "$server" native-server/mcppls - native-server/mcppls version - (cd native-server && sha256sum mcppls > SHA256SUMS) - - uses: actions/upload-artifact@v7 - if: matrix.platform == 'linux-x64' && matrix.profile == 'release' - with: - name: native-server-linux-x64 - path: native-server - if-no-files-found: error - retention-days: 7 - - name: The tests, built once and archived - if: env.NATIVE_TESTS == 'yes' && (matrix.platform != 'win32-x64' || matrix.profile != 'dev') - run: | - set -euo pipefail - mkdir -p test-archives - common=(--profile ${{ matrix.profile }} --target ${{ matrix.target }} --no-run) - # The server's own tests, then each member's that has tests. The server's dev tests with their debug - # information are more than 4 GiB, one gzip member since mcpp 2026.10.30.1. - echo "::group::mcppls" - mcpp test "${common[@]}" --archive test-archives/mcppls.tar.gz - echo "::endgroup::" - for package in pack devtools model-gateway harness; do - echo "::group::$package" - mcpp test -p "$package" "${common[@]}" --archive "test-archives/$package.tar.gz" - echo "::endgroup::" - done - ls -l test-archives - - name: The Windows dev tests, archived before reclaiming their executable space - if: env.NATIVE_TESTS == 'yes' && matrix.platform == 'win32-x64' && matrix.profile == 'dev' - env: - ARCHIVE_TARGET: ${{ matrix.target }} - ARCHIVE_PROFILE: ${{ matrix.profile }} - run: | - set -euo pipefail - # Keep every dev test and its debug information. Bounded raw groups - # become one verified container per package before native execution. - python3 .github/scripts/test_archive_tests.py - python3 .github/scripts/archive-tests.py \ - --target "$ARCHIVE_TARGET" --profile "$ARCHIVE_PROFILE" - # The repository's invariants (docs/93-devtools.md): versions agree everywhere, the platform - # surface is six constants, members depend only downward and nothing but devtools reaches - # packaging, no script outside the allowlist -- and the server's own symbol table carries no - # archive or TLS code, which is the one of these a green build cannot show. - - name: Repository invariants - if: matrix.platform == 'linux-x64' && matrix.profile == 'dev' - run: | - set -euo pipefail - devtools=$(find target -type f -name mcppls-devtools -path '*/bin/*' | head -1) - server=$(find target -type f -name mcppls -path '*/bin/*' | head -1) - "$devtools" check all - "$devtools" check binary --server "$server" - - name: Generated protocol is fresh - if: matrix.platform == 'linux-x64' && matrix.profile == 'dev' - run: | - mcpp run mcppls-lspgen -- generate --meta-model vendor/lsp-metamodel/metaModel-3.18.json --out src/lsp - git diff --exit-code src/lsp - - uses: actions/upload-artifact@v7 - if: env.NATIVE_TESTS == 'yes' - with: - name: tests-${{ matrix.platform }}-${{ matrix.profile }} - path: test-archives - compression-level: 0 - retention-days: 7 - # A tarball keeps executable bits, which an artifact zip does not. - - name: The test tools of ${{ matrix.platform }} - if: matrix.profile == 'release' - run: tar -czf stage-${{ matrix.platform }}.tar.gz stage - - uses: actions/upload-artifact@v7 - if: matrix.profile == 'release' - with: - name: stage-${{ matrix.platform }} - path: stage-${{ matrix.platform }}.tar.gz - retention-days: 7 - - # --------------------------------------------------------------------------- - # The unit tests, on each platform's own system, from the archives `build` wrote: the bytes built once - # on Linux, run where they will run (mcpp SPEC-015 §4). The payloads ship release builds, and every - # release-only defect found so far (design 12.9, K7 and K13) was invisible to the dev profile. - test: - name: unit tests (${{ matrix.platform }}, ${{ matrix.profile }}) - needs: build - runs-on: ${{ matrix.os }} - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04, profile: dev } - - { platform: linux-x64, os: ubuntu-24.04, profile: release } - - { platform: linux-arm64, os: ubuntu-24.04-arm, profile: dev } - - { platform: linux-arm64, os: ubuntu-24.04-arm, profile: release } - - { platform: darwin-arm64, os: macos-15, profile: dev } - - { platform: darwin-arm64, os: macos-15, profile: release } - - { platform: win32-x64, os: windows-2022, profile: dev } - - { platform: win32-x64, os: windows-2022, profile: release } - # The native server is not distributed: its tests run on the other systems only in a run that asks for - # `native`, and a row of another system does nothing otherwise. The okb is tested on every system by - # `openkal-test` and the jobs that take the payload. - env: - NATIVE_ROW: ${{ (inputs.native || matrix.platform == 'linux-x64') && 'yes' || 'no' }} - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - if: env.NATIVE_ROW == 'yes' - - uses: ./.github/actions/setup-mcpp - if: env.NATIVE_ROW == 'yes' - - uses: actions/download-artifact@v8 - if: env.NATIVE_ROW == 'yes' - with: - name: tests-${{ matrix.platform }}-${{ matrix.profile }} - path: test-archives - - name: Run the archived tests - if: env.NATIVE_ROW == 'yes' - run: | - set -euo pipefail - failed='' - for archive in test-archives/*.tar.gz; do - echo "::group::$archive" - # The released mcpp can leave a long Windows test path behind when - # it removes this extraction directory. Use the host's filesystem - # implementation before the next archive; every test still runs. - # Windows may briefly retain an executable after its test exits. - # Retry sharing violations only, within a fixed cleanup budget. - python - <<'PYCODE' - from pathlib import Path - import shutil - import time - extraction = Path('target/.test-archive/run') - deadline = time.monotonic() + 10 - while extraction.exists(): - try: - shutil.rmtree(extraction) - except OSError as error: - if getattr(error, 'winerror', None) not in (32, 33) or time.monotonic() >= deadline: - raise - time.sleep(0.1) - PYCODE - if ! mcpp test --archive "$archive"; then failed="$failed $(basename "$archive" .tar.gz)"; fi - echo "::endgroup::" - done - if [ -n "$failed" ]; then echo "::error::failed:$failed"; exit 1; fi - - uses: ./.github/actions/test-report - if: always() && env.NATIVE_ROW == 'yes' - with: - name: mtest-unit-${{ matrix.platform }}-${{ matrix.profile }} - - # --------------------------------------------------------------------------- - # THE SERVER'S UNIT TESTS AS USERS RUN IT: the ok images `server` built, on each platform's own system through its - # runner, the leg of the system's architecture (mcpp SPEC-015 §4). The distributed server is the okb; these are - # its tests in the same form, and the native `test` rows are the tools' and the native build's. - openkal-test: - name: unit tests (${{ matrix.platform }}, openkal) - needs: server - runs-on: ${{ matrix.os }} - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04 } - - { platform: linux-arm64, os: ubuntu-24.04-arm } - - { platform: darwin-arm64, os: macos-15 } - - { platform: win32-x64, os: windows-2022 } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - uses: actions/download-artifact@v8 - with: - name: tests-openkal - path: test-archives - - name: Run the archived tests through this system's runner - run: mcpp test --archive test-archives/mcppls.tar.gz - - uses: ./.github/actions/test-report - if: always() - with: - name: mtest-unit-${{ matrix.platform }}-openkal - - # THE UNIT TESTS OF EVERY SYSTEM AS ONE REPORT: each job's runs, taken into one store, reported together - # (`mtest ci report`, the workflow run's group): the summary, the failures, the slowest cases and the - # critical path of each run. - unit-test-report: - name: unit tests (report) - needs: [test, openkal-test] - if: always() - runs-on: ubuntu-24.04 - timeout-minutes: 10 - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/mtest - - uses: actions/download-artifact@v8 - with: - pattern: mtest-unit-* - path: ${{ runner.temp }}/stores - - name: Every job's runs, reported together - run: | - set -euo pipefail - found=0 - for store in "$RUNNER_TEMP"/stores/*/; do - [ -f "$store/index.ndjson" ] || continue - "$MTEST" store import --from "$store" - found=1 - done - if [ "$found" = 0 ]; then echo "no job kept a run of the unit tests" >> "$GITHUB_STEP_SUMMARY"; exit 0; fi - "$MTEST" ci report --out "$RUNNER_TEMP/unit-tests.md" - - # --------------------------------------------------------------------------- - # THE HOST GRAPH ON THE PLATFORM (mcpp SPEC-013 §6): with `--host-platform openkal-platform@0.24.0` - # every build program of the graph -- openkal-platform's and openkal-musl's build.mcpp -- compiles as - # an ok image of this machine's architecture, with one toolchain on each system, and runs through the - # platform's runner. The server and the tools built over them are the same programs, and start. - host-platform: - name: build programs as images (${{ matrix.os }}) - needs: changes - if: needs.changes.outputs.server == 'true' - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - { os: ubuntu-24.04, target: '' } - - { os: macos-15, target: '--target aarch64-macos' } - - { os: windows-2022, target: '--target x86_64-windows-gnu' } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: Build with the host graph's platform - run: | - set -euo pipefail - mcpp build --host-platform openkal-platform@0.24.0 ${{ matrix.target }} 2>&1 | tee build.log - grep -q 'Host platform openkal-platform 0.24.0 (--host-platform): build programs are [a-z0-9_]*-openkal images run by okrun' build.log - mcpp build -p devtools --host-platform openkal-platform@0.24.0 ${{ matrix.target }} - for exe in mcppls mcppls-devtools; do - path=$(find target -type f \( -name "$exe" -o -name "$exe.exe" \) -path '*/bin/*' -newer mcpp.toml | head -1) - echo "$exe -> $path" - if [ "$exe" = mcppls-devtools ]; then "$path" --help > /dev/null; else "$path" version; fi - done - - # --------------------------------------------------------------------------- - # THE SERVER OF EVERY SYSTEM AND ARCHITECTURE (mcpp SPEC-014 2.0 §4.2–§4.4; ecosystem design 2026-10-29 §9): - # `mcpp pack --target openkal` packs the `openkal` set -- x86_64-openkal and aarch64-openkal -- as one tree: - # both images under `lib//`, the POSIX launcher `bin/mcppls` and the Windows launcher program - # `bin/mcppls.exe`, and with `--mode self-contained --systems all` the platform's runner of every system it - # builds one for under `libexec/okrun/-/`; its mcpp-package.json says where it runs by itself - # (`runs_on.self`). One build writes the tree's zip and the okb every payload carries (`--pkg-format - # zip,okb`); the single files cover every leg and system (`--pkg-format single-file --systems all`). - server: - name: the server (one tree, one okb, the single files) - needs: changes - if: needs.changes.outputs.server == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 90 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: Pack the tree and the okb - run: | - set -euo pipefail - version=$(sed -n 's/^version *= *"\(.*\)"$/\1/p' mcpp.toml | head -1) - mcpp pack --profile release --target openkal --mode self-contained --systems all --pkg-format zip,okb \ - -o "$PWD/packed/" --message-format json > pack.json - jq '.data | {pkgFormats, legs, systems, runsOn, artifacts}' pack.json - mkdir -p server - for artifact in $(jq -r '.data.artifacts[].path' pack.json); do - case "$artifact" in - *.okb) cp "$artifact" "server/mcppls-$version.okb" ;; - *.zip) cp "$artifact" "server/mcppls-$version-openkal.zip" ;; - esac - done - ls -l server - test -f "server/mcppls-$version.okb" && test -f "server/mcppls-$version-openkal.zip" - echo "VERSION=$version" >> "$GITHUB_ENV" - # Where it runs by itself is every platform of packaging/payload.lock.json, and the tree is what it says. - - name: The tree runs on every system it carries a runner for - run: | - set -euo pipefail - python3 - "server/mcppls-$VERSION-openkal.zip" <<'PY' - import hashlib, json, sys, zipfile - z = zipfile.ZipFile(sys.argv[1]) - top = z.namelist()[0].split("/")[0] - d = json.loads(z.read(f"{top}/mcpp-package.json")) - self_ = d["runs_on"]["self"] - assert self_ == ["linux-aarch64", "linux-x86_64", "macos-aarch64", "windows-x86_64"], self_ - assert sorted(d["legs"]) == ["aarch64-openkal", "x86_64-openkal"], d["legs"] - names = set(z.namelist()) - for launcher in ("bin/mcppls", "bin/mcppls.exe"): - assert f"{top}/{launcher}" in names, launcher - assert f"{top}/bin/mcppls.cmd" not in names, "no .cmd launcher" - for system in self_: - runner = f"{top}/libexec/okrun/{system}/okrun" + (".exe" if system.startswith("windows") else "") - assert runner in names, runner - for f in d["files"]: - assert hashlib.sha256(z.read(f"{top}/{f['path']}")).hexdigest() == f["sha256"], f["path"] - print(top, "runs on", self_, "with okrun", d["runs_on"].get("with"), len(d["files"]), "files") - PY - mkdir -p tree && python3 -c "import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" "server/mcppls-$VERSION-openkal.zip" tree - top=$(ls -d tree/*/) - chmod +x "$top/bin/mcppls" "$top"/libexec/okrun/*/okrun - env -i HOME="$HOME" PATH=/usr/bin:/bin "$top/bin/mcppls" version | tee version.txt - grep -q '^mcppls ' version.txt - echo "TREE=$top" >> "$GITHUB_ENV" - - name: The images carry no archive or TLS code - run: | - set -euo pipefail - mcpp build -p devtools - devtools=$(find target -type f -name mcppls-devtools -path '*/bin/*' | head -1) - for arch in x86_64 aarch64; do "$devtools" check binary --server "$TREE/lib/$arch/mcppls.oke"; done - # One okb of both architectures (openkal-platform's `okb` format; okb v1 is openkal-image's): what the payload, - # and so every editor's plugin, carries as its server. Its images are the tree's, stored as they are. - - name: The okb - run: | - set -euo pipefail - mcpp toolchain install --target x86_64-openkal - tools=$(mcpp self env --target x86_64-openkal --format json | jq -c '.data.tools') - okld=$(jq -r .okld <<< "$tools"); okrun=$(jq -r .okrun <<< "$tools") - "$okld" bundle check "server/mcppls-$VERSION.okb" - python3 - "server/mcppls-$VERSION.okb" "$TREE" <<'PY' - import hashlib, sys, zipfile - okb = zipfile.ZipFile(sys.argv[1]) - for arch in ("x86_64", "aarch64"): - want = hashlib.sha256(open(f"{sys.argv[2]}/lib/{arch}/mcppls.oke", "rb").read()).hexdigest() - got = hashlib.sha256(okb.read(f"{arch}/mcppls.oke")).hexdigest() - assert want == got, (arch, want, got) - assert okb.getinfo(f"{arch}/mcppls.oke").compress_type == zipfile.ZIP_STORED, arch - print("okb images:", [i.filename for i in okb.infolist()]) - PY - "$okrun" "server/mcppls-$VERSION.okb" version - # The command line's server as one file per system and architecture: the platform's carrier with the image - # inside, which loads it from memory (SPEC-014 §5.2), for every leg and system this mcpp builds a carrier for. - - name: Single files - run: | - set -euo pipefail - mcpp pack --profile release --target openkal --pkg-format single-file --systems all -o "$PWD/single-packed/" \ - --message-format json > single.json - for file in $(jq -r '.data.artifacts[].path' single.json); do - name=$(basename "$file") - cp "$file" "server/mcppls-$VERSION-${name#mcpp-language-server-$VERSION-}" - done - for file in linux-x86_64 linux-aarch64 macos-aarch64 windows-x86_64.exe; do - test -f "server/mcppls-$VERSION-$file" || { echo "::error::no single file for $file"; exit 1; } - done - "server/mcppls-$VERSION-linux-x86_64" version - (cd server && sha256sum * | tee SHA256SUMS) - - uses: actions/upload-artifact@v7 - with: - name: server - path: server - retention-days: 7 - # The server's unit tests as what users run: ok images of both architectures, built once here and run on - # every system by `openkal-test` (mcpp `test --archive`, SPEC-015 §4). The release profile is the one shipped. - - name: The server's tests, as ok images - run: | - set -euo pipefail - mkdir -p openkal-tests - mcpp test --profile release --target openkal --no-run --archive openkal-tests/mcppls.tar.gz - ls -l openkal-tests - - uses: actions/upload-artifact@v7 - with: - name: tests-openkal - path: openkal-tests - compression-level: 0 - retention-days: 7 - - # --------------------------------------------------------------------------- - # THE TARGET KITS (spec S4; ecosystem design 2026-10-29 §9.2): the semantic kit of each platform's own target, - # which the server installs with xlings once the editor asks, and the release publishes for a machine without - # the network. linux-x64 and win32-x64 are made on Linux; darwin-arm64 needs a macOS host for the libc++ - # configure; linux-arm64 an arm64 Linux host, whose dpkg has the aarch64 C headers the kit carries. The tool is - # each platform's devtools from `build`'s release row. - kits: - name: kit (${{ matrix.platform }}) - needs: build - runs-on: ${{ matrix.os }} - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04, tools: linux-x64 } - - { platform: linux-arm64, os: ubuntu-24.04-arm, tools: linux-arm64 } - - { platform: darwin-arm64, os: macos-15, tools: darwin-arm64 } - - { platform: win32-x64, os: ubuntu-24.04, tools: linux-x64 } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - name: Build tools for the kit configure - run: | - if [ "$RUNNER_OS" = Linux ]; then - sudo apt-get update -q && sudo apt-get install -y -q ninja-build libc6-dev linux-libc-dev - command -v cmake > /dev/null || sudo apt-get install -y -q cmake - fi - if [ "$RUNNER_OS" = macOS ]; then brew install ninja; fi - cmake --version | head -1; ninja --version - - name: Cache upstream inputs - uses: actions/cache@v6 - with: - path: .payload-cache - key: kit-inputs-${{ matrix.platform }}-${{ hashFiles('packaging/payload.lock.json') }} - - uses: actions/download-artifact@v8 - with: - name: stage-${{ matrix.tools }} - - name: The kit - run: | - set -euo pipefail - tar -xzf stage-${{ matrix.tools }}.tar.gz - stage/mcppls-devtools kit --platform ${{ matrix.platform }} --out kit --archive kits --cache "$PAYLOAD_CACHE" - stage/mcppls-devtools kit --verify kit - ls -l kits - - uses: actions/upload-artifact@v7 - with: - name: kit-${{ matrix.platform }} - path: kits - retention-days: 7 - - # --------------------------------------------------------------------------- - # THE PAYLOAD, ONE FOR EVERY PLATFORM (ecosystem design 2026-10-29 §9.1): the server's okb, the runner of every - # system from the tree, each platform's clangd (mcppls-clangd: native, or the ok image clangd.okb its runner starts, - # as the platform's lock row selects), and the openkal kit, made - # from mcpp's build database of each openkal target. Assembled and verified on Linux; every later job of every - # platform takes this one payload and its platform's entry. - payload: - name: payload - needs: [server, build, engine-candidate-parts] - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: Cache upstream inputs - uses: actions/cache@v6 - with: - path: .payload-cache - key: payload-inputs-${{ hashFiles('packaging/payload.lock.json') }} - - uses: actions/download-artifact@v8 - with: - name: server - path: server - - uses: actions/download-artifact@v8 - with: - name: stage-linux-x64 - # A candidate engine (docs/92-release.md, "A new engine"): packaging/engine-candidate.json names - # a mcppls-clangd PR's CI run, whose packaged parts replace the lock's clangd, so the two are - # tested together before the engine is released. Only a pull request, or a manual CI or nightly - # run of a branch other than main, may: the nightly's ux rounds are where an engine change to - # idle memory or typing latency shows, and they have to see the engine before it is released. - - name: Candidate engine - id: candidate - run: | - set -euo pipefail - file=packaging/engine-candidate.json - [ -f "$file" ] || exit 0 - manual=no - if [ '${{ github.event_name }}' = workflow_dispatch ] && [ '${{ github.ref }}' != refs/heads/main ]; then - case '${{ github.workflow }}' in CI|nightly) manual=yes ;; esac - fi - if [ '${{ github.event_name }}' != pull_request ] && [ "$manual" = no ]; then - echo "::error::$file names an unreleased engine; it is for a pull request's CI, not ${{ github.workflow }} (${{ github.event_name }}). Release the engine, switch packaging/payload.lock.json to it and remove the file." - exit 1 - fi - echo "repository=$(jq -r .repository "$file")" >> "$GITHUB_OUTPUT" - echo "run-id=$(jq -r '.["run-id"]' "$file")" >> "$GITHUB_OUTPUT" - jq . "$file" - - uses: actions/download-artifact@v8 - if: steps.candidate.outputs.run-id != '' - with: - pattern: clangd-* - repository: ${{ steps.candidate.outputs.repository }} - run-id: ${{ steps.candidate.outputs.run-id }} - github-token: ${{ github.token }} - path: candidate - # The openkal kit the payload carries, checked by compiling the std and std.compat modules and a unit with it - # for both targets (the LLVM the build uses, which is clangd's major). - - name: The openkal kit - run: | - set -euo pipefail - tar -xzf stage-linux-x64.tar.gz - stage/mcppls-devtools kit --openkal --out kit-openkal --archive kits - clang="$HOME/.mcpp/registry/data/xpkgs/xim-x-llvm/$LLVM_VERSION/bin/clang++" - printf 'import std;\n#include \n#include \nint main() { std::println("{}", sizeof(long)); }\n' > "$RUNNER_TEMP/unit.cpp" - for target in x86_64-openkal aarch64-openkal; do - args=$(jq -r --arg t "$target" '.targets[$t] | (["--target=" + .target] + .arguments + [.["system-include-directories"][] | "-isystem" + .]) | join(" ")' kit-openkal/kit.json) - (cd kit-openkal && "$clang" --no-default-config -std=c++23 $args -Wno-reserved-module-identifier --precompile share/libc++/v1/std.cppm -o "$RUNNER_TEMP/std.pcm") - (cd kit-openkal && "$clang" --no-default-config -std=c++23 $args -Wno-reserved-module-identifier -fmodule-file=std="$RUNNER_TEMP/std.pcm" \ - --precompile share/libc++/v1/std.compat.cppm -o "$RUNNER_TEMP/std.compat.pcm") - (cd kit-openkal && "$clang" --no-default-config -std=c++23 $args -fmodule-file=std="$RUNNER_TEMP/std.pcm" -fsyntax-only "$RUNNER_TEMP/unit.cpp") - echo "$target: std, std.compat and a unit compile with the kit" - done - # Fetch the engines, assemble and verify: one command, the one a developer runs (docs/93-devtools.md). - - name: Assemble - run: | - set -euo pipefail - version=$(sed -n 's/^version *= *"\(.*\)"$/\1/p' mcpp.toml | head -1) - engine=() - if [ -d candidate ]; then - # The ok image's part (clangd-openkal) is its release archive; its engine.json names openkal. - for archive in candidate/clangd-openkal/*.tar.gz; do - [ -f "$archive" ] && tar -xzf "$archive" -C candidate/clangd-openkal - done - for part in $(find candidate -path '*clangd/engine.json' -exec dirname {} \;); do - platform=$(jq -r .platform "$part/engine.json") - chmod +x "$part"/bin/* - jq '{platform, version: .["engine-version"], fork: .["fork-commit"], sha256}' "$part/engine.json" - engine+=(--clangd "$platform=$part") - done - engine+=(--candidate) - fi - stage/mcppls-devtools payload --server-image "server/mcppls-$version.okb" --server-tree "server/mcppls-$version-openkal.zip" \ - --kit kit-openkal ${engine[@]+"${engine[@]}"} --out payload --cache "$PAYLOAD_CACHE" - stage/mcppls-devtools payload --verify payload --json > "$RUNNER_TEMP/verify.json" - cat "$RUNNER_TEMP/verify.json" - # The payload carries the one okb byte for byte, and an entry for every platform. - want=$(sha256sum "server/mcppls-$version.okb" | cut -d' ' -f1) - got=$(jq -r '.server.sha256' "$RUNNER_TEMP/verify.json") - echo "okb $want, payload $got" - [ "$want" = "$got" ] - [ "$(jq -r '.platforms | sort | join(" ")' "$RUNNER_TEMP/verify.json")" = 'darwin-arm64 linux-arm64 linux-x64 win32-x64' ] - # A tarball keeps executable bits and links, which an artifact zip does not. - tar -czf payload.tar.gz payload - du -sh payload payload.tar.gz kits/* - - uses: actions/upload-artifact@v7 - with: - name: payload - path: payload.tar.gz - retention-days: 7 - - uses: actions/upload-artifact@v7 - with: - name: kit-openkal - path: kits - retention-days: 7 - - # --------------------------------------------------------------------------- - # The VS Code extension, one VSIX for every system: the one payload inside it, no target platform. Every editor - # job below installs this file, which is what a release ships. - vsix: - name: the VS Code extension (one VSIX) - needs: [changes, payload] - if: ${{ !cancelled() && !failure() && needs.changes.outputs.vscode == 'true' }} - permissions: - contents: read - actions: read - runs-on: ubuntu-24.04 - timeout-minutes: 30 - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 22 - cache: npm - cache-dependency-path: editors/vscode/package-lock.json - - uses: ./.github/actions/payload - with: - platform: linux-x64 - run-id: ${{ needs.changes.outputs.lend }} - - name: Build and package the extension - run: | - set -euo pipefail - version=$(sed -n 's/^version *= *"\(.*\)"$/\1/p' mcpp.toml | head -1) - mkdir -p vsix - stage/mcppls-devtools extension --editor vscode --payload "$PWD/payload" --out "$PWD/vsix/mcppls-$version.vsix" - unzip -l "vsix/mcppls-$version.vsix" | tail -1 - ! unzip -p "vsix/mcppls-$version.vsix" extension.vsixmanifest | grep -q 'TargetPlatform=' || { echo "::error::the VSIX names a target platform"; exit 1; } - # The extension's own logic that needs no VS Code (src/serverLog.ts, src/kitAsk.ts), in plain Node. - - name: Unit tests - working-directory: editors/vscode - run: npm run test:unit - - uses: actions/upload-artifact@v7 - with: - name: vsix - path: vsix - retention-days: 7 - - # --------------------------------------------------------------------------- - # The specifications' executable part, against real toolchains (tests/cts/README.md). The cases are the objects of - # tests/cts's topic files: each states the platforms it runs on (`on:`), what it holds and how the runner - # drives it; a row runs the cases of its platform, two at a time. mtest's `--shard` divides programs, and the - # cases are one program, so a platform is one row (a part beside it was given nothing). - conformance: - name: conformance (${{ matrix.platform }}${{ matrix.part && format(', {0}', matrix.part) || '' }}) - needs: [payload, kits] - permissions: - contents: read - actions: read # the stores main's last run kept, which divide the cases by time - runs-on: ${{ matrix.os }} - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04 } - - { platform: darwin-arm64, os: macos-15 } - # The arm64 row takes no mcpp: the server and mock producers are built on Linux. Most fixtures use the - # semantic kit; the SDK-free target fixtures probe distribution clang, installed below. - - { platform: linux-arm64, os: ubuntu-24.04-arm, cross-tools: true } - - { platform: win32-x64, os: windows-2022 } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - if: '!matrix.cross-tools' - # The one payload, this platform's test tools, and its target kit installed as on a machine whose person agreed - # to install it: the fixtures read loose sources with this system's own target, as before the openkal kit. - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - kit: 'true' - # mtest runs the cases: the release .github/versions.env pins, its sha256 checked. - - uses: ./.github/actions/mtest - # The store of the first attempt, so that a second runs only what failed. - - uses: actions/cache/restore@v6 - id: attempt-store - with: - path: .mtest - key: conformance-mtest-${{ github.run_id }}-${{ matrix.platform }}-${{ matrix.part || 'full' }}-${{ github.run_attempt }} - restore-keys: conformance-mtest-${{ github.run_id }}-${{ matrix.platform }}-${{ matrix.part || 'full' }}- - # A first attempt takes the stores main's last successful run kept for this platform, every part's: both parts - # read the same history, so `--shard` divides the cases by measured time the same way in each. Without them the - # cases are divided by count, and this run's stores become the next run's history. - - name: The stores of main's last run - if: steps.attempt-store.outputs.cache-matched-key == '' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow ci.yml --branch main --status success --limit 1 \ - --json databaseId -q '.[0].databaseId' 2>/dev/null || true) - [ -n "$run" ] || { echo "no successful run of main: the cases are divided by count"; exit 0; } - rm -rf "$RUNNER_TEMP/cts-stores" - if ! gh run download "$run" -R "$GITHUB_REPOSITORY" -p "conformance-mtest-${{ matrix.platform }}-*" -D "$RUNNER_TEMP/cts-stores" 2>/dev/null; then - echo "main's run $run kept no store for ${{ matrix.platform }}: the cases are divided by count"; exit 0 - fi - for store in "$RUNNER_TEMP"/cts-stores/*/; do "$MTEST" store import --from "$store"; done - # The cases of this row: its platform's, in its part. The steps below install what they build with. - - name: The cases of this row - run: | - set -euo pipefail - exe=''; [ "$RUNNER_OS" != Windows ] || exe='.exe' - select=(--tag "on:${{ matrix.platform }}") - [ -z "${{ matrix.shard }}" ] || select+=(--shard "${{ matrix.shard }}") - echo "CTS_SELECT=${select[*]}" >> "$GITHUB_ENV" - echo "CTS=stage/mcppls-cts$exe" >> "$GITHUB_ENV" - "$MTEST" plan --json "${select[@]}" "stage/mcppls-cts$exe" > "$RUNNER_TEMP/cts-plan.json" - cases=$(jq -r '[.units[].cases[].case] | join(" ")' "$RUNNER_TEMP/cts-plan.json") - fixtures=$(jq -r '[.units[].cases[].case | split("@")[0]] | unique | join(" ")' "$RUNNER_TEMP/cts-plan.json") - echo "$cases" | tr ' ' '\n' | sed 's/^/ /' - echo "CTS_FIXTURES= $fixtures " >> "$GITHUB_ENV" - - name: Toolchains the fixtures build with - run: | - set -euo pipefail - # GCC 16 for the fixtures that build with it. - gcc=no - case "$CTS_FIXTURES" in *" mcpp-gcc "*|*" mcpp-split-gcc "*|*" mingw "*) gcc=yes ;; esac - [ "$gcc" = no ] || mcpp toolchain install gcc 16.1.0 - if [ "$RUNNER_OS" = Linux ]; then sudo apt-get update -q && sudo apt-get install -y -q ninja-build; fi - # The native mock target fixtures need only a compiler probe and SDK-free module sources. - # arm64 has no mcpp toolchain store; its distribution clang is sufficient for these fixtures. - if [ "${{ matrix.cross-tools }}" = true ]; then sudo apt-get install -y -q clang; fi - # ux-modules-qt: Qt 5's headers in module units' global module fragments, from the distribution. - case "$CTS_FIXTURES" in *" ux-modules-qt "*) sudo apt-get install -y -q qtbase5-dev ;; esac - # 0.0.8 part 2 X-7: the xmake fixtures run xmake itself (pinned in .github/versions.env, from xlings like every other - # tool here) with GCC 16 first on PATH: xmake picks the compiler it finds, and only GCC 16 has libstdc++'s std module. - - name: xmake and GCC 16 for the xmake fixtures - if: runner.os == 'Linux' && contains(env.CTS_FIXTURES, ' xmake-') - run: | - set -euo pipefail - for package in "xmake@$XMAKE_VERSION" gcc@16.1.0; do - for attempt in 1 2 3; do - if xlings install "$package" -y -g; then break; fi - [ "$attempt" = 3 ] && { echo "::error::$package could not be installed"; exit 1; } - sleep 30 - done - done - xmake --version | head -1 - g++ --version | head -1 - # A fixture that needs a newer mcpp than the pinned one says so in its scenario.json ("requires-mcpp"; the runner - # checks the version and says what it found). That release is installed beside the pinned one, which stays the - # default, and named to the fixtures by MCPPLS_REAL_MCPP. - - name: The mcpp the fixtures that require one run with - if: '!matrix.cross-tools' - run: | - set -euo pipefail - wanted=no - for fixture in $CTS_FIXTURES; do - grep -q '"requires-mcpp"' "tests/cts/fixtures/$fixture/scenario.json" 2>/dev/null && wanted=yes - done - [ "$wanted" = yes ] || exit 0 - # From mcpp's own release, its checksum checked: the index snapshot the pinned xlings syncs can lag a new mcpp - # by days (2026.10.11.1 was in xim-pkgindex, not yet in the snapshot), and the pinned mcpp stays the default. - case "$RUNNER_OS-$(uname -m)" in - Linux-x86_64) platform=linux-x86_64; ext=tar.gz ;; - Linux-aarch64) platform=linux-aarch64; ext=tar.gz ;; - macOS-arm64) platform=macosx-arm64; ext=tar.gz ;; - Windows-*) platform=windows-x86_64; ext=zip ;; - *) echo "::error::no mcpp release for $RUNNER_OS-$(uname -m)"; exit 1 ;; - esac - archive="mcpp-$MCPP_FIXTURE_VERSION-$platform.$ext" - base="https://github.com/mcpp-community/mcpp/releases/download/v$MCPP_FIXTURE_VERSION" - temp="$RUNNER_TEMP"; python=python3 - if [ "$RUNNER_OS" = Windows ]; then temp=$(cygpath -u "$RUNNER_TEMP"); python=python; fi - curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o "$temp/$archive" "$base/$archive" - curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 -o "$temp/$archive.sha256" "$base/$archive.sha256" - expected=$(cut -d' ' -f1 "$temp/$archive.sha256") - actual=$("$python" -c "import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],'rb').read()).hexdigest())" "$temp/$archive") - [ "$expected" = "$actual" ] || { echo "::error::$archive: sha256 $actual, the release says $expected"; exit 1; } - rm -rf "$temp/mcpp-fixture" && mkdir -p "$temp/mcpp-fixture" - "$python" -c "import shutil,sys; shutil.unpack_archive(sys.argv[1], sys.argv[2])" "$temp/$archive" "$temp/mcpp-fixture" - exe=''; [ "$RUNNER_OS" = Windows ] && exe='.exe' - mcpp="$temp/mcpp-fixture/mcpp-$MCPP_FIXTURE_VERSION-$platform/bin/mcpp$exe" - chmod +x "$mcpp" 2>/dev/null || true - "$mcpp" --version - mcpp --version - if [ "$RUNNER_OS" = Windows ]; then mcpp=$(cygpath -m "$mcpp"); fi - echo "MCPPLS_REAL_MCPP=$mcpp" >> "$GITHUB_ENV" - # usable plan W3, closing the loop with mcpp itself: what `mcpp emit build-database` prints for - # each of this host's mcpp fixtures, against the S1 and S2 schemas, S1's semantic rules and the - # contract of mcpp-community/mcpp#636 (docs/specs/tools/validate.py). The fixtures below then have the - # server consume the same answers. A fixture with mcpp-mock.json simulates mcpp and is skipped. - - name: mcpp's own build databases against the specifications - if: '!matrix.cross-tools' - env: - PYTHONUTF8: '1' - run: | - set -euo pipefail - temp="$RUNNER_TEMP"; python=python3 - if [ "$RUNNER_OS" = Windows ]; then temp=$(cygpath -u "$RUNNER_TEMP"); python=python; fi - "$python" -m venv "$temp/specs" - venv="$temp/specs/bin/python"; [ "$RUNNER_OS" = Windows ] && venv="$temp/specs/Scripts/python.exe" - "$venv" -m pip install --quiet --disable-pip-version-check 'jsonschema>=4.18' - envelopes=() - for fixture in $CTS_FIXTURES; do - case "$fixture" in mcpp-*) ;; *) continue ;; esac - source="tests/cts/fixtures/$fixture" - [ -f "$source/mcpp.toml" ] && [ ! -f "$source/mcpp-mock.json" ] || continue - [ -e "$temp/mcpp-emit/$fixture.json" ] && continue - mkdir -p "$temp/mcpp-emit" && cp -R "$source" "$temp/mcpp-emit/$fixture" - emit=mcpp; grep -q '"requires-mcpp"' "$source/scenario.json" && emit="${MCPPLS_REAL_MCPP:-mcpp}" - (cd "$temp/mcpp-emit/$fixture" && "$emit" emit build-database --format json > "$temp/mcpp-emit/$fixture.json") - envelopes+=("$temp/mcpp-emit/$fixture.json") - done - "$venv" docs/specs/tools/validate.py ${envelopes[@]+"${envelopes[@]}"} | grep -v '^PASS' - # cmake-clang-bdb, cmake-msvc-bdb and cmake-clang-cl need CMake 4.4 (the build-database export merges - # correctly from 4.4 on, usable plan W4, E16; clang-cl module scanning, W1.8); these hosts' own CMake is the - # 3.31.6 the runner images ship. Each states it (`requires-cmake`, CONFORMANCE_CMAKE), and the runner puts - # this CMake first on the PATH of that fixture's prepare steps only: CMake rotates its experimental-feature - # UUIDs, and cmake-msvc-std pins 3.31.6's CMAKE_EXPERIMENTAL_CXX_IMPORT_STD UUID (E1/E7-E11). - - name: Cache CMake 4.4.2 (cmake-clang-bdb, cmake-msvc-bdb, cmake-clang-cl) - if: matrix.platform == 'linux-x64' || matrix.platform == 'win32-x64' - id: cmake-bdb-cache - uses: actions/cache@v6 - with: - path: ${{ runner.temp }}/cmake-4.4.2 - key: cmake-bdb-4.4.2-${{ runner.os }} - - name: Install CMake 4.4.2 - if: (matrix.platform == 'linux-x64' || matrix.platform == 'win32-x64') && steps.cmake-bdb-cache.outputs.cache-hit != 'true' - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/cmake-4.4.2" - if [ "$RUNNER_OS" = Linux ]; then - curl -sfL https://github.com/Kitware/CMake/releases/download/v4.4.2/cmake-4.4.2-linux-x86_64.tar.gz -o "$RUNNER_TEMP/cmake-4.4.2.tar.gz" - tar -xzf "$RUNNER_TEMP/cmake-4.4.2.tar.gz" -C "$RUNNER_TEMP/cmake-4.4.2" --strip-components 1 - else - curl -sfL https://github.com/Kitware/CMake/releases/download/v4.4.2/cmake-4.4.2-windows-x86_64.zip -o "$RUNNER_TEMP/cmake-4.4.2.zip" - # A bash step, so $RUNNER_TEMP (bash-expanded below) rather than - # pwsh's own $env:RUNNER_TEMP, which bash would try to expand itself. - pwsh -NoProfile -Command "Expand-Archive -Path '$RUNNER_TEMP/cmake-4.4.2.zip' -DestinationPath '$RUNNER_TEMP/cmake-4.4.2-zip' -Force" - mv "$RUNNER_TEMP"/cmake-4.4.2-zip/cmake-4.4.2-windows-x86_64/* "$RUNNER_TEMP/cmake-4.4.2/" - fi - - name: CMake 4.4.2 is there and reports its version - if: matrix.platform == 'linux-x64' || matrix.platform == 'win32-x64' - run: | - set -euo pipefail - if [ "$RUNNER_OS" = Windows ]; then "$RUNNER_TEMP/cmake-4.4.2/bin/cmake.exe" --version; else "$RUNNER_TEMP/cmake-4.4.2/bin/cmake" --version; fi - # The MSVC fixtures build inside a developer environment; the server under test - # runs without one, as it does when an editor starts it (usable plan D30). - - name: The MSVC developer environment, for the fixtures' own builds - if: runner.os == 'Windows' - shell: pwsh - run: | - $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - Set-Content -Path "$env:RUNNER_TEMP\capture-msvc.cmd" -Value "@call `"$vs\VC\Auxiliary\Build\vcvars64.bat`" >nul`r`n@set" - cmd /d /c "$env:RUNNER_TEMP\capture-msvc.cmd" | Out-File -Encoding utf8 "$env:RUNNER_TEMP\msvc.env" - Select-String -Path "$env:RUNNER_TEMP\msvc.env" -Pattern '^(VCToolsInstallDir|WindowsSDKVersion)=' - - name: The packaged mock producer starts on this host - run: | - set -euo pipefail - exe=''; [ "$RUNNER_OS" != Windows ] || exe='.exe' - "stage/mcppls-mock-mcpp$exe" --protocol-version - - name: Run the fixtures - env: - # LLVM_VERSION (.github/versions.env) is put in below: one pin for the toolchain and this path. - CONFORMANCE_CLANGXX: ${{ runner.os == 'Linux' && !matrix.cross-tools && '~/.mcpp/registry/data/xpkgs/xim-x-llvm/LLVM_VERSION/bin/clang++' || '' }} - run: | - set -euo pipefail - # A cross-tools row has no setup-mcpp, so no versions.env, and no path to fill in. - CONFORMANCE_CLANGXX="${CONFORMANCE_CLANGXX/LLVM_VERSION/${LLVM_VERSION:-}}" - export CONFORMANCE_CLANGXX="${CONFORMANCE_CLANGXX/#\~/$HOME}" - if [ "${{ matrix.cross-tools }}" = true ]; then - # S1 toolchain drivers are absolute paths; this host has distribution clang, - # rather than an mcpp LLVM store for the mock's compiler placeholder. - CONFORMANCE_CLANGXX=$(command -v clang++) - [[ "$CONFORMANCE_CLANGXX" = /* ]] - test -x "$CONFORMANCE_CLANGXX" - export CONFORMANCE_CLANGXX - "$CONFORMANCE_CLANGXX" --version - fi - # The cases of this row (above), each in a process of its own and two at a time; a case that holds the - # machine runs alone. A failing case runs once more with the server's log, kept in the store and named by - # `mtest explain` (the cases' `diagnose`). A second attempt of this job runs only the cases that failed. - export MCPPLS_CTS_MEASURES="$RUNNER_TEMP/conformance-measures" MCPPLS_CTS_BUNDLES="$RUNNER_TEMP/bundles" - export MCPPLS_CTS_EVIDENCE="$RUNNER_TEMP/evidence" - if [ "$RUNNER_OS" = Windows ]; then - export MCPPLS_CTS_MSVC_ENV="$RUNNER_TEMP/msvc.env" CONFORMANCE_CMAKE="$RUNNER_TEMP/cmake-4.4.2/bin/cmake.exe" - elif [ "${{ matrix.platform }}" = linux-x64 ]; then - export CONFORMANCE_CMAKE="$RUNNER_TEMP/cmake-4.4.2/bin/cmake" - fi - status=0 - if [ "${{ github.run_attempt }}" -gt 1 ] && [ -f .mtest/index.ndjson ]; then - # `run --failed` (`rerun` takes a selection, not the run's options). The steps after it see that - # a case which passed before has not run again (CONFORMANCE_FAILED_ONLY). - echo "CONFORMANCE_FAILED_ONLY=1" >> "$GITHUB_ENV" - "$MTEST" run --failed --isolate --jobs "${CONFORMANCE_JOBS:-2}" --junit "$RUNNER_TEMP/conformance.xml" "$CTS" || status=$? - else - # shellcheck disable=SC2086 # CTS_SELECT is words - "$MTEST" run --isolate --jobs "${CONFORMANCE_JOBS:-2}" --junit "$RUNNER_TEMP/conformance.xml" $CTS_SELECT "$CTS" || status=$? - fi - if [ "$status" != 0 ]; then - "$MTEST" explain || true - for out in .mtest/runs/*/diagnoses/*/out.txt; do - [ -f "$out" ] || continue - echo "::group::diagnosis $(dirname "$out")"; tail -200 "$out"; echo "::endgroup::" - done - exit 1 - fi - - - uses: actions/cache/save@v6 - if: always() - with: - path: .mtest - key: conformance-mtest-${{ github.run_id }}-${{ matrix.platform }}-${{ matrix.part || 'full' }}-${{ github.run_attempt }} - # The store alone, so its files are the artifact's (a hidden directory is uploaded only when asked). - - uses: actions/upload-artifact@v7 - if: always() - with: - name: conformance-mtest-${{ matrix.platform }}-${{ matrix.part || 'full' }} - path: .mtest - include-hidden-files: true - if-no-files-found: ignore - - uses: actions/upload-artifact@v7 - if: always() - with: - name: conformance-junit-${{ matrix.platform }}-${{ matrix.part || 'full' }} - path: ${{ runner.temp }}/conformance.xml - if-no-files-found: ignore - - # Preserve the first producer envelopes even when schema validation stops - # the job before any LSP fixture starts. - - uses: actions/upload-artifact@v7 - if: always() && !matrix.cross-tools - with: - name: producer-envelopes-${{ matrix.platform }}-${{ matrix.part || 'full' }} - path: ${{ runner.temp }}/mcpp-emit/*.json - if-no-files-found: ignore - - - uses: actions/upload-artifact@v7 - if: always() - with: - name: conformance-measures-${{ matrix.platform }}-${{ matrix.part || 'full' }} - path: ${{ runner.temp }}/conformance-measures/*.json - if-no-files-found: ignore - - - uses: actions/upload-artifact@v7 - if: always() && contains(env.CTS_FIXTURES, ' mcpp-target-windows-real ') - with: - name: real-mcpp-target-cdb-${{ matrix.platform }} - path: | - ${{ runner.temp }}/evidence/mcpp-target-windows-real*/workspace/mcpp-target-windows-real/recorded-*.json - ${{ runner.temp }}/evidence/mcpp-target-windows-real*/workspace/mcpp-target-windows-real/compile_commands.json - ${{ runner.temp }}/evidence/mcpp-target-windows-real*/cache/logs/*.log - ${{ runner.temp }}/evidence/mcpp-target-windows-real*/cache/workspaces/**/model.mcpp.json - if-no-files-found: ignore - - # Issue #23 fix plan F18: the bundles diagnostic-bundle exported on this runner's own account, whose - # name is in every path of it (runneradmin and RUNNER~1 on Windows). The fixture already held each - # to its manifest and found no user name and no home directory in it; here another program's zip - # reader opens them and checks every entry's CRC, and they are kept to look at. - - name: The diagnostic bundles open with another program's zip reader - if: always() && contains(env.CTS_FIXTURES, ' diagnostic-bundle ') - env: - PYTHONUTF8: '1' - run: | - set -euo pipefail - temp="$RUNNER_TEMP"; python=python3 - if [ "$RUNNER_OS" = Windows ]; then temp=$(cygpath -u "$RUNNER_TEMP"); python=python; fi - shopt -s nullglob - bundles=("$temp"/bundles/*.zip) - # An attempt that ran only the cases that failed ran diagnostic-bundle only if it had failed: then it - # left its bundles or failed again. Without them, it passed in an earlier attempt, which checked them. - if [ ${#bundles[@]} -eq 0 ] && [ "${CONFORMANCE_FAILED_ONLY:-}" = 1 ]; then - echo "diagnostic-bundle passed in an earlier attempt; this one ran only the cases that failed"; exit 0 - fi - [ ${#bundles[@]} -gt 0 ] || { echo "::error::diagnostic-bundle left no bundle"; exit 1; } - for bundle in "${bundles[@]}"; do "$python" -m zipfile -t "$bundle"; "$python" -m zipfile -l "$bundle"; done - - uses: actions/upload-artifact@v7 - if: always() && contains(env.CTS_FIXTURES, ' diagnostic-bundle ') - with: - name: diagnostic-bundle-${{ matrix.platform }} - path: ${{ runner.temp }}/bundles/*.zip - if-no-files-found: ignore - - # --------------------------------------------------------------------------- - # termux / PRoot is a supported environment (issue #32, 0.0.7 plan §1, §10.2), so it is tested, - # not assumed: the server runs under termux's PRoot, built here from a pinned commit, in both of - # PRoot's modes -- its default (a seccomp filter takes the fast path, and rewrites registers it - # does not restore) and PROOT_NO_SECCOMP=1 (ptrace stops on every call). Two defects of openkal-linux - # 0.15.0 showed up as "cannot start ...: not supported" and "program is outside every preopened - # directory" on every start; vendor/README.md has both. A regression of either fails the fixtures - # below, which is what this job is for. - # - # The server under test is the payload's own -- this platform's runner starting its okb - # (`payload/libexec/okrun/-/okrun payload/bin/mcppls.okb`) -- wrapped: a wrapper script generated here runs it under proot, which - # is how the conformance runner (--server) and the VS Code extension - # (MCPPLS_SERVER) are pointed at it. The payload's clangd, the kit and the fixtures' own - # processes are started BY the server, so they run under proot too. The runner and the editor - # stay outside. - proot: - name: proot (${{ matrix.platform }}) - needs: [payload, kits, vsix] - runs-on: ${{ matrix.os }} - timeout-minutes: 90 - strategy: - fail-fast: false - matrix: - include: - # The arm64 runner has no mcpp: its tools are the ones built on Linux (stage/), as in the - # conformance job. VS Code's suite runs on linux-x64 only, as the - # arm64 runner would need the display libraries installed for one more run of the same thing. - - { platform: linux-x64, os: ubuntu-24.04, vscode: true } - - { platform: linux-arm64, os: ubuntu-24.04-arm } - env: - # termux/proot master at the time this was written. A pin, because a PRoot change must not - # turn this job red without anyone having touched this repository. - PROOT_COMMIT: d4d2a19081c3c07f75250e4ce2980b9fa2f5720f - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - kit: 'true' - - name: Cache termux/proot - id: proot-cache - uses: actions/cache@v6 - with: - path: ~/proot-bin - key: termux-proot-${{ env.PROOT_COMMIT }}-${{ runner.arch }}-v2 - - name: Build termux/proot at the pinned commit - if: steps.proot-cache.outputs.cache-hit != 'true' - run: | - set -euo pipefail - sudo apt-get update -q && sudo apt-get install -y -q libtalloc-dev build-essential - src="$RUNNER_TEMP/termux-proot" - mkdir -p "$src" && cd "$src" - git init -q . - git remote add origin https://github.com/termux/proot - git fetch -q --depth 1 origin "$PROOT_COMMIT" - git checkout -q FETCH_HEAD - git log --oneline -1 - cd src - # compat.h defines SYS_SECCOMP unless it is a macro, and a glibc that declares it as an enum - # constant turns that into a syntax error. The definition is redundant there: drop it if, - # and only if, the build needs it dropped. - # No 32-bit loader (HAS_LOADER_32BIT empty): it needs a compiler for 32-bit ARM or x86, which the arm64 - # runner's gcc is not (`-m32`), and nothing here runs a 32-bit program. - make -j"$(nproc)" HAS_LOADER_32BIT= || { sed -i '/^# ifndef SYS_SECCOMP/,/^# endif/d' compat.h && make -j"$(nproc)" HAS_LOADER_32BIT=; } - mkdir -p ~/proot-bin && cp proot ~/proot-bin/proot - # The cached binary links libtalloc, which the runner image no longer carries (ubuntu-24.04, - # 2026-11-01: "libtalloc.so.2: cannot open shared object file"); the build step installs it - # only when it builds. - - name: The library proot links - if: steps.proot-cache.outputs.cache-hit == 'true' - run: sudo apt-get update -q && sudo apt-get install -y -q libtalloc2 - - name: proot itself works, and is the one we built - run: | - set -euo pipefail - ~/proot-bin/proot -r / /bin/true - PROOT_NO_SECCOMP=1 ~/proot-bin/proot -r / /bin/true - file ~/proot-bin/proot - # The two wrappers. `-r /`: no guest file system, only proot's translation of every call, which - # is what a Debian rootfs under termux adds a root directory to. - - name: Wrappers that run the server under proot, in each mode - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/proot" - for mode in seccomp noseccomp; do - wrapper="$RUNNER_TEMP/proot/mcppls-$mode" - { - echo '#!/bin/sh' - # A line per start, so a step can say the server it ran against really ran under proot. - echo "echo started >> $RUNNER_TEMP/proot/started-$mode" - if [ "$mode" = noseccomp ]; then echo 'export PROOT_NO_SECCOMP=1'; fi - echo "exec $HOME/proot-bin/proot -r / $PWD/payload/$(jq -r '.platforms["${{ matrix.platform }}"].runner' payload/payload.json) $PWD/payload/bin/mcppls.okb \"\$@\"" - } > "$wrapper" - chmod +x "$wrapper" - cat "$wrapper" - done - # What the report says before any fixture runs: clangd started and is ready, and nothing in - # the output or the log says a program could not be started. - - name: mcppls report under proot, in each mode - run: | - set -euo pipefail - for mode in seccomp noseccomp; do - echo "::group::report ($mode)" - work="$RUNNER_TEMP/report-$mode" - rm -rf "$work" && mkdir -p "$work" && cp -R tests/cts/fixtures/inferred "$work/project" - rm -f "$work/project/scenario.json" - MCPPLS_CACHE_DIR="$work/cache" "$RUNNER_TEMP/proot/mcppls-$mode" report --root "$work/project" \ - --settle 20 --payload "$PWD/payload" > "$work/report.json" 2> "$work/report.err" - tail -5 "$work/report.err" - if grep -q -e 'not supported' -e 'outside every preopened' -e 'no preopened directory is named' "$work/report.json" "$work/report.err"; then - grep -h -e 'not supported' -e 'outside every preopened' -e 'no preopened directory is named' "$work/report.json" "$work/report.err" | head - echo "::error::the server could not start a program under proot ($mode)"; exit 1 - fi - state=$(jq -r '[.roots[0].engines[] | select(.name == "clangd") | .state][0]' "$work/report.json") - echo "clangd: $state" - [ "$state" = ready ] || { echo "::error::clangd is '$state' under proot ($mode)"; exit 1; } - echo "::endgroup::" - done - - name: Conformance fixtures under proot, in each mode - run: | - set -euo pipefail - failed='' - for mode in seccomp noseccomp; do - for fixture in inferred typing-import completion-keywords diagnostic-bundle watch-polling; do - echo "::group::$fixture ($mode)" - extra=() - # The polling fallback runs only when the client never declares dynamic watching. - case "$fixture" in watch-polling) extra+=(--no-dynamic-watch) ;; esac - if ! stage/mcppls-conformance run --server "$RUNNER_TEMP/proot/mcppls-$mode" --payload payload \ - ${extra[@]+"${extra[@]}"} --fixture "tests/cts/fixtures/$fixture" --timeout 180; then - failed="$failed $fixture@$mode" - fi - echo "::endgroup::" - done - done - if [ -n "$failed" ]; then - echo "::error::failed under proot:$failed" - first=${failed# }; first=${first%% *}; mode=${first#*@} - stage/mcppls-conformance run --server "$RUNNER_TEMP/proot/mcppls-$mode" --payload payload \ - --fixture "tests/cts/fixtures/${first%@*}" --timeout 60 --verbose 2>&1 | tail -200 || true - exit 1 - fi - # The editor's own suite, on the server under proot (the extension reads MCPPLS_SERVER; see - # editors/vscode/src/payload.ts): what a person in termux with VS Code's server sees. - - uses: actions/setup-node@v7 - if: matrix.vscode - with: - node-version: 22 - cache: npm - cache-dependency-path: editors/vscode/package-lock.json - - uses: actions/download-artifact@v8 - if: matrix.vscode - with: - name: vsix - path: vsix - - name: The extension's test harness - if: matrix.vscode - working-directory: editors/vscode - run: npm ci --no-audit --no-fund && npm run compile - - name: VS Code end-to-end suite with the server under proot - if: matrix.vscode - working-directory: editors/vscode - run: | - set -euo pipefail - before=$(wc -l < "$RUNNER_TEMP/proot/started-seccomp") - MCPPLS_E2E_VSIX=$(ls "$GITHUB_WORKSPACE"/vsix/mcppls-*.vsix) MCPPLS_SERVER="$RUNNER_TEMP/proot/mcppls-seccomp" xvfb-run -a npm test - # The editor started the wrapper, not some other server. - after=$(wc -l < "$RUNNER_TEMP/proot/started-seccomp") - echo "the editor started the server under proot $((after - before)) times" - [ "$after" -gt "$before" ] - - # --------------------------------------------------------------------------- - # User-experience scenarios (0.0.7 plan 6.4, tests/cts/README.md "Scenario checks"): the mcpp and xlings - # repositories at pinned commits, a person's first minutes and worst moments with them, on the size of - # machine the plan measures against: this runner's 4 vCPU and 16 GB are the `taskset -c 0-3` tier of a - # developer's laptop. The budgets in the fixtures are the merge gate. Each stage is a run of the fixture - # (cold start, warm start on the same cache, typing and saves on that cache, faults on a cache of their - # own); a stage that fails does not skip the ones after it, so a run says everything that is over budget, - # and every stage's measure file is uploaded whether or not it passed, for the trend. The `long` stage - # (ten idle minutes, a git checkout twenty commits back) is nightly's. ux-heavy-headers (0.0.9 plan M-2) - # needs no checkout: it generates a project of heavy headers and holds completion through the server to - # what clangd alone does on it, in its one stage. - ux: - name: ux (${{ matrix.fixture }}) - needs: [payload, kits] - runs-on: ubuntu-24.04 - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - fixture: [ux-mcpp, ux-xlings, ux-heavy-headers] - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: GCC 16, which the mcpp and xlings repositories build with - if: matrix.fixture != 'ux-heavy-headers' - run: mcpp toolchain install gcc 16.1.0 - # The mcpp repository's .xlings.json asks for the mcpp it is built with, and xlings runs that one - # inside it, refusing to when it is not installed (nightly's self-hosting job does the same). - - name: The mcpp the mcpp repository asks for - if: matrix.fixture == 'ux-mcpp' - run: | - set -euo pipefail - xlings install mcpp@2026.9.21.1 -y -g - xlings use mcpp "$MCPP_VERSION" - mcpp --version - - uses: ./.github/actions/payload - with: - platform: linux-x64 - kit: 'true' - - uses: ./.github/actions/mtest - # The scenario's stages, each a case of mcppls-bench (tests/bench/ux.cpp), in order on one workspace. - - name: The stages - run: | - set -euo pipefail - nproc - cat /proc/loadavg - export MCPPLS_CTS_MEASURES="$RUNNER_TEMP/ux-results" MCPPLS_CTS_EVIDENCE="$RUNNER_TEMP/ux-work" - status=0 - "$MTEST" run --case '${{ matrix.fixture }}' stage/mcppls-bench || status=$? - "$MTEST" analyze trend --runs 1 | tee -a "$GITHUB_STEP_SUMMARY" || true - [ "$status" = 0 ] || { "$MTEST" explain || true; exit 1; } - - uses: actions/upload-artifact@v7 - if: always() - with: - name: ux-${{ matrix.fixture }} - path: ${{ runner.temp }}/ux-results - # The server's logs and incidents of a failed stage (each incident keeps clangd's log): what a stall, a - # restart or a set-aside file was, which the measurements above only count. - - uses: actions/upload-artifact@v7 - if: failure() - with: - name: ux-${{ matrix.fixture }}-server - path: | - ${{ runner.temp }}/ux-work/*/cache-*/logs - ${{ runner.temp }}/ux-work/*/cache-*/workspaces/*/incidents - if-no-files-found: ignore - retention-days: 7 - - # --------------------------------------------------------------------------- - # Clean machines (usable plan W5, design 20.2): no compiler, no Visual Studio, no - # Command Line Tools. Linux runs in a bare container; the Windows and macOS images - # cannot be emptied, so their toolchains are hidden and asserted invisible first. - # The conformance fixture and the packaged extension's end-to-end suite both run there. - clean-machine: - name: clean machine (${{ matrix.platform }}) - needs: [payload, kits, vsix] - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - # Linux and Windows have no kit of their own target either: what a clean machine with no network has, the - # openkal kit. macOS has its kit, whose SDK the hidden Command Line Tools take away (inferred-no-sdk). - - { platform: linux-x64, os: ubuntu-24.04, exe: '', kit: 'false' } - - { platform: win32-x64, os: windows-2022, exe: '.exe', kit: 'false' } - - { platform: darwin-arm64, os: macos-15, exe: '', kit: 'true' } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - kit: ${{ matrix.kit }} - - uses: actions/setup-node@v7 - with: - node-version: 22 - cache: npm - cache-dependency-path: editors/vscode/package-lock.json - - uses: actions/download-artifact@v8 - with: - name: vsix - path: vsix - - name: The extension, and its test harness - run: | - set -euo pipefail - cp vsix/mcppls-*.vsix "$RUNNER_TEMP/mcpp-language-server.vsix" - (cd editors/vscode && npm ci --no-audit --no-fund && npm run compile) - - name: A container without a compiler - if: runner.os == 'Linux' - run: | - docker run --rm -v "$PWD:/w" -w /w ubuntu:24.04 bash -c ' - set -euo pipefail - for tool in c++ g++ gcc cc clang++ clang; do - if command -v "$tool"; then echo "::error::$tool is on PATH"; exit 1; fi - done - stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload \ - --fixture tests/cts/fixtures/inferred-discover --timeout 180' - - name: The extension in the same kind of container - if: runner.os == 'Linux' - timeout-minutes: 40 - run: | - # Each phase is timed and bounded, so a stall names itself: this step sat silent for the - # whole job timeout twice, in package configuration. Non-interactive: libgtk-3-0's - # dependencies would otherwise ask. PKGSYSTEM_ENABLE_FSYNC=0: shared-mime-info's - # update-mime-database syncs every file it writes, which on a container's overlay file - # system can take longer than the job; dpkg's own syncs are skipped the same way. - docker run --rm -e DEBIAN_FRONTEND=noninteractive -e PKGSYSTEM_ENABLE_FSYNC=0 -v "$PWD:/w" -v "$RUNNER_TEMP:/t" -w /w/editors/vscode \ - node:22-bookworm-slim bash -c ' - set -euo pipefail - phase() { echo "[$(date +%T)] $*"; } - phase "apt-get update" - timeout -k 30s 5m apt-get update -qq - phase "apt-get install" - if ! timeout -k 30s 15m apt-get install -y -q --no-install-recommends -o Dpkg::Use-Pty=0 -o Dpkg::Options::=--force-unsafe-io \ - xvfb xauth ca-certificates libnss3 libatk1.0-0 libatk-bridge2.0-0 libgtk-3-0 libgbm1 libasound2 libxss1 libxkbfile1 \ - libsecret-1-0 > /t/apt.log 2>&1; then - tail -60 /t/apt.log; echo "::error::the packages the extension host needs did not install"; exit 1 - fi - phase "no compiler on PATH" - for tool in c++ g++ gcc cc clang++ clang; do - if command -v "$tool"; then echo "::error::$tool is on PATH"; exit 1; fi - done - phase "npm test" - MCPPLS_E2E_VSIX=/t/mcpp-language-server.vsix timeout -k 30s 20m xvfb-run -a npm test - phase "done"' - - name: Visual Studio hidden and no compiler on PATH - if: runner.os == 'Windows' - run: | - set -euo pipefail - NODE_PATH_FOR_E2E="$(command -v node)" - mv "/c/Program Files (x86)/Microsoft Visual Studio/Installer" "/c/Program Files (x86)/Microsoft Visual Studio/Installer.hidden" - export PATH="/c/Windows/system32:/c/Windows:/c/Windows/System32/Wbem:/usr/bin" - for tool in vswhere cl clang-cl clang++ g++ c++ gcc; do - if command -v "$tool"; then echo "::error::$tool is still visible"; exit 1; fi - done - stage/mcppls-conformance.exe run --server payload/bin/mcppls.okb --payload payload \ - --fixture tests/cts/fixtures/inferred-discover --timeout 180 - # The packaged extension with the same machine view; node stays reachable for the test runner. - # Git's /usr/bin goes first: npm starts through `env bash`, and System32's bash.exe is WSL's. - export PATH="/usr/bin:$(dirname "$NODE_PATH_FOR_E2E"):/c/Windows/system32:/c/Windows:/c/Windows/System32/Wbem" - cd editors/vscode && MCPPLS_E2E_VSIX="$RUNNER_TEMP/mcpp-language-server.vsix" npm test - - # U7: the kit needs the macOS SDK, which is gone with the Command Line Tools. The server - # degrades with the install command and runs no developer-tool shim; the extension asks - # once and not again after a restart. - - name: Command Line Tools and Xcode hidden - if: runner.os == 'macOS' - run: | - set -euo pipefail - sudo mv /Library/Developer/CommandLineTools /Library/Developer/CommandLineTools.hidden - for app in /Applications/Xcode*.app; do sudo mv "$app" "${app%.app}.hidden"; done - sudo xcode-select --reset || true - if [ -e /Library/Developer/CommandLineTools ] || compgen -G "/Applications/Xcode*.app" > /dev/null; then - echo "::error::developer tools are still visible"; exit 1 - fi - stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload \ - --fixture tests/cts/fixtures/inferred-no-sdk --timeout 120 - cd editors/vscode - MCPPLS_E2E_VSIX="$RUNNER_TEMP/mcpp-language-server.vsix" MCPPLS_E2E_ONLY=sdk-missing.test.js MCPPLS_E2E_EXPECT_SDK_MISSING=1 npm test - - # --------------------------------------------------------------------------- - # VS Code → extension → mcppls → clangd, the one VSIX on every system (design 16; ecosystem design 2026-10-29 §9.3). - vscode-e2e: - name: VS Code end to end (${{ matrix.platform }}) - needs: [changes, vsix, kits] - if: ${{ !cancelled() && !failure() && needs.changes.outputs.vscode == 'true' }} - permissions: - contents: read - actions: read - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04, exe: '' } - - { platform: linux-arm64, os: ubuntu-24.04-arm, exe: '' } - - { platform: darwin-arm64, os: macos-15, exe: '' } - - { platform: win32-x64, os: windows-2022, exe: '.exe' } - env: - MCPPLS_LOG_LEVEL: debug - defaults: - run: - shell: bash - steps: - - name: Set editor evidence directory on the allocated runner - run: echo "MCPPLS_E2E_EVIDENCE_DIR=$RUNNER_TEMP/editor-e2e-evidence" >> "$GITHUB_ENV" - - uses: actions/checkout@v7 - # The arm64 image is not the x64 one: the display server and the libraries VS Code's - # Electron loads are installed rather than assumed. - - name: What VS Code needs on arm64 Linux - if: matrix.platform == 'linux-arm64' - run: | - sudo apt-get update -q - sudo apt-get install -y -q --no-install-recommends xvfb xauth libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 \ - libgtk-3-0t64 libgbm1 libasound2t64 libxss1 libxkbfile1 libsecret-1-0 - - uses: actions/setup-node@v7 - with: - node-version: 22 - cache: npm - cache-dependency-path: editors/vscode/package-lock.json - # The VSIX the `vsix` job packaged, the one a release ships (W6.3: the suites below test the installed VSIX, - # not the development folder), and this platform's target kit, as on a machine whose person installed it. - - uses: actions/download-artifact@v8 - with: - name: vsix - path: vsix - - uses: actions/download-artifact@v8 - with: - name: kit-${{ matrix.platform }} - run-id: ${{ needs.changes.outputs.lend || github.run_id }} - github-token: ${{ github.token }} - path: kit-archive - - name: The kit of this platform's target, installed - run: | - set -euo pipefail - archive=$(ls kit-archive/mcppls-kit-*.tar.gz) - name=$(basename "$archive" .tar.gz); version=${name##*-}; target=${name%-*}; target=${target#mcppls-kit-} - store="$HOME/.xlings/data/xpkgs/xim-x-mcppls-kit-$target/$version" - mkdir -p "$store" && tar -xzf "$archive" -C "$store" --strip-components 1 && test -f "$store/kit.json" - cp vsix/mcppls-*.vsix "editors/vscode/mcppls.vsix" - - name: The extension's test harness - working-directory: editors/vscode - run: npm ci --no-audit --no-fund && npm run compile - - name: End-to-end tests (VSIX form) - working-directory: editors/vscode - env: - MCPPLS_E2E_VSIX: ${{ github.workspace }}/editors/vscode/mcppls.vsix - run: | - if [ "$RUNNER_OS" = Linux ]; then xvfb-run -a npm test; else npm test; fi - # Conflicting-extension detection (W6.4/U10): a separate scenario, its - # own pair of stub extensions, and its own workspace-unchanged - # allowance for .vscode/settings.json (see test/runTest.ts). - - name: End-to-end tests (conflicts scenario) - working-directory: editors/vscode - env: - MCPPLS_E2E_VSIX: ${{ github.workspace }}/editors/vscode/mcppls.vsix - MCPPLS_E2E_SCENARIO: conflicts - run: | - if [ "$RUNNER_OS" = Linux ]; then xvfb-run -a npm test; else npm test; fi - # Seeded random use through VS Code's own provider commands (real-project plan RP0): every - # request answered, none past its budget, p90 within five seconds. - - name: End-to-end tests (stress scenario) - working-directory: editors/vscode - env: - MCPPLS_E2E_VSIX: ${{ github.workspace }}/editors/vscode/mcppls.vsix - MCPPLS_E2E_SCENARIO: stress - run: | - if [ "$RUNNER_OS" = Linux ]; then xvfb-run -a npm test; else npm test; fi - - name: Preserve editor request and engine failure evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: vscode-e2e-evidence-${{ matrix.platform }} - path: | - ${{ runner.temp }}/editor-e2e-evidence/*.json - ${{ runner.temp }}/editor-e2e-evidence/**/logs/** - ${{ runner.temp }}/editor-e2e-evidence/**/incidents/** - ${{ runner.temp }}/editor-e2e-evidence/**/crash-reports/** - if-no-files-found: ignore - retention-days: 7 - - # --------------------------------------------------------------------------- - # ONE PACKAGE, EVERY SYSTEM (ecosystem design 2026-10-29 §9.3), on each system: the one VSIX carries exactly - # one okb, the bytes `server` wrote, and this platform's entry names the runner that starts it here; the tree - # starts the server here through its launcher with nothing installed -- the POSIX script, or on Windows the - # launcher program `bin/mcppls.exe` (no .cmd), and the script hands over to it under Git Bash; and the single - # file of this system and architecture runs here. - distribution: - name: one package, every system (${{ matrix.platform }}) - needs: [server, vsix] - runs-on: ${{ matrix.os }} - timeout-minutes: 20 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, system: linux-x86_64, os: ubuntu-24.04, exe: '' } - - { platform: linux-arm64, system: linux-aarch64, os: ubuntu-24.04-arm, exe: '' } - - { platform: darwin-arm64, system: macos-aarch64, os: macos-15, exe: '' } - - { platform: win32-x64, system: windows-x86_64, os: windows-2022, exe: '.exe' } - defaults: - run: - shell: bash - steps: - - uses: actions/download-artifact@v8 - with: - name: server - path: server - - uses: actions/download-artifact@v8 - with: - name: vsix - path: vsix - - name: The VSIX carries the server's okb and the engine's, and this platform's runner starts them - run: | - set -euo pipefail - python=python3; temp="$RUNNER_TEMP" - if [ "$RUNNER_OS" = Windows ]; then python=python; temp=$(cygpath -u "$RUNNER_TEMP"); fi - "$python" -c "import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" vsix/mcppls-*.vsix "$temp/vsix" - payload="$temp/vsix/extension/payload" - jq -c '{server, platforms: (.platforms | keys)}' "$payload/payload.json" - [ "$(jq -r .server.path "$payload/payload.json")" = bin/mcppls.okb ] - runner=$(jq -r '.platforms["${{ matrix.platform }}"].runner' "$payload/payload.json") - [ "$runner" = 'libexec/okrun/${{ matrix.system }}/okrun${{ matrix.exe }}' ] || { echo "::error::this platform's runner is $runner"; exit 1; } - # The server's okb, and the engine's once where a platform's engine is the ok image (S4-4-12). - okbs=$(cd "$payload" && find . -name '*.okb' | sed 's#^\./##' | sort | tr '\n' ' ') - named=$({ echo bin/mcppls.okb; jq -r '.platforms[].engines.clangd | select(.runner) | .path' "$payload/payload.json" | tr -d '\r'; } | sort -u | tr '\n' ' ') - echo "okbs: $okbs" - [ "$okbs" = "$named" ] || { echo "::error::the VSIX carries $okbs; payload.json names $named"; exit 1; } - want=$("$python" -c "import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], 'rb').read()).hexdigest())" server/mcppls-*.okb) - got=$("$python" -c "import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], 'rb').read()).hexdigest())" "$payload/bin/mcppls.okb") - echo "okb $want, VSIX $got" - [ "$want" = "$got" ] || { echo "::error::the VSIX's okb is not the one okb"; exit 1; } - chmod +x "$payload/$runner" - "$payload/$runner" "$payload/bin/mcppls.okb" version | tee version.txt - grep -q '^mcppls ' version.txt - # An engine that is the ok image starts through the same runner and states the version the entry names. - engine=$(jq -r '.platforms["${{ matrix.platform }}"].engines.clangd | select(.runner) | .path' "$payload/payload.json" | tr -d '\r') - if [ -n "$engine" ]; then - "$payload/$runner" "$payload/$engine" --version | tee engine.txt - grep -qF "$(jq -r '.platforms["${{ matrix.platform }}"].engines.clangd.version' "$payload/payload.json" | tr -d '\r')" engine.txt - fi - - name: The tree starts the server here, with nothing installed - run: | - set -euo pipefail - python=python3; temp="$RUNNER_TEMP" - if [ "$RUNNER_OS" = Windows ]; then python=python; temp=$(cygpath -u "$RUNNER_TEMP"); fi - "$python" -c "import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" server/mcppls-*-openkal.zip "$temp/openkal" - top=$(ls -d "$temp"/openkal/*/) - if [ "$RUNNER_OS" = Windows ]; then - system32="$(cygpath -u "$SYSTEMROOT")/System32" - # The launcher program, from a directory with nothing on PATH but the system's. - PATH="$system32" "$top/bin/mcppls.exe" version | tee version.txt - # The POSIX script hands over to it under Git Bash, where `shell: bash` runs. - "$top/bin/mcppls" version | tee -a version.txt - else - chmod +x "$top/bin/mcppls" "$top"/libexec/okrun/*/okrun - env -i HOME="$HOME" PATH=/usr/bin:/bin "$top/bin/mcppls" version | tee version.txt - fi - grep -q '^mcppls ' version.txt - - name: The single file of this system runs here - run: | - set -euo pipefail - file=$(ls server/mcppls-*-${{ matrix.system }}${{ matrix.exe }}) - chmod +x "$file" - "$file" version | tee version.txt - grep -q '^mcppls ' version.txt - ls -l "$file" - - # --------------------------------------------------------------------------- - # The same extension, the same three suites, in a Code-OSS build (0.0.7 plan §7, O-2): VSCodium, at the - # version in .github/versions.env, downloaded per architecture and cached. What differs from Microsoft's - # build is what a Code-OSS user meets first (its own CLI name, `codium`; `vscode.env.appName`; the open - # extension registry), and nothing in vscode-e2e above would notice. MCPPLS_E2E_EDITOR points the harness at - # the unpacked editor (test/runTest.ts); the main suite checks the diagnostic environment names it. - code-oss-e2e: - name: Code-OSS (VSCodium) end to end (${{ matrix.platform }}) - needs: [changes, vsix, kits] - if: ${{ !cancelled() && !failure() && needs.changes.outputs.vscode == 'true' }} - permissions: - contents: read - actions: read - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04 } - - { platform: linux-arm64, os: ubuntu-24.04-arm } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - # The arm64 image is not the x64 one: the display server and the libraries the editor's - # Electron loads are installed rather than assumed (the same list vscode-e2e installs). - - name: What the editor needs on arm64 Linux - if: matrix.platform == 'linux-arm64' - run: | - sudo apt-get update -q - sudo apt-get install -y -q --no-install-recommends xvfb xauth libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 \ - libgtk-3-0t64 libgbm1 libasound2t64 libxss1 libxkbfile1 libsecret-1-0 - - name: The VSCodium version - run: grep '^VSCODIUM_VERSION=' .github/versions.env >> "$GITHUB_ENV" - - uses: actions/setup-node@v7 - with: - node-version: 22 - cache: npm - cache-dependency-path: editors/vscode/package-lock.json - - name: Cache VSCodium - id: vscodium-cache - uses: actions/cache@v6 - with: - path: ${{ runner.temp }}/vscodium - key: vscodium-${{ matrix.platform }}-${{ env.VSCODIUM_VERSION }} - # The release publishes a checksum next to each archive; a download that does not match it - # is not unpacked. - - name: Download VSCodium - if: steps.vscodium-cache.outputs.cache-hit != 'true' - run: | - set -euo pipefail - archive="VSCodium-${{ matrix.platform }}-${VSCODIUM_VERSION}.tar.gz" - base="https://github.com/VSCodium/vscodium/releases/download/${VSCODIUM_VERSION}" - mkdir -p "$RUNNER_TEMP/vscodium" - cd "$RUNNER_TEMP" - curl -fsSL --retry 5 -o "$archive" "$base/$archive" - curl -fsSL --retry 5 -o "$archive.sha256" "$base/$archive.sha256" - sha256sum -c "$archive.sha256" - tar -xzf "$archive" -C vscodium - - name: Find the editor - run: | - set -euo pipefail - test -x "$RUNNER_TEMP/vscodium/codium" - test -x "$RUNNER_TEMP/vscodium/bin/codium" - echo "MCPPLS_E2E_EDITOR=$RUNNER_TEMP/vscodium/codium" >> "$GITHUB_ENV" - echo "MCPPLS_E2E_EXPECT_APP_NAME=VSCodium" >> "$GITHUB_ENV" - # The one VSIX, as vscode-e2e takes it, and this platform's target kit installed: the suites test the installed VSIX. - - uses: actions/download-artifact@v8 - with: - name: vsix - path: vsix - - uses: actions/download-artifact@v8 - with: - name: kit-${{ matrix.platform }} - run-id: ${{ needs.changes.outputs.lend || github.run_id }} - github-token: ${{ github.token }} - path: kit-archive - - name: The kit of this platform's target, installed, and the extension's test harness - run: | - set -euo pipefail - archive=$(ls kit-archive/mcppls-kit-*.tar.gz) - name=$(basename "$archive" .tar.gz); version=${name##*-}; target=${name%-*}; target=${target#mcppls-kit-} - store="$HOME/.xlings/data/xpkgs/xim-x-mcppls-kit-$target/$version" - mkdir -p "$store" && tar -xzf "$archive" -C "$store" --strip-components 1 && test -f "$store/kit.json" - cp vsix/mcppls-*.vsix editors/vscode/mcppls.vsix - (cd editors/vscode && npm ci --no-audit --no-fund && npm run compile) - - name: End-to-end tests (VSIX form) - working-directory: editors/vscode - env: - MCPPLS_E2E_VSIX: ${{ github.workspace }}/editors/vscode/mcppls.vsix - run: xvfb-run -a npm test - - name: End-to-end tests (conflicts scenario) - working-directory: editors/vscode - env: - MCPPLS_E2E_VSIX: ${{ github.workspace }}/editors/vscode/mcppls.vsix - MCPPLS_E2E_SCENARIO: conflicts - run: xvfb-run -a npm test - - name: End-to-end tests (stress scenario) - working-directory: editors/vscode - env: - MCPPLS_E2E_VSIX: ${{ github.workspace }}/editors/vscode/mcppls.vsix - MCPPLS_E2E_SCENARIO: stress - run: xvfb-run -a npm test - - # --------------------------------------------------------------------------- - # The Neovim plugin (editors/nvim) through Neovim's own LSP client, on each host's payload and on - # the oldest and newest Neovim it supports: require('mcppls').setup() on every version, and - # vim.lsp.enable('mcppls') (lsp/mcppls.lua) from 0.11 on. Neovim comes from xlings like every - # other tool here (`nvim` in xim-pkgindex), each version installed and selected with `xlings use`. - neovim: - name: Neovim (${{ matrix.platform }}) - needs: [changes, payload, kits] - if: ${{ !cancelled() && !failure() && needs.changes.outputs.nvim == 'true' }} - permissions: - contents: read - actions: read - runs-on: ${{ matrix.os }} - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04, versions: 0.10.4 0.11.5 0.12.5 } - - { platform: darwin-arm64, os: macos-15, versions: 0.10.4 0.12.5 } - - { platform: win32-x64, os: windows-2022, versions: 0.10.4 0.12.5 } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - run-id: ${{ needs.changes.outputs.lend }} - kit: 'true' - - name: Neovim from xlings - run: | - set -euo pipefail - for version in ${{ matrix.versions }}; do - for attempt in 1 2 3; do - if xlings install "nvim@$version" -y -g; then break; fi - [ "$attempt" = 3 ] && { echo "::error::nvim@$version could not be installed"; exit 1; } - sleep 30 - done - done - - name: The plugin on each Neovim - run: | - set -euo pipefail - # The payload's server: its okb, which the plugin starts through the runner the payload names. - server="$PWD/payload/bin/mcppls.okb"; [ "$RUNNER_OS" = Windows ] && server="$(cygpath -w "$server")" - failed='' - for version in ${{ matrix.versions }}; do - xlings use nvim "$version" - nvim --version | head -1 - # No `timeout` wrapper: macOS has none, and every wait in smoke.lua is bounded already. - for mode in setup enable; do - echo "::group::Neovim $version, $mode" - if ! MCPPLS_SERVER="$server" nvim --headless --clean -l editors/nvim/tests/smoke.lua "$mode"; then - failed="$failed $version:$mode" - fi - echo "::endgroup::" - done - done - if [ -n "$failed" ]; then echo "::error::failed:$failed"; exit 1; fi - - # --------------------------------------------------------------------------- - # The Zed extension's choice of server (editors/zed/src/resolve.rs) as a plain Rust test on the - # runner, and the crate still compiling to the wasm Zed loads. Every pull request: a few seconds. - zed-unit: - name: Zed extension (cargo test) - needs: changes - if: needs.changes.outputs.zed == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - uses: actions/checkout@v7 - - name: Unit tests - run: cargo test --manifest-path editors/zed/Cargo.toml - - name: The wasm build Zed loads - run: | - rustup target add wasm32-wasip2 - cargo build --release --target wasm32-wasip2 --manifest-path editors/zed/Cargo.toml - - # A pull request that changes release packaging gets the same smoke from release-checks.yml, so - # it is not run twice here. nightly.yml and every release candidate run it on their own. - zed-smoke: - name: real Zed - needs: [changes, payload, kits] - if: ${{ !cancelled() && !failure() && github.event_name == 'pull_request' && needs.changes.outputs.zed_real == 'true' && needs.changes.outputs.packaging != 'true' }} - permissions: - contents: read - actions: read - uses: ./.github/workflows/zed-smoke.yml - with: - payload-run: ${{ needs.changes.outputs.lend }} - - # --------------------------------------------------------------------------- - # The CLion plugin (editors/clion), 0.0.8 part 2 CL-2 and CL-3. Gradle comes from xlings like the - # rest of the tools here, and the JDK with it. The IDEs Gradle downloads (about 1.9 GB each) are - # kept in the Gradle cache under a key that carries their versions, so they are fetched once per - # bump, not once per run. - # - # verifyPlugin (the JetBrains Plugin Verifier) runs on every pull request: the plugin against CLion - # 2026.1, the oldest build it claims, and against the one it is built on. - clion-verify: - name: CLion plugin verifier - needs: changes - if: needs.changes.outputs.clion == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: Gradle from xlings - run: xlings install gradle -y -g - - name: Cache the CLion downloads - uses: actions/cache@v6 - with: - path: ~/.gradle/caches/modules-2 - key: gradle-clion-${{ hashFiles('editors/clion/gradle.properties', 'editors/clion/build.gradle.kts') }} - - name: Verify the plugin - working-directory: editors/clion - run: gradle --no-daemon verifyPlugin - - uses: actions/upload-artifact@v7 - if: always() - with: - name: clion-verifier - path: editors/clion/build/reports/pluginVerifier - if-no-files-found: ignore - retention-days: 7 - - # CLion 2026.2.3 itself, headless, with the platform's test framework: the sandbox holds the plugin, - # carrying the one payload this run built (MCPPLS_PAYLOAD), and the - # plugin starts that payload's server with nothing on the PATH. The test asserts what a person sees - # in an mcpp project -- the server Running, a diagnostic, a module-name completion, the process gone - # when the project closes -- and the one-engine rule: a CMake project stays CLion's, and "mcppls - # only" lowers a file to Syntax with mcppls's diagnostics kept (editors/clion/src/test). What CLion's - # own engine answers for the same file is printed in the log. - clion-e2e: - name: CLion 2026.2.3 (headless) - needs: [changes, payload, kits] - if: ${{ !cancelled() && !failure() && needs.changes.outputs.clion == 'true' }} - permissions: - contents: read - actions: read - runs-on: ubuntu-24.04 - timeout-minutes: 60 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: Gradle from xlings - run: xlings install gradle -y -g - - uses: ./.github/actions/payload - with: - platform: linux-x64 - run-id: ${{ needs.changes.outputs.lend }} - kit: 'true' - - name: Cache the CLion downloads - uses: actions/cache@v6 - with: - path: ~/.gradle/caches/modules-2 - key: gradle-clion-${{ hashFiles('editors/clion/gradle.properties', 'editors/clion/build.gradle.kts') }} - # Issue #71: the project a person has after `mcpp build`, which CLion's Compilation Database must not take. - - name: Build the mcpp-built fixture with mcpp - working-directory: editors/clion/src/test/fixtures/mcpp-built - run: mcpp build - - name: Tests in CLion - working-directory: editors/clion - env: - MCPPLS_PAYLOAD: ${{ github.workspace }}/payload - MCPPLS_FIXTURES_BUILT: '1' - run: | - set -euo pipefail - "$MCPPLS_PAYLOAD/libexec/okrun/linux-x86_64/okrun" "$MCPPLS_PAYLOAD/bin/mcppls.okb" version - gradle --no-daemon test buildPlugin - - uses: actions/upload-artifact@v7 - with: - name: mcppls-clion-zip - path: editors/clion/build/distributions/mcppls-clion-*.zip - if-no-files-found: error - - uses: actions/upload-artifact@v7 - if: always() - with: - name: clion-e2e - path: | - editors/clion/build/reports/tests - editors/clion/build/test-results - editors/clion/build/idea-sandbox/*/log - if-no-files-found: ignore - retention-days: 7 - - # Test the current adapter and native server while an engine candidate is - # unqualified, preserving the published 0.0.19 engine/kit bytes. This does not - # qualify the candidate image or its final payload. The old server remains a - # recorded negative control for request-lifecycle errors; it is not required - # to pass a regression that checks the fix for those errors. - clion-adapter-baseline-package: - name: CLion current server with the published engine - needs: [changes, build] - if: ${{ !cancelled() && !failure() && needs.changes.outputs.clion == 'true' }} - permissions: - contents: read - actions: read - runs-on: ubuntu-24.04 - timeout-minutes: 45 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: Gradle from xlings - run: xlings install gradle -y -g - - uses: actions/cache@v6 - with: - path: ~/.gradle/caches/modules-2 - key: gradle-clion-${{ hashFiles('editors/clion/gradle.properties', 'editors/clion/build.gradle.kts') }} - - name: The unchanged public payload - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p baseline-plugin - gh release download v0.0.19 --repo Sunrisepeak/mcpp-language-server \ - --pattern 'mcppls-clion-0.0.19-linux-x64.zip' --dir baseline-plugin - unzip -q baseline-plugin/mcppls-clion-0.0.19-linux-x64.zip -d baseline-plugin/unpacked - jq -e '.server.version == "0.0.19"' baseline-plugin/unpacked/mcppls-clion/payload/payload.json - - uses: actions/download-artifact@v8 - with: - name: native-server-linux-x64 - run-id: ${{ needs.changes.outputs.lend || github.run_id }} - github-token: ${{ github.token }} - path: native-server - - name: Replace only the published server with the current native build - run: | - set -euo pipefail - (cd native-server && sha256sum --check SHA256SUMS) - chmod +x native-server/mcppls - native-server/mcppls version - python3 - <<'PYTHON' - import hashlib, json, os, pathlib, shutil, subprocess, tomllib - payload = pathlib.Path('baseline-plugin/unpacked/mcppls-clion/payload') - manifest_path = payload / 'payload.json' - manifest = json.loads(manifest_path.read_text()) - assert manifest['payload-version'] == 4 and manifest['platform'] == 'linux-x64' - assert manifest['server']['version'] == '0.0.19' - version = tomllib.loads(pathlib.Path('mcpp.toml').read_text())['package']['version'] - source = pathlib.Path('native-server/mcppls') - assert subprocess.check_output([str(source.resolve()), 'version'], text=True).split()[:2] == ['mcppls', version] - frozen = {str(p.relative_to(payload)): hashlib.sha256(p.read_bytes()).hexdigest() - for root in ('clangd', 'kit') for p in (payload / root).rglob('*') if p.is_file()} - assert frozen and 'clangd/bin/clangd' in frozen and 'kit/kit.json' in frozen - destination = payload / 'bin/mcppls' - shutil.copy2(source, destination) - manifest['server'] = {'path': 'bin/mcppls', 'version': version} - manifest['build'] = {'source': 'from-source', 'commit': os.environ['GITHUB_SHA'], 'dirty': False} - manifest['regression'] = {'frozen-engine-release': 'v0.0.19', 'candidate-qualified': False} - manifest['files']['bin/mcppls'] = {'sha256': hashlib.sha256(destination.read_bytes()).hexdigest(), - 'size': destination.stat().st_size} - manifest_path.write_text(json.dumps(manifest, indent=2) + '\n') - assert all(hashlib.sha256((payload / path).read_bytes()).hexdigest() == sha for path, sha in frozen.items()) - print('current native server:', version, '; preserved engine/kit members:', len(frozen)) - PYTHON - - name: Package this adapter once with the current server and published engine - run: | - gradle --no-daemon --max-workers=4 -p editors/clion buildPlugin \ - -Pmcppls.payload="$PWD/baseline-plugin/unpacked/mcppls-clion/payload" - - uses: actions/upload-artifact@v7 - with: - name: mcppls-clion-current-server-zip - path: editors/clion/build/distributions/mcppls-clion-*.zip - if-no-files-found: error - - clion-stable-adapter: - name: CLion stable current-server regression (public engine) - needs: clion-adapter-baseline-package - uses: ./.github/workflows/clion-stable.yml - with: - plugin_artifact: mcppls-clion-current-server-zip - report_name: clion-stable-current-server - - # Both stable IDEs install the same candidate ZIP. This independent build has - # no dependency on the plugin's source output or a per-IDE recompilation. - clion-stable: - needs: clion-e2e - uses: ./.github/workflows/clion-stable.yml - with: - plugin_artifact: mcppls-clion-zip - - # --------------------------------------------------------------------------- - # The measurements (tests/bench/README.md): the cases of mcppls-bench this platform runs (`on:`), the - # release's own (`release`) and the user-experience scenarios (`ux`, the job below) left out: a session's start, cold and warm on one cache, with its first navigation - # bounded; the review fixtures, each rule's precision and recall bounded; on Linux the semantic tokens of a large - # file, whose checks are the design's criteria, and the agent task benchmark's data (each task's baseline fails a - # check and its reference passes them, with mcpp and GCC 16 and no agent). The store takes main's last one, so - # each metric is shown beside its history and its bounds (`mtest analyze trend`). - bench: - name: bench (${{ matrix.platform }}) - needs: [payload, kits] - permissions: - contents: read - actions: read # the store main's last run kept, each metric's history - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04 } - - { platform: darwin-arm64, os: macos-15 } - - { platform: win32-x64, os: windows-2022 } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: GCC 16 (two of the ten tasks, and the review fixture toolchain-divergence, build with it) - if: runner.os == 'Linux' - run: mcpp toolchain install gcc 16.1.0 - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - kit: 'true' - - uses: ./.github/actions/mtest - - name: The store of main's last run - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow ci.yml --branch main --status success --limit 1 \ - --json databaseId -q '.[0].databaseId' 2>/dev/null || true) - [ -n "$run" ] || { echo "no successful run of main: no history"; exit 0; } - rm -rf "$RUNNER_TEMP/bench-store" - if ! gh run download "$run" -R "$GITHUB_REPOSITORY" -n "bench-mtest-${{ matrix.platform }}" -D "$RUNNER_TEMP/bench-store" 2>/dev/null; then - echo "main's run $run kept no store for ${{ matrix.platform }}: no history"; exit 0 - fi - "$MTEST" store import --from "$RUNNER_TEMP/bench-store" - - name: The measurements - run: | - set -euo pipefail - exe=''; [ "$RUNNER_OS" != Windows ] || exe='.exe' - export MCPPLS_CTS_MEASURES="$RUNNER_TEMP/bench-measures" - status=0 - "$MTEST" run --junit "$RUNNER_TEMP/bench.xml" --tag "on:${{ matrix.platform }}" --exclude-tag release --exclude-tag ux \ - "stage/mcppls-bench$exe" || status=$? - "$MTEST" analyze trend --runs 10 | tee -a "$GITHUB_STEP_SUMMARY" || true - if [ "$status" != 0 ]; then - "$MTEST" explain || true - for out in .mtest/runs/*/diagnoses/*/out.txt; do - [ -f "$out" ] || continue - echo "::group::diagnosis $(dirname "$out")"; tail -200 "$out"; echo "::endgroup::" - done - exit 1 - fi - - uses: actions/upload-artifact@v7 - if: always() - with: - name: bench-mtest-${{ matrix.platform }} - path: .mtest - include-hidden-files: true - if-no-files-found: ignore - - uses: actions/upload-artifact@v7 - if: always() - with: - name: bench-results-${{ matrix.platform }} - path: | - ${{ runner.temp }}/bench.xml - ${{ runner.temp }}/bench-measures/*.json - if-no-files-found: ignore - - # --------------------------------------------------------------------------- - # A pull request that changes release packaging (the `packaging` of `changes`) gets the release checks - # after the CI it runs anyway: the plugins packaged, installed and removed, performance, stability, the - # staged candidate. - release-checks: - name: release checks - needs: [changes, specifications, build, test, openkal-test, server, kits, payload, vsix, conformance, clean-machine, vscode-e2e, distribution, neovim, bench] - if: needs.changes.outputs.packaging == 'true' - uses: ./.github/workflows/release-checks.yml - - # --------------------------------------------------------------------------- - # Main lends what it built: a marker named after the server's inputs, which a pull request that changes - # only a plugin finds (`changes`), and whose run's payload, stage and kits it then tests over. - server-digest: - name: lend the payload of these server inputs - needs: [changes, build, kits, payload] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.server == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - run: echo '${{ needs.changes.outputs.digest }}' > "$RUNNER_TEMP/digest.txt" - - uses: actions/upload-artifact@v7 - with: - name: server-digest-${{ needs.changes.outputs.digest }} - path: ${{ runner.temp }}/digest.txt - retention-days: 7 - - # THE ONE CHECK A BRANCH REQUIRES. The jobs a change does not reach are skipped, so no job of them can be - # required; this one is, and it fails when any job that ran failed or was cancelled. - ci: - name: ci - if: always() - needs: [changes, invariants, specifications, engine-candidate-parts, mcpp, build, test, openkal-test, unit-test-report, host-platform, - server, kits, payload, vsix, conformance, proot, ux, clean-machine, vscode-e2e, distribution, code-oss-e2e, - neovim, zed-unit, zed-smoke, clion-verify, clion-e2e, clion-adapter-baseline-package, clion-stable-adapter, - clion-stable, bench, release-checks] - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - env: - RESULTS: ${{ toJSON(needs.*.result) }} - run: | - set -euo pipefail - echo "$RESULTS" - if echo "$RESULTS" | grep -Eq '"(failure|cancelled)"'; then - echo "::error::a job this change reaches failed or was cancelled"; exit 1 - fi diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml deleted file mode 100644 index a11fd51f..00000000 --- a/.github/workflows/clion-stable.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: CLion stable runtime - -on: - workflow_call: - inputs: - plugin_artifact: - type: string - default: '' - expected_server_version: - type: string - default: '' - report_name: - type: string - default: clion-stable-candidate - workflow_dispatch: - inputs: - release_tag: - description: Published plugin to test without rebuilding the server or engine - type: string - default: v0.0.19 - -env: - XLINGS_NON_INTERACTIVE: '1' - -permissions: - contents: read - -jobs: - runtime: - name: packaged plugin on CLion ${{ matrix.version }} - runs-on: ubuntu-24.04 - timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - version: ['2026.1.5.2', '2026.2.4.1'] - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: Gradle and a virtual display - run: | - xlings install gradle -y -g - sudo apt-get update -qq - sudo apt-get install -y xvfb - - uses: actions/cache@v6 - with: - path: ~/.gradle/caches/modules-2 - key: clion-stable-runtime-${{ hashFiles('editors/clion/integration-tests/*.gradle.kts') }} - - name: The candidate ZIP packaged once by CI - if: inputs.plugin_artifact != '' - uses: actions/download-artifact@v8 - with: - name: ${{ inputs.plugin_artifact }} - path: stable-plugin - - name: A published ZIP for an independent compatibility check - if: inputs.plugin_artifact == '' - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.release_tag || 'v0.0.19' }} - run: | - set -euo pipefail - [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] - mkdir -p stable-plugin - version="${RELEASE_TAG#v}" - # 0.0.19 used platform ZIPs; later releases carry one universal payload. - if ! gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --pattern "mcppls-clion-$version.zip" --dir stable-plugin; then - gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --pattern "mcppls-clion-$version-linux-x64.zip" --dir stable-plugin - fi - - name: Run the actual packaged language features in the IDE - env: - IDE_VERSION: ${{ matrix.version }} - run: | - set -euo pipefail - mapfile -t plugins < <(find stable-plugin -maxdepth 1 -name 'mcppls-clion-*.zip' -print) - test "${#plugins[@]}" -eq 1 - expected="${{ inputs.expected_server_version }}" - if [ -z "$expected" ]; then - if [ -n "${{ inputs.plugin_artifact }}" ]; then - # The zip carries the server this repository builds, which a server patch - # (docs/92-release.md "Versions") moves past the plugin's own version. - expected="$(sed -n 's/^version *= *"\(.*\)"/\1/p' mcpp.toml)" - else - expected="${{ inputs.release_tag || 'v0.0.19' }}"; expected="${expected#v}" - fi - fi - home="$RUNNER_TEMP/clion-stable-home" - mkdir -p "$home" - xvfb-run -a gradle --no-daemon --max-workers=4 \ - -p editors/clion/integration-tests test \ - -PpluginZip="$GITHUB_WORKSPACE/${plugins[0]}" \ - -PideVersion="$IDE_VERSION" \ - -PexpectedServerVersion="$expected" \ - -PtestHome="$home" \ - -Poutput="$RUNNER_TEMP/clion-stable-result" - # Public versions, ZIP identity and assertion outcomes only. No raw IDE - # logs, private user paths or arbitrary project contents are uploaded. - - uses: actions/upload-artifact@v7 - if: always() - with: - name: ${{ inputs.report_name || 'clion-stable-release' }}-${{ matrix.version }} - path: ${{ runner.temp }}/clion-stable-result/result.json - if-no-files-found: warn diff --git a/.github/workflows/issue86-repro.yml b/.github/workflows/issue86-repro.yml new file mode 100644 index 00000000..95c8f829 --- /dev/null +++ b/.github/workflows/issue86-repro.yml @@ -0,0 +1,290 @@ +# TEMPORARY (issue 86) -- not for merging. The branch carries this workflow alone. +# +# The reporter's machine, on a hosted Windows runner: the project they had open (yizhinailong/systop, at the +# commit of their report), built by the mcpp they ran, and the released 0.22.0 payload they ran -- the same +# server, the same clangd image (sha256 47a09679...). Two experiments, each leaving what an analysis needs: +# +# 1. The session, replayed (.github/issue86/drive_lsp.py): the server started as the extension starts it, +# the files opened, an editor's requests kept up, then the engine restarted until the sandbox's grant +# table refuses a start. Leaves the server's logs, incidents and bundles, the engine database, clangd's +# command line, and a dump of each okrun.exe that ends by an exception (Windows Error Reporting). +# 2. clangd --check on src/ui/monitor_view.cppm as a matrix (.github/issue86/check_matrix.py): the +# reporter's reduction row by row, and the switches that say which part of clangd traps; a run that +# ends by an exception is repeated under cdb, and symbolize.py names its frames from the image's ELF. +name: issue86-repro + +on: + pull_request: + workflow_dispatch: + inputs: + session_seconds: + description: 'How long the replayed session keeps up an editor''s requests before the restart loop' + default: '420' + +permissions: + contents: read + +concurrency: + group: issue86-${{ github.ref }} + cancel-in-progress: true + +env: + SYSTOP_REPOSITORY: yizhinailong/systop + SYSTOP_COMMIT: b50613226647f4b6583d683f7a7e87dd6b405d0b + MCPP_VERSION: 2026.11.10.2 + MCPP_SHA256: e158b07d38f73e26701b48a2cbf1b7eb50749447903672ddb3544b220747128c + LLVM_VERSION: 23.1.3 + MCPPLS_VERSION: 0.22.0 + PAYLOAD_SHA256: 94269a69f8519c663a95e4b0674961bc42d12fa3fe2d3781cf02d7f5ee4ee6a2 + CLANGD_SHA256: 47a0967956dff5b6071c192fd210ad3a545b237255d2285799ca8bbb551df6d9 + PYTHONUTF8: '1' + +jobs: + repro: + name: systop on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + os: [windows-2025, windows-2022] + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v7 + + - name: The reporter's project + uses: actions/checkout@v7 + with: + repository: ${{ env.SYSTOP_REPOSITORY }} + ref: ${{ env.SYSTOP_COMMIT }} + path: systop + persist-credentials: false + + - name: The machine + run: | + $evidence = Join-Path $env:RUNNER_TEMP 'evidence' + New-Item -ItemType Directory -Force -Path $evidence, (Join-Path $env:RUNNER_TEMP 'dumps'), (Join-Path $env:RUNNER_TEMP 'wer') | Out-Null + "EVIDENCE=$evidence" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $machine = [ordered]@{ + os = (Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, OSArchitecture) + processor = (Get-CimInstance Win32_Processor | Select-Object Name, Manufacturer, NumberOfCores, NumberOfLogicalProcessors, Description) + memoryGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 1) + python = (python --version) + } + $vswhere = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path $vswhere) { $machine.visualStudio = (& $vswhere -all -products * -format json | ConvertFrom-Json | Select-Object displayName, installationVersion, installationPath) } + $machine.msvcTools = @(Get-ChildItem 'C:\Program Files\Microsoft Visual Studio\2022\*\VC\Tools\MSVC' -ErrorAction SilentlyContinue | ForEach-Object { $_.FullName; (Get-ChildItem $_.FullName).Name }) + $machine.windowsSdks = @((Get-ChildItem 'C:\Program Files (x86)\Windows Kits\10\Include' -ErrorAction SilentlyContinue).Name) + $machine | ConvertTo-Json -Depth 6 | Tee-Object -FilePath (Join-Path $evidence 'machine.json') + + - name: The debuggers + run: | + $ErrorActionPreference = 'Continue' + $cdb = 'C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\cdb.exe' + if (!(Test-Path $cdb)) { + Write-Host 'cdb is not installed; asking Chocolatey for the debugging tools' + choco install windows-sdk-10-version-2004-windbg -y --no-progress + } + if (Test-Path $cdb) { + "CDB=$cdb" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + & $cdb -version + } else { + Write-Host '::warning::no cdb on this runner: a trap is captured by procdump as a dump only' + } + $zip = Join-Path $env:RUNNER_TEMP 'procdump.zip' + try { + Invoke-WebRequest -Uri 'https://download.sysinternals.com/files/Procdump.zip' -OutFile $zip + Expand-Archive -LiteralPath $zip -DestinationPath (Join-Path $env:RUNNER_TEMP 'procdump') -Force + "PROCDUMP=$(Join-Path $env:RUNNER_TEMP 'procdump\procdump64.exe')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } catch { Write-Host "::warning::procdump was not downloaded: $_" } + # A full dump of every okrun.exe that ends by an unhandled exception, the clangd of the replayed session included. + $wer = 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting' + Set-ItemProperty -Path $wer -Name Disabled -Value 0 -Type DWord -ErrorAction SilentlyContinue + Set-ItemProperty -Path $wer -Name DontShowUI -Value 1 -Type DWord -ErrorAction SilentlyContinue + $key = "$wer\LocalDumps\okrun.exe" + New-Item -Path $key -Force | Out-Null + Set-ItemProperty -Path $key -Name DumpFolder -Value (Join-Path $env:RUNNER_TEMP 'wer') -Type ExpandString + Set-ItemProperty -Path $key -Name DumpType -Value 2 -Type DWord + Set-ItemProperty -Path $key -Name DumpCount -Value 3 -Type DWord + Get-ItemProperty -Path $key | Format-List DumpFolder, DumpType, DumpCount + + - name: The mcpp the reporter ran + run: | + $ErrorActionPreference = 'Stop' + $archiveName = "mcpp-$env:MCPP_VERSION-windows-x86_64" + $archivePath = Join-Path $env:RUNNER_TEMP "$archiveName.zip" + $installRoot = Join-Path $env:RUNNER_TEMP $archiveName + Invoke-WebRequest -Uri "https://github.com/mcpp-community/mcpp/releases/download/v$env:MCPP_VERSION/$archiveName.zip" -OutFile $archivePath + if ((Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash -ne $env:MCPP_SHA256) { throw 'The mcpp release archive checksum does not match.' } + Expand-Archive -LiteralPath $archivePath -DestinationPath $env:RUNNER_TEMP -Force + $mcppBin = Join-Path $installRoot 'bin' + "MCPP_HOME=$installRoot" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $mcppBin | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + $env:MCPP_HOME = $installRoot + & (Join-Path $mcppBin 'mcpp.exe') --version + + - name: The toolchain, and the project built once + working-directory: systop + run: | + $ErrorActionPreference = 'Continue' + mcpp toolchain install llvm $env:LLVM_VERSION + mcpp toolchain default "llvm@$env:LLVM_VERSION" + mcpp build 2>&1 | Tee-Object -FilePath (Join-Path $env:EVIDENCE 'mcpp-build.log') + Write-Host "mcpp build ended $LASTEXITCODE" + # The reporter ran the tests too, which is what fetches the dev-dependency (mcpplibs.mtest): without it the + # server's offline `mcpp emit build-database` stops for a download (exit 5) and the model is only inferred. + mcpp test --timeout 120 2>&1 | Tee-Object -FilePath (Join-Path $env:EVIDENCE 'mcpp-test.log') + Write-Host "mcpp test ended $LASTEXITCODE" + # What the server will ask, asked here: offline, as the reporter's setting has it. + $env:MCPP_OFFLINE = '1' + mcpp emit build-database --format json 2> (Join-Path $env:EVIDENCE 'mcpp-emit.stderr.log') | Out-File -FilePath (Join-Path $env:EVIDENCE 'mcpp-build-database.json') -Encoding utf8 + Write-Host "mcpp emit build-database (offline) ended $LASTEXITCODE; $((Get-Item (Join-Path $env:EVIDENCE 'mcpp-build-database.json')).Length) bytes" + Get-Content (Join-Path $env:EVIDENCE 'mcpp-emit.stderr.log') -Tail 20 + exit 0 + + - name: The released payload + run: | + $ErrorActionPreference = 'Stop' + $archive = Join-Path $env:RUNNER_TEMP "mcppls-payload-$env:MCPPLS_VERSION.tar.gz" + Invoke-WebRequest -Uri "https://github.com/Sunrisepeak/mcpp-language-server/releases/download/v$env:MCPPLS_VERSION/mcppls-payload-$env:MCPPLS_VERSION.tar.gz" -OutFile $archive + if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $env:PAYLOAD_SHA256) { throw 'The payload archive checksum does not match.' } + tar -xzf $archive -C $env:RUNNER_TEMP + $payload = Join-Path $env:RUNNER_TEMP 'payload' + $image = Join-Path $payload 'clangd\openkal\bin\clangd.okb' + if ((Get-FileHash -LiteralPath $image -Algorithm SHA256).Hash -ne $env:CLANGD_SHA256) { throw 'clangd.okb is not the image of the report.' } + # The image's ELF of this architecture, whose symbol table names a trap's frames. + python -c "import sys, zipfile; z = zipfile.ZipFile(sys.argv[1]); print(z.namelist()); z.extract('x86_64/clangd.oke', sys.argv[2]); open(sys.argv[2] + '/okb.json', 'wb').write(z.read('okb.json'))" $image (Join-Path $env:RUNNER_TEMP 'oke') + Copy-Item (Join-Path $env:RUNNER_TEMP 'oke\okb.json') (Join-Path $env:EVIDENCE 'clangd-okb.json') + Copy-Item (Join-Path $payload 'payload.json') (Join-Path $env:EVIDENCE 'payload.json') + "PAYLOAD=$payload" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "OKRUN=$(Join-Path $payload 'libexec\okrun\windows-x86_64\okrun.exe')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "CLANGD_ELF=$(Join-Path $env:RUNNER_TEMP 'oke\x86_64\clangd.oke')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "MCPPLS_CACHE=$(Join-Path $env:RUNNER_TEMP 'mcppls-cache')" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + + - name: 1 - the session, replayed + timeout-minutes: 30 + continue-on-error: true + run: | + $seconds = if ('${{ inputs.session_seconds }}') { '${{ inputs.session_seconds }}' } else { '150' } + python .github/issue86/drive_lsp.py ` + --okrun $env:OKRUN --server (Join-Path $env:PAYLOAD 'bin\mcppls.okb') --payload $env:PAYLOAD ` + --root (Join-Path $env:GITHUB_WORKSPACE 'systop') --cache $env:MCPPLS_CACHE --out (Join-Path $env:EVIDENCE 'session') ` + --open src/ui/monitor_view.cppm src/main.cpp src/core/refresh_clock.cppm src/core/sampler.cppm src/systop.cppm ` + --duration $seconds --stop-after-exits 3 --restarts 0 --restart-wait 25 --log-level info + if (Test-Path (Join-Path $env:EVIDENCE 'session\lsp-summary.md')) { Get-Content (Join-Path $env:EVIDENCE 'session\lsp-summary.md') | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append } + + - name: 2 - clangd --check, the matrix + timeout-minutes: 40 + continue-on-error: true + run: | + $database = Get-ChildItem -Path (Join-Path $env:MCPPLS_CACHE 'workspaces') -Recurse -Filter compile_commands.json -ErrorAction SilentlyContinue | + Where-Object { $_.DirectoryName -match '\\contexts\\[^\\]+\\cdb$' } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if (!$database) { Write-Host '::error::the replayed session left no engine database'; exit 1 } + Write-Host "engine database: $($database.DirectoryName)" + python .github/issue86/check_matrix.py ` + --okrun $env:OKRUN --image (Join-Path $env:PAYLOAD 'clangd\openkal\bin\clangd.okb') --elf $env:CLANGD_ELF ` + --project (Join-Path $env:GITHUB_WORKSPACE 'systop') --database $database.DirectoryName ` + --session (Join-Path $env:EVIDENCE 'session\lsp-summary.json') ` + --cdb "$env:CDB" --procdump "$env:PROCDUMP" --wer (Join-Path $env:RUNNER_TEMP 'wer') --dumps (Join-Path $env:RUNNER_TEMP 'dumps') ` + --out (Join-Path $env:EVIDENCE 'matrix') --timeout 300 + if (Test-Path (Join-Path $env:EVIDENCE 'matrix\matrix.md')) { Get-Content (Join-Path $env:EVIDENCE 'matrix\matrix.md') | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append } + # The other files the incidents name, each as it is (the switches only if it traps). + foreach ($file in 'src/core/sampler.cppm', 'src/main.cpp', 'tests/test_monitor_view.cpp', 'src/core/refresh_clock.cppm') { + $name = 'matrix-' + ($file -replace '[\\/.]', '-') + python .github/issue86/check_matrix.py --only-original --file $file ` + --okrun $env:OKRUN --image (Join-Path $env:PAYLOAD 'clangd\openkal\bin\clangd.okb') --elf $env:CLANGD_ELF ` + --project (Join-Path $env:GITHUB_WORKSPACE 'systop') --database $database.DirectoryName ` + --cdb "$env:CDB" --dumps (Join-Path $env:RUNNER_TEMP 'dumps') --out (Join-Path $env:EVIDENCE $name) --timeout 300 + if (Test-Path (Join-Path $env:EVIDENCE "$name\matrix.md")) { Get-Content (Join-Path $env:EVIDENCE "$name\matrix.md") | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append } + } + + - name: What the server left + if: always() + shell: python + run: | + import os, shutil + cache = os.environ.get("MCPPLS_CACHE", "") + out = os.path.join(os.environ["EVIDENCE"], "server-cache") + kept = 0 + for folder, folders, files in os.walk(cache): + # The module caches and indexes are large and say nothing a log does not. + folders[:] = [name for name in folders if name not in (".cache", "modules", "index", "clangd-memos", "trash")] + for name in files: + path = os.path.join(folder, name) + if os.path.getsize(path) > 8 * 1024 * 1024: + continue + target = os.path.join(out, os.path.relpath(path, cache)) + os.makedirs(os.path.dirname(target), exist_ok=True) + shutil.copy2(path, target) + kept += 1 + print(f"{kept} files of the server's cache kept") + for name in ("wer", "dumps"): + folder = os.path.join(os.environ["RUNNER_TEMP"], name) + for entry in sorted(os.listdir(folder)) if os.path.isdir(folder) else []: + print(name, entry, os.path.getsize(os.path.join(folder, entry))) + + - name: The dumps, packed + if: always() + run: | + $files = @(Get-ChildItem (Join-Path $env:RUNNER_TEMP 'wer'), (Join-Path $env:RUNNER_TEMP 'dumps') -File -ErrorAction SilentlyContinue) + if ($files.Count -gt 0) { + 7z a -t7z -mx=3 (Join-Path $env:RUNNER_TEMP 'issue86-dumps.7z') ($files | ForEach-Object { $_.FullName }) | Select-Object -Last 4 + } else { Write-Host 'no dumps were written' } + + - uses: actions/upload-artifact@v7 + if: always() + with: + name: issue86-evidence-${{ matrix.os }} + path: ${{ runner.temp }}/evidence + retention-days: 14 + if-no-files-found: warn + + - uses: actions/upload-artifact@v7 + if: always() + with: + name: issue86-dumps-${{ matrix.os }} + path: ${{ runner.temp }}/issue86-dumps.7z + retention-days: 7 + compression-level: 0 + if-no-files-found: ignore + + # The evidence as assets of one temporary pre-release (`issue86-evidence`): a release asset downloads far + # faster than an Actions artifact. Not a release of the product; deleted with the branch. + publish: + name: evidence as release assets + needs: repro + if: always() + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + path: artifacts + - name: Upload to the temporary pre-release + env: + GH_TOKEN: ${{ github.token }} + TAG: issue86-evidence + TARGET: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + cd artifacts + ls -la + for directory in issue86-evidence-*; do + [ -d "$directory" ] || continue + (cd "$directory" && zip -qr "../$directory.zip" .) + done + for directory in issue86-dumps-*; do + [ -f "$directory/issue86-dumps.7z" ] && cp "$directory/issue86-dumps.7z" "$directory.7z" + done + sha256sum issue86-*.zip issue86-*.7z | tee SHA256SUMS.txt + notes="TEMPORARY -- not a release of mcpp-language-server. Evidence for issue #86 gathered by the issue86-repro workflow (run ${GITHUB_RUN_ID}, attempt ${GITHUB_RUN_ATTEMPT}, commit ${TARGET}): https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}. Deleted when the analysis is done." + if gh release view "$TAG" -R "$GITHUB_REPOSITORY" > /dev/null 2>&1; then + gh release edit "$TAG" -R "$GITHUB_REPOSITORY" --notes "$notes" --prerelease --latest=false + else + gh release create "$TAG" -R "$GITHUB_REPOSITORY" --target "$TARGET" --prerelease --latest=false --title "TEMP: issue 86 evidence (not a release)" --notes "$notes" + fi + gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber issue86-*.zip issue86-*.7z SHA256SUMS.txt + gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json assets -q '.assets[] | "\(.name) \(.size) \(.url)"' diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml deleted file mode 100644 index 59b5daaa..00000000 --- a/.github/workflows/nightly.yml +++ /dev/null @@ -1,536 +0,0 @@ -name: nightly - -# What is too slow or too variable for every commit (usable plan W7, W8): the whole CI, -# then five cold and warm starts per host, their medians bounded and shown beside the nights -# before (tests/bench), the self-hosting fixtures with their timings, and the mcpp fixtures -# with the latest mcpp. The measure files are uploaded. -# The whole CI includes the CLion plugin in a real CLion (ci.yml clion-verify and clion-e2e, which -# run on anything that is not a pull request). -on: - schedule: - - cron: '30 18 * * *' - # A manual run can test what a pull request's CI already built, and only the ux rounds, as many - # times as asked, beside a released server: a change is measured without being built again - # (docs/92-release.md, "Measuring a change"). - workflow_dispatch: - inputs: - payload-run: - description: A CI run of this branch whose payloads to test (its run id); empty builds them here - default: '' - scope: - description: 'all: every job; ux: only the ux rounds (and their budgets and comparison)' - type: choice - options: [all, ux] - default: all - ux-repeat: - description: How many times each ux round runs its edits stage, each in a new server session - default: '1' - ux-baseline: - description: A release (v0.0.16) whose server runs the same stages beside this one, on the same machine - default: '' - -permissions: - contents: read - actions: read # another run's payload artifacts (payload-run) - -env: - # The build pins live in .github/versions.env and are exported by .github/actions/setup-mcpp, - # so a bump lands in one file instead of three. A job may still set one to override it. - XLINGS_NON_INTERACTIVE: '1' - -jobs: - ci: - if: ${{ github.event_name != 'workflow_dispatch' || inputs.payload-run == '' }} - uses: ./.github/workflows/ci.yml - - # The Zed extension in a real Zed (editors/zed/tests/smoke.sh), on the payload ci.yml just built. - # Every night, so a Zed or lavapipe that stops working shows up here before a pull request meets it. - zed: - name: real Zed - needs: ci - if: ${{ !cancelled() && inputs.scope != 'ux' }} - uses: ./.github/workflows/zed-smoke.yml - with: - payload-run: ${{ inputs.payload-run }} - - timing: - name: startup timing (${{ matrix.platform }}) - needs: ci - # Timings and self-hosting need only the payloads: a failure elsewhere in CI does not skip them. - if: ${{ !cancelled() && inputs.scope != 'ux' }} - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04 } - - { platform: darwin-arm64, os: macos-15 } - - { platform: win32-x64, os: windows-2022 } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - run-id: ${{ inputs.payload-run }} - kit: 'true' - - uses: ./.github/actions/mtest - # The night before's store, so each start's medians are shown beside their history (`mtest analyze trend`). - - name: The store of the last night - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - run=$(gh run list -R "$GITHUB_REPOSITORY" --workflow nightly.yml --status success --limit 1 \ - --json databaseId -q '.[0].databaseId' 2>/dev/null || true) - [ -n "$run" ] || { echo "no night before: no history"; exit 0; } - rm -rf "$RUNNER_TEMP/timing-store" - gh run download "$run" -R "$GITHUB_REPOSITORY" -n "nightly-timing-mtest-${{ matrix.platform }}" -D "$RUNNER_TEMP/timing-store" 2>/dev/null \ - && "$MTEST" store import --from "$RUNNER_TEMP/timing-store" || echo "the night before kept no store" - # Five cold and five warm starts (tests/bench/startup.cpp), their medians bounded as for a release. - - name: Five cold and warm starts - run: | - set -euo pipefail - exe=''; [ "$RUNNER_OS" = Windows ] && exe='.exe' - export MCPPLS_CTS_MEASURES="$RUNNER_TEMP/timing-results" - status=0 - "$MTEST" run --case 'startup, the median of five' "stage/mcppls-bench$exe" || status=$? - "$MTEST" analyze trend --runs 30 | tee -a "$GITHUB_STEP_SUMMARY" || true - [ "$status" = 0 ] || { "$MTEST" explain || true; exit 1; } - - uses: actions/upload-artifact@v7 - if: always() - with: - name: nightly-timing-${{ matrix.platform }} - path: ${{ runner.temp }}/timing-results - if-no-files-found: ignore - - uses: actions/upload-artifact@v7 - if: always() - with: - name: nightly-timing-mtest-${{ matrix.platform }} - path: .mtest - include-hidden-files: true - if-no-files-found: ignore - - # usable plan 5.2: the mcpp fixtures with the newest mcpp the index has, so that a change on the - # producer's side shows up here before the pinned MCPP_VERSION moves. - mcpp-latest: - name: mcpp fixtures with the latest mcpp (linux-x64) - needs: ci - if: ${{ !cancelled() && inputs.scope != 'ux' }} - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - MCPP_VERSION: latest - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: GCC 16, which mcpp-gcc builds with - run: mcpp toolchain install gcc 16.1.0 - - uses: ./.github/actions/payload - with: - platform: linux-x64 - run-id: ${{ inputs.payload-run }} - kit: 'true' - - name: The mcpp fixtures - run: | - set -euo pipefail - mcpp --version | tee -a "$GITHUB_STEP_SUMMARY" - failed='' - for fixture in mcpp-gcc mcpp-llvm mcpp-watch; do - echo "::group::$fixture" - if ! stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture "tests/cts/fixtures/$fixture" --timeout 180; then - failed="$failed $fixture" - fi - echo "::endgroup::" - done - if [ -n "$failed" ]; then - echo "::error::failed with the latest mcpp:$failed" - exit 1 - fi - - # The environment issue #32's reporter has, as near as a runner gets: termux (its own userland and - # its own PRoot, in termux/termux-docker), and inside it a Debian rootfs from proot-distro -- so the - # server runs under a PRoot that has a guest root file system, its paths translated both ways, which - # the `proot` job in ci.yml (`proot -r /`) does not have. The payload is the one CI built for - # linux-arm64; nothing is built here. - # - # BEST EFFORT, AND WRITTEN WITHOUT BEING RUN. It needs Docker Hub, termux's package mirrors and - # proot-distro's rootfs download, and the first run may need its commands adjusted. It does not - # fail the nightly (continue-on-error); a red step here is a reason to read the log, and a green one - # is a second opinion beside `proot`, which is the job that gates a change. A real device - # (Android) is not in CI at all: the reporter tries the release candidate before each release. - termux: - name: termux + proot-distro Debian (linux-arm64) - needs: ci - if: ${{ !cancelled() && inputs.scope != 'ux' }} - continue-on-error: true - runs-on: ubuntu-24.04-arm - timeout-minutes: 60 - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/payload - with: - platform: linux-arm64 - run-id: ${{ inputs.payload-run }} - # The container's user is not the runner's: what it reads has to be readable. - - run: chmod -R a+rX payload stage conformance - # Runs in the Debian rootfs, as its root, with the checkout bound at /work. The same three - # checks as the `proot` job, on a server started directly (there is no wrapper: the whole - # session is already under proot). - - name: The script that runs in Debian - run: | - set -euo pipefail - cat > "$RUNNER_TEMP/in-debian.sh" <<'EOF' - set -euo pipefail - apt-get update -q - apt-get install -y -q --no-install-recommends jq ca-certificates - cd /work - work=/tmp/termux-report - rm -rf "$work" && mkdir -p "$work" && cp -R tests/cts/fixtures/inferred "$work/project" - rm -f "$work/project/scenario.json" - MCPPLS_CACHE_DIR="$work/cache" payload/libexec/okrun/linux-aarch64/okrun payload/bin/mcppls.okb report --root "$work/project" \ - --settle 20 --payload /work/payload > "$work/report.json" 2> "$work/report.err" - tail -5 "$work/report.err" - if grep -q -e 'not supported' -e 'outside every preopened' -e 'no preopened directory is named' "$work/report.json" "$work/report.err"; then - grep -h -e 'not supported' -e 'outside every preopened' -e 'no preopened directory is named' "$work/report.json" "$work/report.err" | head - echo "the server could not start a program"; exit 1 - fi - state=$(jq -r '[.roots[0].engines[] | select(.name == "clangd") | .state][0]' "$work/report.json") - echo "clangd: $state" - [ "$state" = ready ] - for fixture in inferred typing-import; do - stage/mcppls-conformance run --server /work/payload/bin/mcppls.okb --payload /work/payload \ - --fixture "tests/cts/fixtures/$fixture" --timeout 300 - done - EOF - cat > "$RUNNER_TEMP/in-termux.sh" <<'EOF' - set -eu - # Use the official seed instead of pkg's randomly selected mirrors: - # https://github.com/termux/termux-packages/wiki/Mirrors - # A stale mirror previously advertised a proot-distro package it no longer served. - printf '%s\n' 'deb https://packages.termux.dev/apt/termux-main stable main' > "$PREFIX/etc/apt/sources.list" - apt-get update - apt-get install -y proot-distro - proot-distro install debian - proot-distro login debian --bind /mnt/work:/work --bind /mnt/scripts:/mnt/scripts -- bash -c 'test -r /mnt/scripts/in-debian.sh && exec bash /mnt/scripts/in-debian.sh' - EOF - chmod a+r "$RUNNER_TEMP"/in-*.sh - # /entrypoint.sh is termux-docker's: it prepares the termux environment (the Android-style - # /system, $PREFIX, the user) and then runs the command it is given. - - name: proot-distro Debian inside termux-docker - run: | - set -euo pipefail - docker run --rm \ - -v "$PWD:/mnt/work" -v "$RUNNER_TEMP:/mnt/scripts:ro" \ - termux/termux-docker:aarch64 /entrypoint.sh bash /mnt/scripts/in-termux.sh - - # A real mcpp project at a fixed commit: the mcpp repository (usable plan W8). - self-hosting: - name: self-hosting (${{ matrix.fixture }}, ${{ matrix.platform }}) - needs: ci - if: ${{ !cancelled() && inputs.scope != 'ux' }} - runs-on: ${{ matrix.os }} - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - include: - - { fixture: self-mcpp, platform: linux-x64, os: ubuntu-24.04 } - # This repository's own conformance: real-project plan RP0/RP3.4's acceptance evidence. - - { fixture: self-mcppls, platform: linux-x64, os: ubuntu-24.04 } - # xlings, as committed and with its old pinned mcpp: the real-project plan's own incident. - - { fixture: real-xlings, platform: linux-x64, os: ubuntu-24.04 } - - { fixture: real-xlings-old-mcpp, platform: linux-x64, os: ubuntu-24.04 } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: GCC 16, which the mcpp and xlings repositories build with - if: matrix.fixture == 'self-mcpp' || startsWith(matrix.fixture, 'real-xlings') - run: mcpp toolchain install gcc 16.1.0 - # The mcpp repository's .xlings.json asks for the mcpp it is built with, and xlings runs that one - # inside it, refusing to when it is not installed. A developer sets the repository up with - # `xlings install`; here the version it names is installed next to MCPP_VERSION, which stays the default. - - name: The mcpp the mcpp repository asks for - if: matrix.fixture == 'self-mcpp' - run: | - set -euo pipefail - xlings install mcpp@2026.9.21.1 -y -g - xlings use mcpp "$MCPP_VERSION" - mcpp --version - # xlings pins mcpp 2026.8.8.4, too old to emit a build database; real-xlings-old-mcpp makes the - # pin take effect, so it has to be installed, beside MCPP_VERSION, which stays the default. - - name: The old mcpp xlings pins - if: matrix.fixture == 'real-xlings-old-mcpp' - run: | - set -euo pipefail - xlings install mcpp@2026.8.8.4 -y -g - xlings use mcpp "$MCPP_VERSION" - mcpp --version - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - run-id: ${{ inputs.payload-run }} - kit: 'true' - - name: The fixture, timed - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/self" - if ! stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture tests/cts/fixtures/${{ matrix.fixture }} \ - --timeout 900 --measure "$RUNNER_TEMP/self/${{ matrix.fixture }}.json"; then - stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture tests/cts/fixtures/${{ matrix.fixture }} \ - --timeout 300 --verbose 2>&1 | tail -300 || true - exit 1 - fi - cat "$RUNNER_TEMP/self/${{ matrix.fixture }}.json" >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v7 - if: always() - with: - name: nightly-${{ matrix.fixture }}-${{ matrix.platform }} - path: ${{ runner.temp }}/self - - # ux-mcpp on macOS and Windows (mcpp design 2026-11-02 part 3 §10). The engine there chooses its owned module - # cache and supervised workers by what the system provides (clangd.okb on both; the native Windows clangd copies - # each BMI for each reader), and these rounds measure the product on it: cold, warm, the edits and the faults, - # held to ux-mcpp's budgets. A round is recorded and does not fail the workflow, as the two-core round: the - # budgets were calibrated on the Linux runner. - ux-systems: - name: ux (ux-mcpp, ${{ matrix.platform }}) - needs: ci - if: ${{ !cancelled() }} - runs-on: ${{ matrix.os }} - timeout-minutes: 240 - continue-on-error: true - strategy: - fail-fast: false - matrix: - include: - - { platform: darwin-arm64, os: macos-15 } - - { platform: win32-x64, os: windows-2022 } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: The mcpp the mcpp repository asks for - run: | - set -euo pipefail - xlings install mcpp@2026.9.21.1 -y -g - xlings use mcpp "$MCPP_VERSION" - mcpp --version - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - run-id: ${{ inputs.payload-run }} - kit: 'true' - - uses: ./.github/actions/mtest - # ux-mcpp's stages, each a case of mcppls-bench (tests/bench/ux.cpp), in order on one workspace. - - name: The stages - run: | - set -euo pipefail - exe=''; [ "$RUNNER_OS" = Windows ] && exe='.exe' - export MCPPLS_CTS_MEASURES="$RUNNER_TEMP/ux-results" - status=0 - "$MTEST" run --case ux-mcpp "stage/mcppls-bench$exe" || status=$? - "$MTEST" analyze trend --runs 1 | tee -a "$GITHUB_STEP_SUMMARY" || true - [ "$status" = 0 ] || { "$MTEST" explain || true; exit 1; } - - uses: actions/upload-artifact@v7 - if: always() - with: - name: nightly-ux-ux-mcpp-${{ matrix.platform }} - path: ${{ runner.temp }}/ux-results - - # The user-experience scenarios of ci.yml's `ux` job (0.0.7 plan 6.4), three times each, so that a budget missed - # by runner noise shows as one failed round of three and a real regression as three; the `long` stage - # (U13's ten idle minutes, U12's git checkout twenty commits back and forth) runs in the first round only. - # A round on two cores, the plan's low tier, is recorded and never fails the workflow: it is here to see - # how far a two-core machine is from the budgets, not to hold it to them. Measure files are uploaded. - ux: - name: ux (${{ matrix.fixture }}, ${{ matrix.round }}) - needs: ci - if: ${{ !cancelled() }} - runs-on: ubuntu-24.04 - timeout-minutes: 240 - continue-on-error: ${{ matrix.round == 'two-core' }} - strategy: - fail-fast: false - matrix: - fixture: [ux-mcpp, ux-xlings] - round: ['1', '2', '3'] - include: - - { fixture: ux-mcpp, round: two-core, cores: '0-1' } - - { fixture: ux-xlings, round: two-core, cores: '0-1' } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: GCC 16, which the mcpp and xlings repositories build with - run: mcpp toolchain install gcc 16.1.0 - - name: The mcpp the mcpp repository asks for - if: matrix.fixture == 'ux-mcpp' - run: | - set -euo pipefail - xlings install mcpp@2026.9.21.1 -y -g - xlings use mcpp "$MCPP_VERSION" - mcpp --version - - uses: ./.github/actions/payload - with: - platform: linux-x64 - run-id: ${{ inputs.payload-run }} - kit: 'true' - # ux-baseline: the release's own server and stage tools, beside this run's; its own workspace - # and cache, since the two servers' caches are not each other's. - - name: The baseline release - if: inputs.ux-baseline != '' - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/baseline" - # A release's payload: one for every platform from 0.0.20 (mcppls-payload-.tar.gz), one per platform before. - gh release download '${{ inputs.ux-baseline }}' --repo '${{ github.repository }}' --pattern 'mcppls-payload-*.tar.gz' --dir "$RUNNER_TEMP/baseline" \ - || gh release download '${{ inputs.ux-baseline }}' --repo '${{ github.repository }}' --pattern payload-linux-x64.tar.gz --dir "$RUNNER_TEMP/baseline" - tar -xzf "$(ls "$RUNNER_TEMP"/baseline/*payload*.tar.gz | head -1)" -C "$RUNNER_TEMP/baseline" - # The release's server as its payload.json names it: a program, or an image and this platform's runner. - baseline="$RUNNER_TEMP/baseline/payload" - runner=$(jq -r '.platforms["linux-x64"].runner // .server.runner // empty' "$baseline/payload.json") - ${runner:+"$baseline/$runner"} "$baseline/$(jq -r .server.path "$baseline/payload.json")" --version - - name: The stages - env: - REPEAT: ${{ inputs.ux-repeat || '1' }} - BASELINE: ${{ inputs.ux-baseline }} - run: | - set -euo pipefail - nproc - cat /proc/loadavg - case "$REPEAT" in ''|*[!0-9]*|0) echo "::error::ux-repeat is a positive number, not '$REPEAT'"; exit 1 ;; esac - results="$RUNNER_TEMP/ux-results" - work="$RUNNER_TEMP/ux-work" - mkdir -p "$results" "$work" - # The two-core round pins the runner and, through it, the server and clangd it starts. - pin=(); [ -n "${{ matrix.cores }}" ] && pin=(taskset -c "${{ matrix.cores }}") - failed='' - # run_stage [runner arguments]: the - # baseline's server runs in a workspace and cache of its own, measured into a directory of its - # own (an artifact the budgets leave out); the runner and the fixture are this run's for both, - # so only the server differs. - run_stage() { - local server="$1" name="$2" suffix="$3" cache="$4"; shift 4 - local payload=payload into="$results" space="$work" label="$name$suffix" - if [ "$server" = baseline ]; then - payload="$RUNNER_TEMP/baseline/payload" into="$RUNNER_TEMP/ux-baseline-results" space="$work/baseline" label="$label (baseline $BASELINE)" - fi - mkdir -p "$into" "$space" - echo "::group::${{ matrix.fixture }} ${{ matrix.round }} $label" - if ! ${pin[@]+"${pin[@]}"} stage/mcppls-conformance run --server "$payload/$(jq -r .server.path "$payload/payload.json")" --payload "$payload" \ - --fixture tests/cts/fixtures/${{ matrix.fixture }} --workspace-dir "$space/workspace" --cache-dir "$space/$cache" \ - --stage "$name" --timeout 900 --measure "$into/${{ matrix.fixture }}-${{ matrix.round }}-$name$suffix.json" "$@"; then - if [ "$server" = this ]; then failed="$failed $name$suffix"; else echo "::warning::the baseline failed $name$suffix"; fi - # Keep the original failed stage's complete server logs before later - # stages rotate them. This runs after the measured process exits. - if [ -d "$space/$cache/logs" ]; then - mkdir -p "$into/failed-$name$suffix/logs" - cp -a "$space/$cache/logs/." "$into/failed-$name$suffix/logs/" || - echo "::warning::could not preserve all $name$suffix server logs" - fi - fi - echo "::endgroup::" - } - servers=(this); [ -n "$BASELINE" ] && servers=(this baseline) - for server in "${servers[@]}"; do - run_stage "$server" cold '' cache-a - run_stage "$server" warm '' cache-a --expect-warm - done - # The edits stage REPEAT times, a new server session each, on what cold and warm prepared: the - # typing and save scenarios (U5, U6, U7a, U15) sampled that many times without building again. - # Beside a baseline the two alternate A B B A, so that a slower stretch of the machine is - # shared between them. - for i in $(seq 1 "$REPEAT"); do - suffix=''; [ "$REPEAT" -gt 1 ] && suffix="-$i" - order=("${servers[@]}") - if [ -n "$BASELINE" ] && [ $((i % 2)) -eq 0 ]; then order=(baseline this); fi - for server in "${order[@]}"; do run_stage "$server" edits "$suffix" cache-a --expect-warm; done - done - run_stage this faults '' cache-b - if [ "${{ matrix.round }}" = 1 ]; then - for server in "${servers[@]}"; do run_stage "$server" long '' cache-a --expect-warm; done - fi - { - echo "### ${{ matrix.fixture }}, round ${{ matrix.round }}" - echo - echo '| stage | check | ok | seconds | detail |' - echo '|---|---|---|---|---|' - for file in "$results"/*.json; do - jq -r --arg stage "$(basename "$file" .json)" '.checks[] | "| \($stage) | \(.id) | \(.ok) | \(.seconds * 10 | round / 10) | \(.detail | tostring | .[0:150] | gsub("\\|"; "/")) |"' "$file" - done - } >> "$GITHUB_STEP_SUMMARY" - if [ -n "$failed" ]; then - echo "::error::stages over budget or failing:$failed" - exit 1 - fi - - uses: actions/upload-artifact@v7 - if: always() - with: - name: nightly-ux-${{ matrix.fixture }}-${{ matrix.round }} - path: ${{ runner.temp }}/ux-results - - uses: actions/upload-artifact@v7 - if: always() && inputs.ux-baseline != '' - with: - name: nightly-ux-${{ matrix.fixture }}-${{ matrix.round }}-baseline - path: ${{ runner.temp }}/ux-baseline-results - - # U-* (0.0.8 plan §3.4): every scenario's numbers over the night's rounds, for setting each budget from what was - # measured (1.3 x the 95th percentile over two weeks of nights) instead of widening it after a slow runner. The - # two-core round is left out: it is recorded, never a budget. - ux-budgets: - name: ux budgets (the night's rounds) - needs: ux - if: ${{ !cancelled() }} - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/payload - with: - platform: linux-x64 - run-id: ${{ inputs.payload-run }} - kit: 'true' - - uses: actions/download-artifact@v8 - with: - pattern: nightly-ux-ux-*-[123] - path: ux-rounds - - name: Distributions - run: | - set -euo pipefail - stage/mcppls-devtools measure budgets ux-rounds | tee ux-budgets.md - { echo '### ux budgets over the night'"'"'s rounds'; echo; cat ux-budgets.md; } >> "$GITHUB_STEP_SUMMARY" - # ux-baseline: each scenario's numbers beside the release's, from the same machines and stages. - - uses: actions/download-artifact@v8 - if: inputs.ux-baseline != '' - with: - pattern: nightly-ux-ux-*-[123]-baseline - path: ux-baseline - - name: Beside the baseline - if: inputs.ux-baseline != '' - run: | - set -euo pipefail - stage/mcppls-devtools measure compare ux-rounds ux-baseline | tee ux-compare.md - { echo '### ux beside ${{ inputs.ux-baseline }}'; echo; cat ux-compare.md; } >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v7 - with: - name: nightly-ux-budgets - path: ux-*.md diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml deleted file mode 100644 index 671134aa..00000000 --- a/.github/workflows/prerelease.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Pre-release - -# A release candidate, built and tested the way a user would get it, without publishing anything: -# every job of ci.yml, then release-checks.yml on the artifacts that run produced (the plugins, -# installing them, performance, stability, and the staged, checked `release-candidate` artifact -# Release publishes). -# -# It runs by itself on a release candidate tag (v-rc) and weekly on main; Release calls -# it before publishing, so nothing is published that did not pass here. A pull request that changes -# release packaging gets the same release checks from ci.yml, after the CI it runs anyway. - -on: - push: - tags: ['v*-rc*'] - schedule: - - cron: '0 20 * * 0' - workflow_dispatch: - workflow_call: - -concurrency: - group: prerelease-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - ci: - uses: ./.github/workflows/ci.yml - - checks: - needs: ci - uses: ./.github/workflows/release-checks.yml diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml deleted file mode 100644 index ac99cc00..00000000 --- a/.github/workflows/publish-jetbrains.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Publish JetBrains - -on: - release: - types: [published] - workflow_dispatch: - inputs: - tag: - description: 'Published GitHub release tag, e.g. v0.0.20' - required: true - workflow_call: - inputs: - tag: - type: string - required: true - secrets: - JETBRAINS_TOKEN: - required: true - -permissions: - contents: read - -concurrency: - group: publish-jetbrains-${{ inputs.tag || github.event.release.tag_name }} - cancel-in-progress: false - -jobs: - publish: - # Only a release that carries the CLion plugin: the series (vX.Y.0) or the plugin's patch (clion-v*). - if: startsWith(inputs.tag || github.event.release.tag_name, 'v') || startsWith(inputs.tag || github.event.release.tag_name, 'clion-v') - runs-on: ubuntu-24.04 - timeout-minutes: 15 - env: - TAG: ${{ inputs.tag || github.event.release.tag_name }} - steps: - - uses: actions/checkout@v7 - - name: Resolve the published release version - run: | - python3 - <<'PY' - import os, re - from pathlib import Path - # A series release (vX.Y.0) or the CLion plugin's own patch (clion-vX.Y.Z; docs/92-release.md "Versions"). - match = re.fullmatch(r'(?:clion-)?v(\d+\.\d+\.\d+)', os.environ['TAG']) - assert match, 'Expected a versioned release tag' - with Path(os.environ['GITHUB_ENV']).open('a') as out: - out.write('VERSION=' + match[1] + '\n') - PY - - name: Download the immutable release ZIP and verify its checksum - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - gh release download "$TAG" -p "mcppls-clion-$VERSION.zip" -p SHA256SUMS -D published - (cd published && sha256sum -c --ignore-missing SHA256SUMS) - - name: Submit the existing release file to JetBrains - env: - JETBRAINS_TOKEN: ${{ secrets.JETBRAINS_TOKEN }} - run: | - set -euo pipefail - wanted=$(awk -v name="mcppls-clion-$VERSION.zip" '$2 == name { print $1 }' published/SHA256SUMS) - python3 .github/scripts/publish-jetbrains.py --archive "published/mcppls-clion-$VERSION.zip" \ - --xml-id io.github.sunrisepeak.mcppls --version "$VERSION" --sha256 "$wanted" \ - --output published/jetbrains-receipt.json - - uses: actions/upload-artifact@v7 - if: always() - with: - name: jetbrains-${{ inputs.tag || github.event.release.tag_name }}-receipt - path: published/jetbrains-receipt.json - if-no-files-found: warn diff --git a/.github/workflows/publish-openvsx.yml b/.github/workflows/publish-openvsx.yml deleted file mode 100644 index ff3c1458..00000000 --- a/.github/workflows/publish-openvsx.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: Publish to Open VSX - -# Open VSX is where VS Code-compatible editors (Cursor, VSCodium, Windsurf, Trae, ...) find -# extensions. Its version of a release is the release's own `mcppls-.vsix`, one file for every -# system, the one the maintainer verified locally, so a release reaches Open VSX when it is published, not before: -# -# release.yml makes a draft -> the maintainer verifies its VSIX files locally -> publishes the -# draft (this workflow runs) -> uploads the same files to the VS Code Marketplace by hand. -# -# The VS Code Marketplace stays manual on purpose (docs/92-release.md). A GitHub pre-release is -# published here as an ordinary version: Open VSX users get what the release page offers. - -on: - # A person publishing a draft in the UI. - release: - types: [published] - # release.yml, when it creates the release already published: a release created with - # GITHUB_TOKEN does not trigger `release: published`, so it calls this directly. - workflow_call: - inputs: - tag: - description: 'The release tag, e.g. v0.0.3' - type: string - required: true - # A retry. Versions Open VSX already has are skipped, so a re-run publishes only what is missing. - workflow_dispatch: - inputs: - tag: - description: 'The release tag to publish, e.g. v0.0.3' - required: true - -permissions: - contents: read - -concurrency: - group: publish-openvsx-${{ inputs.tag || github.event.release.tag_name }} - cancel-in-progress: false - -jobs: - openvsx: - name: Open VSX ${{ inputs.tag || github.event.release.tag_name }} - # Only a release that carries the extension: the series (vX.Y.0) or the extension's patch (vscode-v*). - if: startsWith(inputs.tag || github.event.release.tag_name, 'v') || startsWith(inputs.tag || github.event.release.tag_name, 'vscode-v') - runs-on: ubuntu-24.04 - steps: - # ovsx 1.2.0 requires Node 22. - - uses: actions/setup-node@v7 - with: - node-version: 22 - - name: The release's VSIX files, as checksummed - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag || github.event.release.tag_name }} - run: | - set -euo pipefail - mkdir vsix && cd vsix - gh release download "$TAG" --repo '${{ github.repository }}' -p 'mcppls-*.vsix' -p SHA256SUMS - ls -l - compgen -G 'mcppls-*.vsix' > /dev/null || { echo "::error::release $TAG has no mcppls-*.vsix"; exit 1; } - # SHA256SUMS covers every file of the release; only the VSIX files were downloaded. - sha256sum -c --ignore-missing SHA256SUMS - # Fails in seconds, and says so, when the token has expired or lost the namespace, instead of - # failing on the first upload. - - name: The token may publish to the namespace - env: - OVSX_PAT: ${{ secrets.OVSX_PAT }} - run: npx --yes ovsx@1.2.0 verify-pat sunrisepeak - # One VSIX for every system (no target platform; a release before 0.0.20 had one per platform). - # --skip-duplicate: a version Open VSX already has is left alone, which makes re-runs no-ops. - - name: Publish - env: - OVSX_PAT: ${{ secrets.OVSX_PAT }} - run: | - set -euo pipefail - for vsix in vsix/mcppls-*.vsix; do - echo "publishing $vsix" - npx --yes ovsx@1.2.0 publish --packagePath "$vsix" --skip-duplicate - done - # What Open VSX serves has to be what the release offers (0.0.7 plan O-3). Publishing succeeding - # says the upload was accepted; this says the file a VSCodium user is handed for each platform - # is, byte for byte, the VSIX the maintainer verified: the API lists it, the download's SHA256 is the one - # in the release's SHA256SUMS. A listing can lag the upload, so each platform is polled, bounded. - - name: Open VSX serves the release's files - env: - TAG: ${{ inputs.tag || github.event.release.tag_name }} - run: | - set -euo pipefail - version="${TAG#v}" - api="https://open-vsx.org/api/sunrisepeak/mcpp-language-server" - cd vsix - failed=0 - for vsix in mcppls-*.vsix; do - # The file of a VSIX with no target platform is listed as `universal`. - target="${vsix#mcppls-}"; target="${target%.vsix}" - [ "$target" = "$version" ] && target=universal - want=$(grep " ${vsix}\$" SHA256SUMS | cut -d' ' -f1) - [ -n "$want" ] || { echo "::error::SHA256SUMS has no entry for $vsix"; failed=1; continue; } - url="" - # O-6 (plan 0.0.8): Open VSX took more than three minutes to list 0.0.7's darwin-arm64 after it was published - # (run 36692618794): up to ten minutes before this counts as a failure. - for attempt in $(seq 1 40); do - path="$api/$target/$version"; [ "$target" = universal ] && path="$api/$version" - url=$(curl -fsS --retry 3 "$path" 2>/dev/null | python3 -c 'import json,sys; print(json.load(sys.stdin).get("files", {}).get("download", ""))' 2>/dev/null || true) - [ -n "$url" ] && break - echo "$target $version is not listed yet ($attempt/40)"; sleep 15 - done - if [ -z "$url" ]; then echo "::error::Open VSX does not list $target $version after 10 minutes"; failed=1; continue; fi - curl -fsSL --retry 3 -o "served-$vsix" "$url" - got=$(sha256sum "served-$vsix" | cut -d' ' -f1) - if [ "$got" = "$want" ]; then - echo "$target: Open VSX serves the release's file ($got)" - else - echo "::error::$target: Open VSX serves $got, the release has $want"; failed=1 - fi - done - exit "$failed" diff --git a/.github/workflows/release-checks.yml b/.github/workflows/release-checks.yml deleted file mode 100644 index 7d879d72..00000000 --- a/.github/workflows/release-checks.yml +++ /dev/null @@ -1,422 +0,0 @@ -name: Release checks - -# What a release adds to CI, run after CI in the same workflow run and on its artifacts (the -# payload, the VSIX, the server and the kits). Called from two places: -# -# prerelease.yml after its own run of ci.yml: release candidate tags, weekly, and Release -# ci.yml on a pull request that changes release packaging, after CI's own jobs, so such -# a pull request is checked the way a release is without running CI twice -# -# (The CLion plugin inside a real CLion, clion-verify and clion-e2e, is in ci.yml: both callers run -# all of it first, so the release checks have those results too.) -# -# plugins the Zed and CLion plugins, packaged exactly as a release ships them: the Zed -# extension once, the CLion plugin once with the one payload inside -# install each host: the Zed extension and the CLion package installed by devtools, Zed's -# server and the one the CLion package carries each answering a project through -# this platform's entry, and uninstall leaving nothing behind -# zed the Zed extension in a real Zed on Linux, under both the recommended settings and -# Zed's defaults (zed-smoke.yml) -# performance each host: five cold and five warm starts, medians against a budget; on Linux the -# server on this repository itself as well -# stability each host: the fixtures of the project shapes that matter most, three rounds in a -# row; one failure in any round fails the candidate -# candidate every asset staged, checked against packaging/release.manifest.json, checksummed, -# and kept as the `release-candidate` artifact — which is what Release publishes - -on: - workflow_call: - -env: - XLINGS_NON_INTERACTIVE: '1' - PAYLOAD_CACHE: ${{ github.workspace }}/.payload-cache - -jobs: - # The Zed extension is wasm and carries no payload, so one archive serves every machine. The CLion - # plugin carries its server: one package for every system, holding the one payload this run built - # (ecosystem design 2026-10-29 §9.3). - plugins: - name: zed and clion plugins - runs-on: ubuntu-24.04 - timeout-minutes: 60 - outputs: - version: ${{ steps.version.outputs.version }} - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: The version being packaged - id: version - run: | - set -euo pipefail - mcpp build --release -p devtools - devtools=$(find target -type f -name mcppls-devtools -path '*/bin/*' | head -1) - "$devtools" version --check - # Each package is named with its own component's version (docs/92-release.md "Versions"). - { echo "version=$("$devtools" version --print)" - echo "zed=$("$devtools" version --print --component zed)" - echo "clion=$("$devtools" version --print --component clion)"; } >> "$GITHUB_OUTPUT" - # No rust or java action here on purpose. `[xlings.workspace]` declares what packaging needs - # and `mcpp run` provisions it, so this job is the thing that proves that claim on a machine - # which has neither. - # - # The crate goes in with the compiled extension: extension.toml says `kind = "Rust"`, and - # Zed's "install dev extension" compiles a Rust extension from its Cargo.toml, so an archive - # of extension.toml and extension.wasm alone cannot be installed the recommended way. - - name: Zed extension - run: | - set -euo pipefail - mcpp run --release -p devtools -- extension --editor zed - tar -czf "mcppls-zed-${{ steps.version.outputs.zed }}.tar.gz" -C editors \ - --transform 's,^zed,mcppls-zed,' \ - zed/extension.toml zed/extension.wasm zed/README.md zed/Cargo.toml zed/Cargo.lock zed/src - tar -tzf "mcppls-zed-${{ steps.version.outputs.zed }}.tar.gz" - - uses: ./.github/actions/payload - with: - platform: linux-x64 - - name: CLion plugin - run: | - set -euo pipefail - mcpp run --release --features clion -p devtools -- extension --editor clion --payload "$PWD/payload" - cp target/pack/mcppls-clion-${{ steps.version.outputs.clion }}.zip . - package=mcppls-clion-${{ steps.version.outputs.clion }}.zip - echo "$package: $(unzip -l "$package" | tail -1)" - unzip -p "$package" mcppls-clion/payload/payload.json | jq -e '.platforms | has("linux-x64") and has("linux-arm64") and has("darwin-arm64") and has("win32-x64")' - - uses: actions/upload-artifact@v7 - with: - name: editor-plugins - path: | - mcppls-zed-*.tar.gz - mcppls-clion-*.zip - if-no-files-found: error - retention-days: 7 - - install: - name: install and remove (${{ matrix.platform }}) - needs: plugins - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04, exe: '' } - - { platform: linux-arm64, os: ubuntu-24.04-arm, exe: '' } - - { platform: darwin-arm64, os: macos-15, exe: '' } - - { platform: win32-x64, os: windows-2022, exe: '.exe' } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - kit: 'true' - - uses: actions/download-artifact@v8 - with: - name: editor-plugins - - name: Install both plugins from the release archives, then remove them - run: | - set -euo pipefail - # D5 (mcpp design 2026-10-30 part 2 §10): a user directory with a space and a name that is - # not ASCII, which is where a person's editor installs the payload. - t="$RUNNER_TEMP/editors with spaces/编辑器"; rm -rf "$RUNNER_TEMP/editors with spaces"; mkdir -p "$t/clion/CLion2026.2" - # From inside the directory: GNU tar reads `D:` in a Windows `-C D:\...` as a remote host. - archive="$PWD/$(ls mcppls-zed-*.tar.gz)" - (cd "$t" && tar -xzf "$archive") - dev="stage/mcppls-devtools${{ matrix.exe }}" - where=(--server-dir "$t/server" --zed-dir "$t/zed" --clion-dir "$t/clion/CLion2026.2") - - "$dev" extension --editor zed --install --link --plugin "$t/mcppls-zed" --payload payload "${where[@]}" - test -f "$t/zed/extensions/installed/mcppls/extension.wasm" - "$dev" extension --editor clion --install --plugin "$(ls mcppls-clion-*.zip)" "${where[@]}" - plugin="$t/clion/CLion2026.2/mcppls-clion" - ls "$plugin"/lib/mcppls-clion-*.jar - jq -e '.platforms | has("${{ matrix.platform }}")' "$plugin/payload/payload.json" - - # The server Zed falls back to, and the one the CLion package carries, each answer a project through this platform's entry. - for server in "$t/server" "$plugin/payload"; do - "$server/$(jq -r '.platforms["${{ matrix.platform }}"].runner' "$server/payload.json")" "$server/bin/mcppls.okb" --version - "stage/mcppls-conformance${{ matrix.exe }}" run --server "$server/bin/mcppls.okb" --payload "$server" \ - --fixture tests/cts/fixtures/inferred --timeout 180 - # The status a program reads (S3 section 8), from the installed payload. - "$server/$(jq -r '.platforms["${{ matrix.platform }}"].runner' "$server/payload.json")" "$server/bin/mcppls.okb" \ - status --root tests/cts/fixtures/inferred --format json | jq -e '.schema == 1 and (.capabilities | index("status"))' - done - - "$dev" uninstall --editor zed "${where[@]}" - if [ -e "$t/server" ]; then echo "::error::Zed's server outlived Zed: the CLion package carries its own"; exit 1; fi - "$dev" uninstall --editor clion "${where[@]}" - for gone in "$t/server" "$t/zed/extensions/installed/mcppls" "$t/clion/CLion2026.2/mcppls-clion"; do - if [ -e "$gone" ]; then echo "::error::$gone is still there after uninstall"; exit 1; fi - done - # The source of a linked install is untouched by removing the link. - test -f "$t/mcppls-zed/extension.toml" - - # Not the install above: this one starts Zed itself and asks the installed extension for mcppls. - zed: - name: real Zed - uses: ./.github/workflows/zed-smoke.yml - - # The release's measurements (tests/bench/README.md): the cases of mcppls-bench tagged `release` that this platform - # runs -- five cold and five warm starts, whose medians decide, and on Windows a project the size where module - # checks cost (thirty module units with , built by MSVC), cold and then warm on one cache. - performance: - name: performance (${{ matrix.platform }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 60 - strategy: - fail-fast: false - matrix: - include: - - { platform: linux-x64, os: ubuntu-24.04, exe: '' } - - { platform: darwin-arm64, os: macos-15, exe: '' } - - { platform: win32-x64, os: windows-2022, exe: '.exe' } - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - kit: 'true' - - uses: ./.github/actions/mtest - - name: The MSVC developer environment, for the large project's own build - if: runner.os == 'Windows' - shell: pwsh - run: | - $vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath - Set-Content -Path "$env:RUNNER_TEMP\capture-msvc.cmd" -Value "@call `"$vs\VC\Auxiliary\Build\vcvars64.bat`" >nul`r`n@set" - cmd /d /c "$env:RUNNER_TEMP\capture-msvc.cmd" | Out-File -Encoding utf8 "$env:RUNNER_TEMP\msvc.env" - - name: The release's measurements - run: | - set -euo pipefail - export MCPPLS_CTS_MEASURES="$RUNNER_TEMP/timing" - [ "$RUNNER_OS" != Windows ] || export MCPPLS_CTS_MSVC_ENV="$RUNNER_TEMP/msvc.env" - status=0 - "$MTEST" run --tag "on:${{ matrix.platform }}&release" "stage/mcppls-bench${{ matrix.exe }}" || status=$? - "$MTEST" analyze trend --runs 1 | tee -a "$GITHUB_STEP_SUMMARY" || true - [ "$status" = 0 ] || { "$MTEST" explain || true; exit 1; } - - uses: actions/upload-artifact@v7 - if: always() - with: - name: prerelease-timing-${{ matrix.platform }} - path: ${{ runner.temp }}/timing - if-no-files-found: ignore - - uses: actions/upload-artifact@v7 - if: always() - with: - name: prerelease-mtest-${{ matrix.platform }} - path: .mtest - include-hidden-files: true - if-no-files-found: ignore - - # The mcpp repository (at the fixed commit the self-mcpp fixture names) opened in the server: a - # real mcpp project, the size a fixture is not. - self-hosting: - name: performance on a real project (linux-x64) - runs-on: ubuntu-24.04 - timeout-minutes: 60 - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-mcpp - - name: GCC 16, which the mcpp repository builds with - run: mcpp toolchain install gcc 16.1.0 - # The mcpp repository's .xlings.json asks for the mcpp it is built with, and xlings runs that - # one inside it; it is installed next to MCPP_VERSION, which stays the default. - - name: The mcpp the mcpp repository asks for - run: | - set -euo pipefail - xlings install mcpp@2026.9.21.1 -y -g - xlings use mcpp "$MCPP_VERSION" - mcpp --version - - uses: ./.github/actions/payload - with: - platform: linux-x64 - kit: 'true' - - name: mcppls on the mcpp repository, timed - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/self" - if ! stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture tests/cts/fixtures/self-mcpp \ - --timeout 900 --measure "$RUNNER_TEMP/self/self-mcpp.json"; then - stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture tests/cts/fixtures/self-mcpp \ - --timeout 300 --verbose 2>&1 | tail -300 || true - exit 1 - fi - { echo '```json'; cat "$RUNNER_TEMP/self/self-mcpp.json"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" - # Real-project plan RP0/RP3.4: this repository's own conformance, at a fixed commit. - - name: mcppls on its own repository, timed - run: | - set -euo pipefail - if ! stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture tests/cts/fixtures/self-mcppls \ - --timeout 900 --measure "$RUNNER_TEMP/self/self-mcppls.json"; then - stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture tests/cts/fixtures/self-mcppls \ - --timeout 300 --verbose 2>&1 | tail -300 || true - exit 1 - fi - { echo '```json'; cat "$RUNNER_TEMP/self/self-mcppls.json"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" - # Real-project plan RP0/RP2.1: xlings with its pinned mcpp 2026.8.8.4, too old to emit a build - # database -- the incident the plan starts from. It must be described by the newer installed mcpp. - - name: mcppls on xlings with its old pinned mcpp, timed - run: | - set -euo pipefail - xlings install mcpp@2026.8.8.4 -y -g - xlings use mcpp "$MCPP_VERSION" - # The project's own mcpp is the one xlings starts for its pin: an mcpp under review that stands - # first on PATH (MCPP_SOURCE_REF) would answer for it, and nothing would be negotiated. - PATH=$(printf '%s' "$PATH" | tr ':' '\n' | grep -vx "$HOME/mcpp-under-review" | paste -sd: -) - if ! stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture tests/cts/fixtures/real-xlings-old-mcpp \ - --timeout 900 --measure "$RUNNER_TEMP/self/real-xlings-old-mcpp.json"; then - stage/mcppls-conformance run --server payload/bin/mcppls.okb --payload payload --fixture tests/cts/fixtures/real-xlings-old-mcpp \ - --timeout 300 --verbose 2>&1 | tail -300 || true - exit 1 - fi - { echo '```json'; cat "$RUNNER_TEMP/self/real-xlings-old-mcpp.json"; echo '```'; } >> "$GITHUB_STEP_SUMMARY" - - uses: actions/upload-artifact@v7 - if: always() - with: - name: prerelease-self-mcpp - path: ${{ runner.temp }}/self - - # The project shapes users have (all-.cppm, .cppm/.cpp split, watched edits, a broken module, a - # hung build tool) run three times over with fresh workspaces. CI runs each once; a fixture that - # passes once and fails on a rerun is exactly what a release should not carry. - stability: - name: stability (${{ matrix.platform }}${{ matrix.part && format(', {0}', matrix.part) || '' }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 120 - strategy: - fail-fast: false - matrix: - include: - # Linux and Windows split their fixtures in two parts that run side by side, balanced on - # measured times; each fixture still runs its three rounds in a row on one machine. - - platform: linux-x64 - part: 1 of 2 - os: ubuntu-24.04 - exe: '' - fixtures: mcpp-split multi-root mcpp-llvm mcpp-watch inferred module-faults mcpp-target-selection mcpp-target-explicit mcpp-target-project mcpp-target-invalid mcpp-target-windows mcpp-producer-identity mcpp-target-race mcpp-target-live mcpp-target-mismatch mcpp-target-mismatch-cold mcpp-target-build-tool-off mcpp-target-provider-excluded mcpp-target-discovery-off mcpp-target-auto-fallback mcpp-producer-file-identity mcpp-producer-relative-path mcpp-target-selection@polling mcpp-producer-identity@polling mcpp-polling-watch-inputs@polling mcpp-target-fallback-mismatch mcpp-target-fallback-preserve - - platform: linux-x64 - part: 2 of 2 - os: ubuntu-24.04 - exe: '' - fixtures: verify-changes mcpp-all-cppm mcpp-watch@polling mcpp-emit-hang mcpp-emit-watch - - platform: darwin-arm64 - os: macos-15 - exe: '' - fixtures: inferred module-faults verify-changes multi-root mcpp-llvm mcpp-split mcpp-all-cppm mcpp-watch mcpp-target-selection mcpp-target-explicit mcpp-target-project mcpp-target-invalid mcpp-target-windows mcpp-producer-identity mcpp-target-race mcpp-target-live mcpp-target-mismatch mcpp-target-mismatch-cold mcpp-target-build-tool-off mcpp-target-provider-excluded mcpp-target-discovery-off mcpp-target-auto-fallback mcpp-producer-file-identity mcpp-producer-relative-path mcpp-polling-watch-inputs@polling mcpp-target-fallback-mismatch mcpp-target-fallback-preserve - - platform: win32-x64 - part: 1 of 2 - os: windows-2022 - exe: '.exe' - fixtures: mcpp-split inferred module-faults mcpp-watch mcpp-target-selection mcpp-target-explicit mcpp-target-project mcpp-target-invalid mcpp-target-windows mcpp-producer-identity mcpp-target-race mcpp-target-live mcpp-target-mismatch mcpp-target-mismatch-cold mcpp-target-build-tool-off mcpp-target-provider-excluded mcpp-target-discovery-off mcpp-target-auto-fallback mcpp-producer-file-identity mcpp-producer-relative-path mcpp-polling-watch-inputs@polling mcpp-target-fallback-mismatch mcpp-target-fallback-preserve - - platform: win32-x64 - part: 2 of 2 - os: windows-2022 - exe: '.exe' - fixtures: verify-changes mcpp-all-cppm multi-root mcpp-emit-hang - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - uses: ./.github/actions/setup-mcpp - - uses: ./.github/actions/payload - with: - platform: ${{ matrix.platform }} - kit: 'true' - - name: Three rounds - run: | - set -euo pipefail - failed='' - evidence="$RUNNER_TEMP/stability-evidence" - mkdir -p "$evidence" - for round in 1 2 3; do - for fixture in ${{ matrix.fixtures }}; do - extra=() - case "$fixture" in *@polling) extra=(--no-dynamic-watch) ;; esac - echo "::group::round $round: $fixture" - started=$(date +%s) - key="$round-${fixture//@/-}" - scratch="$RUNNER_TEMP/stability-scratch/$key" - if ! python3 .github/scripts/stability_evidence.py --output "$evidence/$key" --scratch "$scratch" -- \ - "stage/mcppls-conformance${{ matrix.exe }}" run --server "payload/bin/mcppls.okb" --payload payload \ - ${extra[@]+"${extra[@]}"} --fixture "tests/cts/fixtures/${fixture%@*}" --timeout 180 \ - --workspace-dir "$scratch/workspace" --cache-dir "$scratch/cache" --keep --measure "$scratch/measure.json"; then - failed="$failed $round:$fixture" - fi - echo "::endgroup::" - echo "round $round $fixture $(( $(date +%s) - started )) s" | tee -a "$RUNNER_TEMP/stability.txt" - done - done - { echo '| round | fixture | seconds (runner + diagnostics) |'; echo '|---|---|---|' - awk '{ printf "| %s | %s | %s |\n", $2, $3, $4 }' "$RUNNER_TEMP/stability.txt"; } >> "$GITHUB_STEP_SUMMARY" - if [ -n "$failed" ]; then - echo "::error::failed (round:fixture):$failed" - exit 1 - fi - - uses: actions/upload-artifact@v7 - if: always() - with: - name: stability-evidence-${{ matrix.platform }}-${{ strategy.job-index }} - path: | - ${{ runner.temp }}/stability-evidence - ${{ runner.temp }}/stability.txt - if-no-files-found: warn - - - # Everything a release would publish, staged and checked. Release takes this artifact as it is. - candidate: - name: release candidate - needs: [plugins, install, zed, performance, self-hosting, stability] - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 - - uses: actions/download-artifact@v8 - with: - path: artifacts - - name: Stage the release - run: | - set -euo pipefail - version='${{ needs.plugins.outputs.version }}' - mkdir -p release - # The editors' packages, the payload (`mcppls-payload-.tar.gz`: the payload tree alone, the test tools - # are the stage artifacts'), the server for the command line and every system (ci.yml's `server`: the tree, the - # okb, the single files) and the kits (the openkal kit and each platform's target kit). - cp artifacts/vsix/mcppls-*.vsix release/ - cp artifacts/payload/payload.tar.gz "release/mcppls-payload-$version.tar.gz" - find artifacts/editor-plugins -type f \( -name 'mcppls-zed-*.tar.gz' -o -name 'mcppls-clion-*.zip' \) -exec cp {} release/ \; - find artifacts/server -type f -name "mcppls-$version*" -exec cp {} release/ \; - find artifacts -path '*/kit-*/mcppls-kit-*.tar.gz' -exec cp {} release/ \; - ls -l release - tar -tzf "release/mcppls-payload-$version.tar.gz" > "$RUNNER_TEMP/listing" - if grep -qv '^payload/' "$RUNNER_TEMP/listing"; then echo "::error::the payload archive carries more than the payload tree"; exit 1; fi - # The devtools the same run built for Linux x86_64 (stage-linux-x64), so this job needs no toolchain. - - name: The release tool from this run - run: | - set -euo pipefail - tar -xzf "$(find artifacts -name 'stage-linux-x64.tar.gz' | head -1)" stage/mcppls-devtools - ./stage/mcppls-devtools --help > /dev/null - # Every required asset present and non-empty, nothing shipped that the manifest does not - # declare, and MANIFEST.md rendered from the same declaration. - - name: Check against the release manifest - run: | - set -euo pipefail - version='${{ needs.plugins.outputs.version }}' - ./stage/mcppls-devtools release check --version "$version" --dir release --render - (cd release && sha256sum * > SHA256SUMS && cat SHA256SUMS) - ./stage/mcppls-devtools release check --version "$version" --dir release - - uses: actions/upload-artifact@v7 - with: - name: release-candidate - path: release - if-no-files-found: error - retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 17b4c0f2..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,228 +0,0 @@ -name: Release - -# One button. `workflow_dispatch` with a version runs the pre-release test (prerelease.yml: all of -# CI, the Zed and CLion plugins, installing them, performance and stability), and publishes the -# release candidate it staged and checked, as a GitHub release with the tag it created. A pushed -# tag does the same thing for people who prefer tagging first; `v*-rc*` tags run only the -# pre-release test. -# -# WHAT A RELEASE IS (docs/92-release.md "Versions"). A version is X.Y.Z: X.Y the series of the -# server and every plugin, Z each component's own patch. `component: all` releases the series, -# every component at X.Y.0, tagged `vX.Y.0`; a component releases its patch alone, tagged -# `-vX.Y.Z` (`server-`, `vscode-`, `clion-`, `zed-`, `claude-code-`), carrying only its -# own files. Every release is tested as a whole: the candidate is always the whole product. -# -# The release is a draft unless asked otherwise, because it is verified before anyone gets it: the -# maintainer tries the draft's `mcppls-.vsix` files locally, then publishes the draft. -# Publishing it sends the same files to Open VSX and JetBrains through their -# `release: published` workflows. A release created already published calls both itself, -# since GITHUB_TOKEN does not trigger another workflow. -# -# What it deliberately does NOT do: upload to the VS Code Marketplace, which the maintainer does by -# hand after the same local verification, or publish to the xlings index. See docs/92-release.md. -# Everything a user needs is a file on the release page. - -on: - workflow_dispatch: - inputs: - version: - description: 'Version to release: the series X.Y.0 for `all`, else the component''s X.Y.Z' - required: true - component: - description: 'What is released: the series (all), or one component''s patch' - type: choice - default: all - options: [all, server, vscode, clion, zed, claude-code] - draft: - description: 'Publish as a draft (uncheck to publish immediately)' - type: boolean - default: true - prerelease: - description: 'Mark as a pre-release' - type: boolean - default: true - push: - tags: ['v*', '!v*-rc*', 'server-v*', 'vscode-v*', 'clion-v*', 'zed-v*', 'claude-code-v*'] - -permissions: - contents: write - -env: - XLINGS_NON_INTERACTIVE: '1' - PAYLOAD_CACHE: ${{ github.workspace }}/.payload-cache - -jobs: - # The version the rest of the run uses, from whichever trigger fired. Resolved once so no later - # step has to repeat the `inputs.version || ref_name` dance and get it subtly different. - what: - name: what is being released - runs-on: ubuntu-24.04 - outputs: - version: ${{ steps.pick.outputs.version }} - tag: ${{ steps.pick.outputs.tag }} - component: ${{ steps.pick.outputs.component }} - heading: ${{ steps.pick.outputs.heading }} - steps: - - uses: actions/checkout@v7 - - id: pick - run: | - set -euo pipefail - if [ -n '${{ github.event.inputs.version }}' ]; then - version='${{ github.event.inputs.version }}' - component='${{ github.event.inputs.component }}' - else - ref='${{ github.ref_name }}' - case "$ref" in - v*) component=all; version="${ref#v}" ;; - *-v*) component="${ref%%-v*}"; version="${ref#*-v}" ;; - esac - fi - if [ "$component" = all ]; then - tag="v$version"; heading="[$version]" - case "$version" in *.0) ;; *) echo "::error::a series release is X.Y.0; $version is a component's patch (choose its component)"; exit 1 ;; esac - else - tag="$component-v$version"; heading="[$component $version]" - fi - { echo "version=$version"; echo "component=$component"; echo "tag=$tag"; echo "heading=$heading"; } >> "$GITHUB_OUTPUT" - echo "releasing $tag" - # The version in the manifests is the one being released. Checked before anything is built, - # because a mismatch found after the matrix has run has cost an hour to learn one line. - - uses: ./.github/actions/setup-mcpp - - name: Every version site agrees with it - run: | - set -euo pipefail - mcpp build -p devtools - devtools=$(find target -type f -name mcppls-devtools -path '*/bin/*' | head -1) - "$devtools" version --check - component='${{ steps.pick.outputs.component }}' - version='${{ steps.pick.outputs.version }}' - if [ "$component" = all ]; then - # Every component at the series' X.Y.0. - "$devtools" version --print --component all | while IFS== read -r name declared; do - [ "$declared" = "$version" ] || { - echo "::error::$name is $declared, but a series release has every component at $version." - echo "::error::Run: mcpp run -p devtools -- version --series ${version%.0}" - exit 1 - } - done - else - declared=$("$devtools" version --print --component "$component") - [ "$declared" = "$version" ] || { - echo "::error::$component is $declared but the release asks for $version." - echo "::error::Run: mcpp run -p devtools -- version --bump $component" - exit 1 - } - fi - echo "manifests agree: releasing ${{ steps.pick.outputs.tag }}" - # A release ships the engine the lock names, released and pinned; a candidate never does. - - name: No candidate engine - run: | - [ ! -f packaging/engine-candidate.json ] || { - echo "::error::packaging/engine-candidate.json names an unreleased engine. Release the engine, switch packaging/payload.lock.json to it and remove the file." - exit 1 - } - # CHANGELOG.md says a release's notes are its section, so the section has to exist before an - # hour of pre-release testing, not be found missing after it. - - name: CHANGELOG.md has this version's section - run: | - set -euo pipefail - heading='## ${{ steps.pick.outputs.heading }}' - grep -qF "$heading" CHANGELOG.md || { - echo "::error::CHANGELOG.md has no '$heading' section; a release's notes are that section." - exit 1 - } - - # Exactly what the pre-release test builds and tests, so a release is what was tested, and its - # staged candidate is what ships. - prerelease: - needs: what - # ci.yml's jobs read the stores and payloads other runs kept (`actions: read`); a called workflow is - # given no more than its caller's job, and this file's default is `contents: write` alone. - permissions: - contents: read - actions: read - uses: ./.github/workflows/prerelease.yml - - publish: - name: publish ${{ needs.what.outputs.tag }} - needs: [what, prerelease] - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 - - uses: actions/download-artifact@v8 - with: - name: release-candidate - path: candidate - - uses: actions/download-artifact@v8 - with: - name: stage-linux-x64 - path: stage-artifact - # The candidate was checked against the manifest for the versions the sites declare, and `what` - # checked that this is the version being released. A component's release is the candidate's files - # of that component, with MANIFEST.md and SHA256SUMS of its own. - - name: The files of this release - run: | - set -euo pipefail - (cd candidate && sha256sum -c SHA256SUMS) - if [ '${{ needs.what.outputs.component }}' = all ]; then - mv candidate release - else - tar -xzf "$(find stage-artifact -name 'stage-linux-x64.tar.gz' | head -1)" stage/mcppls-devtools - ./stage/mcppls-devtools release check --version '${{ needs.what.outputs.version }}' \ - --component '${{ needs.what.outputs.component }}' --from candidate --dir release --render - (cd release && sha256sum * > SHA256SUMS) - ./stage/mcppls-devtools release check --version '${{ needs.what.outputs.version }}' \ - --component '${{ needs.what.outputs.component }}' --dir release - fi - ls -l release - ls release | grep -q -- '-${{ needs.what.outputs.version }}\.' || { - echo "::error::the release carries no file of version ${{ needs.what.outputs.version }}"; exit 1; } - - name: Tag and release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag='${{ needs.what.outputs.tag }}' - version='${{ needs.what.outputs.version }}' - # The notes are what CHANGELOG.md promises (its section for this version, under "What - # changed"), then the manifest under "Files": every file, what it is, how to install it. - # The manifest's own table has no heading, and without one it reads as part of the - # CHANGELOG section's last subsection. Written outside release/, whose every file is an asset. - notes="$RUNNER_TEMP/notes.md" - heading='## ${{ needs.what.outputs.heading }}' - section=$(awk -v heading="$heading" 'index($0, heading) == 1 { on = 1; next } on && /^## \[/ { exit } on { print }' CHANGELOG.md) - [ -n "$(printf '%s' "$section" | tr -d '[:space:]')" ] || { echo "::error::CHANGELOG.md has no $heading section"; exit 1; } - { head -n 1 release/MANIFEST.md; printf '\n## What changed\n'; printf '%s\n' "$section"; printf '\n## Files\n'; tail -n +2 release/MANIFEST.md; } > "$notes" - commit=$(git rev-parse HEAD) - remote=$(git ls-remote origin "refs/tags/$tag" "refs/tags/$tag^{}" | awk ' - /\^\{\}$/ { peeled=$1 } !/\^\{\}$/ { direct=$1 } - END { print peeled ? peeled : direct }') - if [ -n "$remote" ]; then - [ "$remote" = "$commit" ] || { echo "::error::existing tag $tag names another commit"; exit 1; } - else - git tag "$tag" "$commit" - git push origin "refs/tags/$tag" - fi - gh release create "$tag" release/* \ - --title "mcpp-language-server $tag" \ - --notes-file "$notes" \ - ${{ github.event.inputs.draft == 'false' && ' ' || '--draft' }} \ - ${{ github.event.inputs.prerelease == 'false' && ' ' || '--prerelease' }} - - # Only a release created already published: a draft reaches Open VSX when the maintainer publishes - # it, through publish-openvsx.yml's own `release: published` trigger. - openvsx: - needs: [what, publish] - if: github.event.inputs.draft == 'false' && contains(fromJSON('["all","vscode"]'), needs.what.outputs.component) - uses: ./.github/workflows/publish-openvsx.yml - with: - tag: ${{ needs.what.outputs.tag }} - secrets: inherit - - jetbrains: - needs: [what, publish] - if: github.event.inputs.draft == 'false' && contains(fromJSON('["all","clion"]'), needs.what.outputs.component) - uses: ./.github/workflows/publish-jetbrains.yml - with: - tag: ${{ needs.what.outputs.tag }} - secrets: inherit diff --git a/.github/workflows/zed-smoke.yml b/.github/workflows/zed-smoke.yml deleted file mode 100644 index 19383e9a..00000000 --- a/.github/workflows/zed-smoke.yml +++ /dev/null @@ -1,108 +0,0 @@ -name: Zed smoke - -# The Zed extension in a real Zed (editors/zed/tests/smoke.sh; plan 0.0.8 part 2, Z-2 and Z-3), on -# Linux only: the pinned stable Zed (ZED_VERSION in .github/versions.env) under xvfb-run, rendering -# with mesa's lavapipe. One job per settings: `recommended` (mcppls first, clangd off) and `default` -# (Zed's own, where both servers run). -# -# Called from the workflows that check a whole candidate, on the payload artifact their own run made: -# -# ci.yml a pull request that touches the Zed extension, the server's protocol layer or -# orchestrator, or devtools, or that merges into a release/* branch -# nightly.yml every night, after ci.yml -# release-checks every release candidate (and a pull request that changes release packaging) -# -# It needs the `payload-linux-x64` artifact of the calling run, or of the run `payload-run` names. - -on: - workflow_call: - inputs: - payload-run: - description: The run whose payload-linux-x64 to test; empty for the calling run - type: string - default: '' - -# No permissions of its own: it has the caller's. CI's default (contents) is all it needs for its own run's -# payload; nightly's `payload-run` reads another run's artifacts, and nightly grants `actions: read` for it. - -jobs: - zed: - name: Zed (linux-x64, ${{ matrix.mode }} settings) - runs-on: ubuntu-24.04 - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - mode: [recommended, default] - steps: - - uses: actions/checkout@v7 - - name: Payload process observer controls - run: python3 editors/zed/tests/test_payload_processes.py - - name: Version pins - run: | - set -euo pipefail - while IFS='=' read -r name value; do - case "$name" in ''|\#*) continue ;; esac - echo "$name=$value" >> "$GITHUB_ENV" - done < .github/versions.env - - uses: ./.github/actions/payload - with: - platform: linux-x64 - run-id: ${{ inputs.payload-run }} - # Zed needs a display and a Vulkan driver: xvfb for the first, lavapipe (software Vulkan) - # for the second. The libraries are what a desktop has and a runner image may not. - - name: Display, Vulkan and the libraries Zed loads - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y --no-install-recommends xvfb mesa-vulkan-drivers libvulkan1 libegl1 libgl1 libgbm1 \ - libxkbcommon0 libxkbcommon-x11-0 libwayland-client0 libx11-xcb1 libxcb1 libfontconfig1 libasound2t64 clangd - # Zed's default lookup tries PATH before downloading from GitHub. Keep its default - # server selection and require the independently started process, without testing downloads. - command -v clangd - clangd --version - - name: Zed ${{ env.ZED_VERSION }} - id: zed-cache - uses: actions/cache@v6 - with: - path: ${{ runner.temp }}/zed-download - key: zed-linux-x86_64-${{ env.ZED_VERSION }}-${{ env.ZED_LINUX_X86_64_SHA256 }} - - name: Download Zed - if: steps.zed-cache.outputs.cache-hit != 'true' - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/zed-download" - curl -fsSL --retry 3 --retry-all-errors --retry-delay 2 -o "$RUNNER_TEMP/zed-download/zed.tar.gz" \ - "https://github.com/zed-industries/zed/releases/download/v${ZED_VERSION}/zed-linux-x86_64.tar.gz" - - name: Unpack Zed - run: | - set -euo pipefail - echo "$ZED_LINUX_X86_64_SHA256 $RUNNER_TEMP/zed-download/zed.tar.gz" | sha256sum -c - - tar -xzf "$RUNNER_TEMP/zed-download/zed.tar.gz" -C "$RUNNER_TEMP" - # The pinned tarball's hash is the version check: the editor binary takes no --version (its CLI, bin/zed, does). - test -x "$RUNNER_TEMP/zed.app/libexec/zed-editor" - # The crate compiled to WebAssembly by the same devtools that installs it (the runner's own - # rustup provides cargo; devtools adds the wasm32-wasip2 target). - - name: Build the extension - run: stage/mcppls-devtools extension --editor zed - - name: Zed on a project, ${{ matrix.mode }} settings - env: - ZED_EDITOR: ${{ runner.temp }}/zed.app/libexec/zed-editor - MCPPLS_PAYLOAD: ${{ github.workspace }}/payload - MCPPLS_DEVTOOLS: ${{ github.workspace }}/stage/mcppls-devtools - ZED_SMOKE_DIR: ${{ runner.temp }}/zs - # The preinstalled PATH clangd must be started by Zed beside mcppls. - ZED_SMOKE_REQUIRE_CLANGD: '1' - run: xvfb-run -a -s '-screen 0 1280x1024x24' editors/zed/tests/smoke.sh ${{ matrix.mode }} - - uses: actions/upload-artifact@v7 - if: failure() - with: - name: zed-smoke-${{ matrix.mode }} - path: | - ${{ runner.temp }}/zs/*.log - ${{ runner.temp }}/zs/zed.out - ${{ runner.temp }}/zs/cache/logs - ${{ runner.temp }}/zs/cache/bundles - ${{ runner.temp }}/zs/home/.local/share/zed/logs - if-no-files-found: ignore - retention-days: 7