diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0218968..6df8107 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ jobs: - run: python -m pip install -r requirements.txt - name: Check catalog run: python tools/snodec_sync check + - run: sudo apt-get update && sudo apt-get install -y cmake ninja-build g++ zstd - name: Unit and workflow boundary tests if: ${{ always() && !cancelled() }} run: python -m unittest discover -s tests -v diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml new file mode 100644 index 0000000..1f2ac68 --- /dev/null +++ b/.github/workflows/publish-dev.yml @@ -0,0 +1,75 @@ +name: Publish development SDK +on: + workflow_call: +permissions: + contents: write +concurrency: + group: development-sdk-${{ github.repository }} + cancel-in-progress: false +jobs: + publish: + name: Publish tested development SDK + if: ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + runs-on: ubuntu-latest + steps: + - run: sudo apt-get update && sudo apt-get install -y zstd + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: development-sdk + path: sdk + - name: Publish current default-branch SDK only + shell: python + env: + GH_TOKEN: ${{ github.token }} + run: | + import json + import os + from pathlib import Path + import subprocess + import tarfile + from urllib.error import HTTPError + from urllib.parse import quote + from urllib.request import Request, urlopen + + repository = os.environ["GITHUB_REPOSITORY"] + sha = os.environ["GITHUB_SHA"] + token = os.environ["GH_TOKEN"] + + def api(path, method="GET", data=None): + body = None if data is None else json.dumps(data).encode() + request = Request("https://api.github.com/" + path, data=body, method=method, + headers={"Authorization": "Bearer " + token, "Accept": "application/vnd.github+json"}) + with urlopen(request, timeout=60) as response: + return json.load(response) + + path = "repos/" + repository + branch = api(path)["default_branch"] + if api(path + "/commits/" + quote(branch, safe=""))["sha"] != sha: + print("Superseded build: no development SDK publication") + else: + archives = list(Path("sdk").glob("*-dev-*.tar.zst")) + if len(archives) != 1: + raise ValueError("expected one tested SDK archive") + archive = archives[0] + unpacked = Path(os.environ["RUNNER_TEMP"]) / "sdk.tar" + subprocess.run(["zstd", "-d", archive, "-o", unpacked], check=True) + with tarfile.open(unpacked) as contents: + identity = json.load(contents.extractfile("./.snodec-sdk.json")) + if identity["repository"] != repository or identity["source_commit"] != sha: + raise ValueError("SDK does not match the successful build") + try: + release = api(path + "/releases/tags/dev") + if not release["prerelease"]: + raise ValueError("dev must remain a prerelease") + except HTTPError as error: + if error.code != 404: + raise + api(path + "/releases", "POST", { + "tag_name": "dev", "target_commitish": sha, + "name": "Development SDK", "body": "Unsigned development SDK from successful default-branch Verify runs. Not a stable product release.", + "prerelease": True, "make_latest": "false", + }) + subprocess.run(["gh", "release", "upload", "dev", str(archive), + "--clobber", "--repo", repository], check=True) + api(path + "/git/refs/tags/dev", "PATCH", {"sha": sha, "force": True}) + print("Published development SDK for " + sha) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 934a7f1..1fddd30 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -5,12 +5,14 @@ permissions: contents: read jobs: prepare: - name: Read C++ manifest + name: Read build manifest runs-on: ubuntu-latest outputs: + profile: ${{ steps.manifest.outputs.profile }} config: ${{ steps.manifest.outputs.config }} container: ${{ steps.manifest.outputs.container }} options: ${{ steps.manifest.outputs.options }} + artifact_paths: ${{ steps.manifest.outputs.artifact_paths }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: @@ -33,44 +35,75 @@ jobs: manifest = yaml.safe_load(Path(".snodec/repo.yml").read_text()) if not isinstance(manifest, dict) or not isinstance(manifest.get("ci"), dict): raise ValueError("manifest must contain a ci mapping") - defaults = { + cpp = { "profile": "cpp", "container": "gcc:15.3.0-trixie", "apt": [], "deps": [], "cmake_options": [], "ctest": True, "ctest_args": ["--output-on-failure"], - "ipv6": False, "runtime_dirs": False, + "ipv6": False, "runtime_dirs": False, "parallel": 2, "sdk": False, "env": {}, "ctest_launcher": [], } + python = { + "profile": "python", "python_version": "3.12", "pip": ".[dev]", + "native_build": [], "smoke": {}, "artifact_globs": [], + } + profile = manifest["ci"].get("profile", "cpp") + if profile not in ("cpp", "python"): + raise ValueError("only cpp and python profiles are supported") + defaults = cpp if profile == "cpp" else python if manifest["ci"].keys() - defaults.keys(): raise ValueError("unsupported ci fields") config = defaults | manifest["ci"] - if config["profile"] != "cpp": - raise ValueError("only the cpp profile is supported") + if profile == "python": + if not isinstance(config["python_version"], str) or not re.fullmatch(r"3[.][0-9]+", config["python_version"]): + raise ValueError("python_version must specify Python 3") + if not isinstance(config["pip"], str) or not config["pip"].startswith("."): + raise ValueError("pip must specify the local project") + if not isinstance(config["smoke"], dict) or set(config["smoke"]) != {"Linux", "Windows", "macOS"}: + raise ValueError("smoke must contain commands for all three native platforms") + for key, values in {"native_build": config["native_build"], "artifact_globs": config["artifact_globs"], **config["smoke"]}.items(): + if not isinstance(values, list) or not values or any(not isinstance(v, str) or not v or any(c in v for c in "\r\n\0") for v in values): + raise ValueError(key + " must be a non-empty argument list") + config["container"] = cpp["container"] if not isinstance(config["container"], str) or not re.fullmatch( r"[A-Za-z0-9][A-Za-z0-9._/:@-]*", config["container"] ): raise ValueError("container must be an image reference") - for key in ("ctest", "ipv6", "runtime_dirs"): + for key in ("ctest", "ipv6", "runtime_dirs", "sdk") if profile == "cpp" else (): if type(config[key]) is not bool: raise ValueError(key + " must be a boolean") - for key in ("apt", "deps", "cmake_options", "ctest_args"): + if profile == "cpp" and (not isinstance(config["env"], dict) or any( + not isinstance(key, str) or not re.fullmatch(r"[A-Z][A-Z0-9_]*", key) or key.startswith(("GITHUB_", "RUNNER_", "GH_")) + or not isinstance(value, str) or any(c in value for c in "\r\n\0") + for key, value in config["env"].items() + )): + raise ValueError("env must contain non-reserved single-line environment values") + if profile == "cpp" and (type(config["parallel"]) is not int or not 1 <= config["parallel"] <= 32): + raise ValueError("parallel must be between 1 and 32") + for key in ("apt", "deps", "cmake_options", "ctest_args", "ctest_launcher") if profile == "cpp" else (): if not isinstance(config[key], list) or any( not isinstance(item, str) or not item or any(c in item for c in "\r\n\0") for item in config[key] ): raise ValueError(key + " must be a list of single-line arguments") - if any(not re.fullmatch(r"[a-z0-9][a-z0-9+.-]*(?::[a-z0-9]+)?", p) for p in config["apt"]): + if profile == "cpp" and any(not re.fullmatch(r"[a-z0-9][a-z0-9+.-]*(?::[a-z0-9]+)?", p) for p in config["apt"]): raise ValueError("apt must contain package names, not options") - if any(not re.fullmatch(r"[A-Za-z0-9_.-]+@source", d) for d in config["deps"]): - raise ValueError("deps supports only SNodeC repository names with @source") - if len(config["deps"]) != len(set(config["deps"])): + if profile == "cpp" and any(not re.fullmatch(r"[A-Za-z0-9_.-]+@(source|dev)", d) for d in config["deps"]): + raise ValueError("deps supports SNodeC repository names with @source or @dev") + if profile == "cpp" and len(config["deps"]) != len({d.split("@")[0] for d in config["deps"]}): raise ValueError("deps must be unique and ordered") - options = "--cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0" if config["ipv6"] else "" + options = "--cap-add=NET_ADMIN --sysctl net.ipv6.conf.all.disable_ipv6=0" if config.get("ipv6") else "" with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write("profile=" + profile + "\n") output.write("config=" + json.dumps(config, separators=(",", ":")) + "\n") output.write("container=" + config["container"] + "\n") output.write("options=" + options + "\n") + if profile == "python": + output.write("artifact_paths<")) + if target.scheme or target.netloc or not target.path: + continue + local = Path(unquote(target.path)) + local = local if local.is_absolute() else path.parent / local + if not local.exists(): + failures.append(filename + ": missing link " + link) + if failures: + raise SystemExit("\n".join(failures)) + + python: + name: snodec / python (${{ matrix.os }}) + needs: prepare + if: ${{ needs.prepare.outputs.profile == 'python' }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2025, macos-15] + runs-on: ${{ matrix.os }} + env: + CI_CONFIG: ${{ needs.prepare.outputs.config }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ fromJSON(needs.prepare.outputs.config).python_version }} + - name: Install development dependencies + shell: python + run: | + import json + import os + import subprocess + subprocess.run(["python", "-m", "pip", "install", "-e", json.loads(os.environ["CI_CONFIG"])["pip"]], check=True) + - name: Linux desktop dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0 libpulse0 + - name: Tests independent of physical audio devices + env: + QT_QPA_PLATFORM: offscreen + run: python -m pytest -q -m "not audio_device" + - name: Build native package + id: package + if: ${{ always() && !cancelled() }} + shell: python + run: | + import json + import os + import subprocess + subprocess.run(json.loads(os.environ["CI_CONFIG"])["native_build"], check=True) + - name: Native packaged launch + if: ${{ always() && !cancelled() && steps.package.outcome == 'success' }} + env: + QT_QPA_PLATFORM: offscreen + shell: python + run: | + import json + import os + import subprocess + subprocess.run(json.loads(os.environ["CI_CONFIG"])["smoke"][os.environ["RUNNER_OS"]], check=True) + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: ${{ always() && !cancelled() && steps.package.outcome == 'success' }} + with: + name: ${{ github.event.repository.name }}-${{ runner.os }}-${{ runner.arch }} + path: ${{ needs.prepare.outputs.artifact_paths }} + if-no-files-found: error diff --git a/PHASE2.md b/PHASE2.md new file mode 100644 index 0000000..7115a21 --- /dev/null +++ b/PHASE2.md @@ -0,0 +1,100 @@ +# Phase 2: verification consolidation + +## Governing invariant + +Build/test policy lives once in the control repository. A caller owns build +options in `.snodec/repo.yml` and only genuinely project-specific `x-` jobs. +Nothing grants build/test code a release write token. The separate development +publisher executes control-owned orchestration, not repository build scripts. + +The approved extensions add the missing Python profile and SDK handoff; +reduction alone cannot supply those capabilities. They replace the duplicated +native/source-build jobs rather than leave two CI implementations in service. +Repository READMEs, licence files, package workflows, secrets, stable product +tags and default branch names are outside this change. + +## Preserved behavior and new checks + +| Repository | Shared profile | Repository-specific behavior | +| --- | --- | --- | +| snode.c | cpp, tests/apps enabled | Existing 212 tests, including installed-consumer/policy coverage; separate default-branch SDK publisher | +| mqttsuite | cpp, snode.c SDK | Six new CTest smoke tests: five CLI help contracts and real MQTT CONNECT/SUBSCRIBE/live and retained PUBLISH/PING/DISCONNECT; separate SDK publisher | +| AISuite | cpp, snode.c SDK | Codex tests/apps enabled, IPv6/runtime directories; unchanged protocol-generation command as `x-protocol`; publisher requires both Verify and protocol validation | +| CodexUI | cpp, snode.c/AISuite SDKs | Native qualification with Xvfb/offscreen and existing timing policy; web qualification remains `x-web` (Node 22/24.20.0, checksum-pinned Chrome, profiling, production/browser qualification, diagnostics and uploads) | +| AutoTalk | python | Three native platforms, non-audio-device pytest, native build, packaged smoke launch, archive/checksum artifacts; old native-build workflow removed | + +The snode.c policy test still strictly restricts package triggers and workflow +paths; it additionally recognizes the thin Verify caller and tests inline event +parsing, duplicate events and unexpected events. No existing test is disabled. +MQTTSuite help deliberately returns 2 when framework bootstrap displays help; +its smoke test verifies this established contract and the actual usage output. + +## Evidence scope + +Local C++ qualification uses the requested GCC 15.3.0 / Debian trixie container, +real source trees, isolated runtime directories and unprivileged CTest. SDK +relocation is tested in a separate container with only extracted installs, no +producer staging/build paths: the existing SNode.C installed consumer runs and +CodexUI configures/builds against relocated snode.c and AISuite exports. +The control unit suite executes the actual workflow scripts with real tiny +CMake builds; only network/privileged boundaries are mocked there. + +Default-branch green runs and actual GitHub development publication cannot be +claimed before merging the coordinated PRs. Windows/macOS native packaging and +CodexUI browser qualification must be confirmed by their GitHub jobs. PRs remain +draft where these prerequisites or shared-workflow/SDK prerequisites are outstanding. App test fixes were +explicitly deferred by the maintainer, not silently skipped. + +## Merge order + +1. Merge the control PR; maintainer publishes its reviewed version and moving + `v1` in **SNodeC/.github**. No tag is created by this implementation task. +2. Merge snode.c; require green default-branch Verify and a published `dev` SDK. +3. Merge mqttsuite and AISuite; require green Verify, AISuite protocol validation + and published SDKs. Stale/missing SDKs fail closed rather than rebuild silently. +4. CodexUI follows its SDK prerequisites; AutoTalk depends only on the shared + workflow release. The maintainer explicitly deferred their existing test + defects to later work; tests and failure gates remain enabled. +5. Phase 2 gate: snode.c and mqttsuite are green on their API-reported default + branches. Phase 3 package migration and Phase 4 README alignment are not started. + +## Local results before opening PRs + +- Control: 31 tests pass; catalog validation, deterministic profile drift, + `actionlint 1.7.12`, changed C++/CMake formatting and whitespace checks pass. +- snode.c: all 212 tests pass as an unprivileged user (including the updated + policy test and installed-consumer test). +- mqttsuite: all six new smoke tests pass as an unprivileged user. +- AISuite: all 22 tests pass; the pinned protocol-generation check passes. +- Relocated real SDKs: the existing SNode.C installed consumer runs, and CodexUI + configures/builds with only extracted snode.c/AISuite installs visible. +- CodexUI: full Xvfb/offscreen CTest discovers 84 cases: 16 failures (12 thread + pane semantic-no-op paint gates; four grouped 10,000-row timeouts), four Breeze + cases skipped by their existing availability guard. The same semantic-no-op + failure reproduces under the old root execution model on unchanged sources; + no UI test or implementation is weakened here. Native production build passes. + The preserved web job was structurally compared with its baseline: equal + except replacing AISuite's hard-coded branch with its default-branch checkout. +- AutoTalk Linux native package build and packaged smoke launch pass locally. + The full pytest run hangs in prepared-playback qualification; Linux/Windows/macOS + full qualification is not claimed green. The + shared matrix preserves the existing jobs; these must pass after release of + the shared workflow. Local Linux qualification uses Python 3.12 and an + isolated container, not the user's desktop or audio services. + +Logs remain in the implementation workspace's `evidence/` directory; they are +not committed build artifacts or substituted for GitHub default-branch gates. + +## Explicitly deferred work + +The maintainer requested on 2026-10-10: defer CodexUI and AutoTalk test fixes. +Their tests remain enabled with unchanged correctness gates. Local investigation +was stopped; no application implementation or audio-test skip was added. The +control, snode.c, mqttsuite and AISuite verification results above are unaffected. + +Coordinated PRs: [control #2](https://github.com/SNodeC/.github/pull/2), +[snode.c #266](https://github.com/SNodeC/snode.c/pull/266), +[mqttsuite #31](https://github.com/SNodeC/mqttsuite/pull/31), +[AISuite #60](https://github.com/SNodeC/AISuite/pull/60), +[CodexUI #76](https://github.com/SNodeC/CodexUI/pull/76), +[AutoTalk #6](https://github.com/SNodeC/AutoTalk/pull/6). diff --git a/README.md b/README.md index 5e48d56..afe13fe 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ rewrite their READMEs, change the package pipeline or create release tags. ## Local checks Python 3 with PyYAML is sufficient for catalog/profile work. CMake, Ninja, Git -and a C++ compiler are also needed for the workflow boundary integration tests. +zstd, tar and a C++ compiler are also needed for the workflow boundary integration tests. ```sh python3 -m venv .venv @@ -51,44 +51,87 @@ In particular, snode.c and mqttsuite still show their current release-package workflow until callers are added in a later phase. The control repository's CI path is included because that workflow is delivered here. -## Reusable C++ Verify - -`.github/workflows/verify.yml` is `workflow_call`-only. A preparation job -validates the caller's manifest and selects the container/capabilities before the -`snodec / cpp` job is scheduled. Checkouts use the exact PR head SHA (otherwise -the event SHA); checkout credentials are not persisted. The C++ checkout includes -submodules. - -The default image is `gcc:15.3.0-trixie`. Base packages are the specified build -dependencies plus Python 3 for stdlib command orchestration. Extra packages must -be individual package names. CMake and CTest arguments are passed as argument -lists, never evaluated as shell fragments. - -Dependencies support `@source` only, in declared order. -The GitHub API supplies each default branch and resolves it to a commit before -checkout; source submodules are included. Each dependency is configured in -Release mode with `BUILD_TESTING=OFF`, built and installed into its own staging -prefix. Previously staged dependencies flow through `CMAKE_PREFIX_PATH` and -`LD_LIBRARY_PATH` (`lib` and `lib64`). The API token is removed from the -environment before any dependency/caller build code runs. - -The caller is configured with Ninja and its `cmake_options`, built, tested -when enabled, and installed into a staging prefix. The workflow owns staging -prefixes and the dependency search path; it sets `CMAKE_INSTALL_LIBDIR=lib`. -Runtime directories and the snodec group are opt-in. IPv6 is also opt-in and -enables NET_ADMIN, the IPv6 sysctl and a dummy interface with `fd00::1/128`. -Whitespace checking runs even after a build/test failure, against the PR base -or the previous push commit (the empty tree for an initial commit). - -Tests execute the actual inline workflow scripts, including a real two-dependency -CMake/CTest/install boundary fixture. They do not duplicate the implementation. -The control CI runs catalog checks, tests and profile drift independently so -one failure does not suppress subsequent checks. - -After merge, **the maintainer** publishes `v1.0.0` and the moving `v1`. -Templates are not installed into other repositories in this phase. Other CI -profiles, organization sync PRs, package/release/site/figure lanes and dev SDK -assets belong to later phases. - -See [evidence](EVIDENCE.md), [brand values](brand/README.md), -[contributing](CONTRIBUTING.md) and [security](SECURITY.md). +## Shared Verify (Phase 2) + +`.github/workflows/verify.yml` remains `workflow_call`-only and read-only. The +manifest is the single authority for build options; the catalog holds identity. +Preparation validates the manifest before selecting the C++ container or native +Python matrix. Every checkout uses the exact PR head SHA, otherwise the event +SHA, with no persisted credentials. C++ includes recursive submodules. + +### C++ + +The default container is `gcc:15.3.0-trixie`. Extra apt packages, CMake arguments, +CTest arguments and optional `ctest_launcher` are argument lists, not shell +fragments. `parallel` controls build workers. Non-secret `env` values reach +builds and tests; reserved GitHub/runner/token variables are rejected. + +Ordered dependencies accept `@source` (API-resolved default branch, +exact commit, Debug build with `BUILD_TESTING=OFF`) or `@dev` (tested +SDK for the same distribution/version/architecture). Both stage installs and +supply `CMAKE_PREFIX_PATH` and `LD_LIBRARY_PATH`. SDK downloads verify the GitHub +asset SHA-256 digest, use safe tar extraction, check repository/target/commit +identity, and reject inconsistent dependency revisions. There is no source +fallback when an SDK is absent or stale. Dependency updates must publish in +order: snode.c, then AISuite, then CodexUI. mqttsuite depends only on snode.c. +The read token is removed before executing any build code. + +The caller configures with Ninja, builds, runs CTest as an unprivileged `snodec` +user when enabled (`--no-tests=error`), then installs into a staging prefix. +`CMAKE_INSTALL_LIBDIR=lib` and dependency paths are owned by the workflow. +Runtime directories and IPv6 remain opt-in; IPv6 adds NET_ADMIN, the sysctl and +`fd00::1/128` on a dummy interface. snode.c enables its existing component, unit, +policy and staged installed-consumer tests explicitly in its manifest. + +### Python + +AutoTalk's existing qualification becomes the shared `python` profile: +Python 3.12 on Ubuntu 24.04, Windows 2025 and macOS 15, editable development +install, pytest excluding physical audio devices, native package build, +packaged smoke launch and archive/checksum upload. The manifest supplies only +local project arguments, build/launch commands and artifact paths. Matrix +failures do not cancel the other platforms. Package qualification can still run +after test failures, without turning a failed test job green. + +### Independent hygiene + +Whitespace checks run against the PR base or previous push commit (empty tree +on the initial commit). An independent job also checks changed C/C++ and CMake +files against existing repository formatting policies and changed Markdown's +local file links. It neither invents a style where none exists nor performs +unreliable external-URL probes. Failures are collected rather than stopping at +the first file. This job still runs when compilation/tests fail. + +### Development SDK publication + +`sdk: true` exports a staged install after successful default-branch C++ tests. +The product's separate `x-sdk` job calls `publish-dev.yml`, with the only +`contents: write` grant. It waits for all shared Verify jobs; AISuite also waits +for `x-protocol`. The publisher never checks out or executes product code. It +checks the current API-resolved default-branch HEAD, rejects superseded runs and +mismatched artifact identities, and creates/updates only the unsigned rolling +`dev` prerelease/tag. Asset names are `-dev---.tar.zst`. +Stable version tags, secrets and package workflows are untouched. + +### Coordinated rollout + +Merge the control workflow change first. The maintainer then publishes the +reviewed shared-workflow version and moving `v1` alias in **SNodeC/.github**. +The existing `v1.0` does not substitute for `@v1`, and predates these extensions. +Merge snode.c next and wait for green default-branch CI plus its `dev` SDK. +Then merge mqttsuite and AISuite; wait for AISuite's green CI/protocol check and +SDK before CodexUI. AutoTalk can follow the control workflow independently. +Caller PRs must not merge while `v1` or their development SDK dependencies are +missing. No duplicate old/native build jobs remain after their migration. + +Tests execute the actual inline workflow scripts, including ordered source +builds, a real SDK export/download/relocation/CTest/install consumer, invalid +SDK identities/digests, Python commands, hygiene aggregation and publication +boundaries. Local boundary tests additionally require **zstd** and tar. + +Repository README frames are **Phase 4**, not Phase 2. Package/release/site, +figure, ecosystem and org-sync lanes remain separate later phases. + +See [Phase 2 evidence and rollout](PHASE2.md), [Phase 1 evidence](EVIDENCE.md), +[brand values](brand/README.md), [contributing](CONTRIBUTING.md) and +[security](SECURITY.md). diff --git a/templates/repo.yml b/templates/repo.yml index a7b7873..036f53c 100644 --- a/templates/repo.yml +++ b/templates/repo.yml @@ -1,13 +1,18 @@ -# Copy to .snodec/repo.yml in a non-fork C++ repository in a later phase. +# Copy to .snodec/repo.yml in a non-fork C++ repository. # Identity (area, kind, summary and ecosystem edges) lives only in catalog.yml. ci: profile: cpp container: gcc:15.3.0-trixie apt: [] # Additional Debian packages; individual package names. deps: [] # Ordered source builds, e.g. [snode.c@source, AISuite@source]. - # @source resolves each dependency's default branch via the API. + # @source resolves the default branch via API; @dev downloads a tested SDK. + # @dev must exist first: merge/publish dependencies before consumers. cmake_options: [] # Individual CMake arguments, e.g. [-DCMAKE_BUILD_TYPE=Release]. ctest: true ctest_args: [--output-on-failure] ipv6: false # NET_ADMIN, IPv6 enabled and dummy interface fd00::1/128. runtime_dirs: false # snodec group; /etc/snode.c, /var/log/snode.c, /var/run/snode.c. + parallel: 2 # Build workers (1–32); ctest parallelism belongs in ctest_args. + sdk: false # Export on default branch; product x-sdk job publishes separately. + env: {} # Non-secret build/test environment, e.g. CODEXUI_TIMING_POLICY. + ctest_launcher: [] # Optional argument-list wrapper, e.g. [xvfb-run, -a]. diff --git a/tests/test_verify.py b/tests/test_verify.py index cace9a4..9317355 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -36,7 +36,21 @@ def prepare(path, ci): output.write_text("") with directory(path), patch.dict(os.environ, {"GITHUB_OUTPUT": str(output)}): exec(compile(script("prepare", "Read and validate manifest"), "manifest", "exec"), {}) - return dict(line.split("=", 1) for line in output.read_text().splitlines()) + result = {} + lines = iter(output.read_text().splitlines()) + for line in lines: + if "<<" in line: + key, delimiter = line.split("<<", 1) + values = [] + for value in lines: + if value == delimiter: + break + values.append(value) + result[key] = "\n".join(values) + else: + key, value = line.split("=", 1) + result[key] = value + return result class ManifestTests(unittest.TestCase): @@ -66,11 +80,12 @@ def test_valid_custom_options(self): def test_invalid_manifests_fail_before_container(self): cases = [{"profile": "python"}, {"ipv6": "true"}, {"runtime_dirs": 1}, - {"ctest": None}, {"apt": "--bad"}, {"deps": ["snode.c@dev"]}, - {"deps": ["snode.c@source"] * 2}, {"apt": ["--allow-unauthenticated"]}, + {"ctest": None}, {"apt": "--bad"}, {"deps": ["snode.c@release"]}, + {"deps": ["snode.c@source"] * 2}, {"deps": ["snode.c@source", "snode.c@dev"]}, {"apt": ["--allow-unauthenticated"]}, {"apt": ["libssl-dev;echo bad"]}, {"container": "gcc:15\noptions=bad"}, {"cmake_options": [False]}, {"ctest_args": ["bad\0argument"]}, - {"deps": ["other/snode.c@source"]}, {"unknown": 1}] + {"deps": ["other/snode.c@source"]}, {"unknown": 1}, {"parallel": 0}, {"parallel": True}, + {"sdk": "yes"}, {"env": {"GH_READ_TOKEN": "bad"}}, {"env": {"OK": 1}}] with tempfile.TemporaryDirectory() as directory_name: for ci in cases: with self.subTest(ci=ci), self.assertRaises(ValueError): @@ -128,7 +143,9 @@ def test_runtime_and_ipv6_are_opt_in(self): with patch("subprocess.run") as run, patch.dict(os.environ, {"CI_CONFIG": json.dumps( {"apt": [], "runtime_dirs": False, "ipv6": False})}): exec(code, {}) - run.assert_not_called() + commands = [call.args[0] for call in run.call_args_list] + self.assertTrue(any(c[0] == "useradd" for c in commands)) + self.assertFalse(any(c[0] in ("ip", "install", "apt-get") for c in commands)) with patch("subprocess.run") as run, patch("grp.getgrnam", side_effect=KeyError), \ patch.dict(os.environ, {"CI_CONFIG": json.dumps( {"apt": ["zlib1g-dev"], "runtime_dirs": True, "ipv6": True})}): @@ -143,11 +160,13 @@ def test_disabled_ctest_and_arguments_are_not_shell_code(self): with tempfile.TemporaryDirectory() as directory_name: ci = json.loads(prepare(Path(directory_name), {"ctest": False, "cmake_options": ["-DTEXT=a value;echo not executed"]})["config"]) - with patch("subprocess.run") as run, patch.dict(os.environ, { + with patch("subprocess.run") as run, patch("subprocess.check_output", return_value="a" * 40), patch.dict(os.environ, { "CI_CONFIG": json.dumps(ci), "GITHUB_WORKSPACE": directory_name, "RUNNER_TEMP": directory_name, "GH_READ_TOKEN": "must-not-leak", + "GITHUB_REPOSITORY": "SNodeC/fixture", }): - exec(script("cpp", "Build ordered source dependencies, test and stage caller"), {}) + with patch("platform.freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": "13"}): + exec(script("cpp", "Build ordered source dependencies, test and stage caller"), {}) commands = [call.args[0] for call in run.call_args_list] self.assertFalse(any(c[0] == "ctest" for c in commands)) self.assertIn("-DTEXT=a value;echo not executed", commands[0]) @@ -207,6 +226,8 @@ def test_real_ordered_dependency_consumer_ctest_and_install(self): add_test(NAME first_success COMMAND consumer) install(TARGETS consumer DESTINATION bin) """) + git(caller, "init", "--initial-branch=caller/default") + commit(caller) ci = json.loads(prepare(caller, {"deps": ["depone@source", "deptwo@source"]})["config"]) requests, commands = [], [] actual_run = subprocess.run @@ -220,18 +241,23 @@ def response(request, **kwargs): return io.BytesIO(json.dumps(data).encode()) def run(args, **kwargs): - commands.append((list(args), dict(kwargs["env"]))) - self.assertNotIn("GH_READ_TOKEN", kwargs["env"]) + commands.append((list(args), dict(kwargs.get("env", os.environ)))) + self.assertNotIn("GH_READ_TOKEN", kwargs.get("env", os.environ)) if args[0] == "git" and "remote" in args: args[-1] = str(root / Path(args[2]).name) + if args[0] == "chown": + return subprocess.CompletedProcess(args, 0) + if args[0] == "runuser": + args = args[args.index("ctest"):] return actual_run(args, **kwargs) with patch.dict(os.environ, {"CI_CONFIG": json.dumps(ci), "GITHUB_WORKSPACE": str(caller), "RUNNER_TEMP": str(root), - "GH_READ_TOKEN": "read-only-fixture"}), \ + "GH_READ_TOKEN": "read-only-fixture", "GITHUB_REPOSITORY": "SNodeC/consumer"}), \ patch("urllib.request.urlopen", side_effect=response), \ patch("subprocess.run", side_effect=run): - exec(script("cpp", "Build ordered source dependencies, test and stage caller"), {}) + with patch("platform.freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": "13"}): + exec(script("cpp", "Build ordered source dependencies, test and stage caller"), {}) stage = root / "snodec/stage/caller/bin/consumer" self.assertTrue(stage.is_file()) self.assertEqual(subprocess.run([stage]).returncode, 0) @@ -246,12 +272,12 @@ def run(args, **kwargs): self.assertEqual([Path(cmd[2]).name for cmd, _ in configurations], ["depone", "deptwo", "caller"]) self.assertIn(str(root / "snodec/stage/depone/lib"), configurations[1][1]["LD_LIBRARY_PATH"]) self.assertIn(str(root / "snodec/stage/deptwo/lib"), configurations[2][1]["LD_LIBRARY_PATH"]) - tests = [cmd for cmd, _ in commands if cmd[0] == "ctest"] + tests = [cmd for cmd, _ in commands if "ctest" in cmd] self.assertEqual(len(tests), 1) self.assertIn("--output-on-failure", tests[0]) def test_whitespace_initial_push_and_pr_base(self): - code = script("cpp", "Check whitespace against PR base or previous commit") + code = script("hygiene", "Check whitespace against PR base or previous commit") with tempfile.TemporaryDirectory() as directory_name: path = Path(directory_name) git(path, "init", "--initial-branch=fixture/default") @@ -270,3 +296,223 @@ def check(base=""): commit(path) self.assertEqual(check(), 0) self.assertNotEqual(check(initial), 0) + + +class PhaseTwoBoundaryTests(unittest.TestCase): + def test_python_manifest_and_native_commands(self): + ci = {"profile": "python", "native_build": ["python", "tools/build.py"], + "smoke": {system: ["dist/program", "--smoke-test"] for system in ("Linux", "Windows", "macOS")}, + "artifact_globs": ["dist/*.zip", "dist/*.tar.gz"]} + with tempfile.TemporaryDirectory() as name: + output = prepare(Path(name), ci) + config = json.loads(output["config"]) + self.assertEqual(output["profile"], "python") + self.assertEqual(output["artifact_paths"], "dist/*.zip\ndist/*.tar.gz") + with patch("subprocess.run") as run, patch.dict(os.environ, { + "CI_CONFIG": output["config"], "RUNNER_OS": "Windows", + }): + exec(script("python", "Install development dependencies"), {}) + exec(script("python", "Build native package"), {}) + exec(script("python", "Native packaged launch"), {}) + self.assertEqual([call.args[0] for call in run.call_args_list], [ + ["python", "-m", "pip", "install", "-e", ".[dev]"], + ci["native_build"], ci["smoke"]["Windows"], + ]) + self.assertEqual(config["python_version"], "3.12") + for change in ({"smoke": {}}, {"python_version": "2.7"}, {"pip": "remote-package"}, + {"native_build": "python build.py"}, {"artifact_globs": ["bad\npath"]}): + with self.subTest(change=change), self.assertRaises(ValueError): + prepare(Path(name), ci | change) + job = WORKFLOW["jobs"]["python"] + self.assertEqual(job["strategy"]["matrix"]["os"], ["ubuntu-24.04", "windows-2025", "macos-15"]) + self.assertEqual(job["strategy"]["fail-fast"], "false") + self.assertIn('not audio_device', script("python", "Tests independent of physical audio devices")) + + def test_changed_file_hygiene_collects_all_failures(self): + code = script("hygiene", "Check changed source formatting and documentation links") + with tempfile.TemporaryDirectory() as name: + root = Path(name) + git(root, "init", "--initial-branch=hygiene/from-api") + (root / "old.md").write_text("[legacy](missing-old.md)\n") + commit(root) + (root / ".clang-format").write_text("BasedOnStyle: LLVM\n") + (root / ".cmake-format.py").write_text("") + (root / "changed.cpp").write_text("int main() {return 0;}\n") + (root / "CMakeLists.txt").write_text("project( test)\n") + (root / "new.md").write_text("[external](https://example.invalid) [missing](missing-new.md)\n```\n[code](ignored.md)\n```\n") + commit(root) + actual_run = subprocess.run + tools = [] + + def run(args, **kwargs): + if args[0] in ("clang-format", "cmake-format"): + tools.append(args[0]) + return subprocess.CompletedProcess(args, 1) + return actual_run(args, **kwargs) + + with directory(root), patch.dict(os.environ, {"PR_BASE": ""}), patch("subprocess.run", side_effect=run): + with self.assertRaises(SystemExit) as error: + exec(code, {}) + self.assertEqual(set(tools), {"clang-format", "cmake-format"}) + self.assertIn("missing-new.md", str(error.exception)) + self.assertNotIn("missing-old.md", str(error.exception)) + self.assertNotIn("ignored.md", str(error.exception)) + + def test_real_sdk_export_download_relocation_and_consumer(self): + import hashlib + import platform + with tempfile.TemporaryDirectory() as name: + root = Path(name) + source = root / "depone" + sha = dependency(source, "depone", first=True) + producer = root / "producer" + prefix = producer / "snodec/stage/caller" + subprocess.run(["cmake", "-S", source, "-B", root / "producer-build", "-G", "Ninja", + "-DCMAKE_INSTALL_PREFIX=" + str(prefix)], check=True) + subprocess.run(["cmake", "--build", root / "producer-build"], check=True) + subprocess.run(["cmake", "--install", root / "producer-build"], check=True) + target = "debian-13-" + platform.machine() + identity = {"repository": "SNodeC/depone", "source_commit": sha, "target": target, "dependencies": {}} + (prefix / ".snodec-sdk.json").write_text(json.dumps(identity)) + output = root / "sdk-output" + with patch.dict(os.environ, {"RUNNER_TEMP": str(producer), "GITHUB_OUTPUT": str(output)}): + exec(script("cpp", "Export tested development SDK"), {}) + archive = producer / output.read_text().strip().split("=", 1)[1] + data = archive.read_bytes() + # Remove the original prefix: imported CMake targets must really relocate. + import shutil + shutil.rmtree(producer / "snodec") + caller = root / "caller" + caller.mkdir() + git(caller, "init", "--initial-branch=sdk/consumer") + (caller / "main.cpp").write_text('#include "depone.h"\nint main(){return depone() == 41 ? 0 : 1;}\n') + (caller / "CMakeLists.txt").write_text('''cmake_minimum_required(VERSION 3.20) +project(consumer LANGUAGES CXX) +find_package(depone CONFIG REQUIRED) +add_executable(consumer main.cpp) +target_link_libraries(consumer PRIVATE depone::depone) +enable_testing() +add_test(NAME first_success COMMAND consumer) +install(TARGETS consumer DESTINATION bin) +''') + commit(caller) + ci = prepare(caller, {"deps": ["depone@dev"], "env": {"TIMING_POLICY": "report"}})["config"] + actual_run = subprocess.run + commands = [] + digest = "sha256:" + hashlib.sha256(data).hexdigest() + + def response(request, **kwargs): + if isinstance(request, str): + return io.BytesIO(data) + self.assertTrue(request.full_url.endswith("/releases/tags/dev")) + return io.BytesIO(json.dumps({"assets": [{"name": archive.name, + "browser_download_url": "https://example.invalid/sdk", "digest": digest}]}).encode()) + + def run(args, **kwargs): + commands.append(list(args)) + if "env" in kwargs: + self.assertEqual(kwargs["env"]["TIMING_POLICY"], "report") + self.assertNotIn("GH_READ_TOKEN", kwargs["env"]) + if args[0] == "chown": + return subprocess.CompletedProcess(args, 0) + if args[0] == "runuser": + args = args[args.index("ctest"):] + return actual_run(args, **kwargs) + + env = {"CI_CONFIG": ci, "GITHUB_WORKSPACE": str(caller), "RUNNER_TEMP": str(root / "consumer-run"), + "GITHUB_REPOSITORY": "SNodeC/consumer", "GH_READ_TOKEN": "no-leak"} + code = script("cpp", "Build ordered source dependencies, test and stage caller") + with patch.dict(os.environ, env), patch("platform.freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": "13"}), \ + patch("urllib.request.urlopen", side_effect=response), patch("subprocess.run", side_effect=run): + exec(code, {}) + self.assertFalse(any(cmd[0] == "git" and "fetch" in cmd for cmd in commands)) + self.assertEqual(subprocess.run([root / "consumer-run/snodec/stage/caller/bin/consumer"]).returncode, 0) + caller_identity = json.loads((root / "consumer-run/snodec/stage/caller/.snodec-sdk.json").read_text()) + self.assertEqual(caller_identity["dependencies"], {"depone": sha}) + digest = "sha256:incorrect" + env["RUNNER_TEMP"] = str(root / "bad-digest") + with patch.dict(os.environ, env), patch("platform.freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": "13"}), \ + patch("urllib.request.urlopen", side_effect=response), self.assertRaisesRegex(ValueError, "digest"): + exec(code, {}) + + for index, change in enumerate(({"repository": "SNodeC/wrong"}, {"target": "wrong-target"}, + {"source_commit": "not-a-commit"}, {"dependencies": {"missing": sha}})): + with self.subTest(change=change): + import tarfile + metadata = json.dumps(identity | change).encode() + buffer = io.BytesIO() + with tarfile.open(fileobj=buffer, mode="w") as contents: + member = tarfile.TarInfo(".snodec-sdk.json") + member.size = len(metadata) + contents.addfile(member, io.BytesIO(metadata)) + data = subprocess.check_output(["zstd", "-q", "-c"], input=buffer.getvalue()) + digest = "sha256:" + hashlib.sha256(data).hexdigest() + env["RUNNER_TEMP"] = str(root / ("invalid-identity-" + str(index))) + with patch.dict(os.environ, env), patch("platform.freedesktop_os_release", return_value={"ID": "debian", "VERSION_ID": "13"}), \ + patch("urllib.request.urlopen", side_effect=response), self.assertRaises(ValueError): + exec(code, {}) + + def test_sdk_publisher_is_default_branch_only_and_never_versions_products(self): + from urllib.error import HTTPError + publisher = yaml.load((ROOT / ".github/workflows/publish-dev.yml").read_text(), Loader=yaml.BaseLoader) + job = publisher["jobs"]["publish"] + self.assertIn("github.event.repository.default_branch", job["if"]) + self.assertEqual(publisher["permissions"], {"contents": "write"}) + self.assertFalse(any("checkout" in step.get("uses", "") for step in job["steps"])) + code = next(step["run"] for step in job["steps"] if step.get("name") == "Publish current default-branch SDK only") + sha = "a" * 40 + with tempfile.TemporaryDirectory() as name: + root = Path(name) + (root / "sdk").mkdir() + stage = root / "stage" + stage.mkdir() + (stage / ".snodec-sdk.json").write_text(json.dumps({"repository": "SNodeC/fixture", "source_commit": sha})) + archive = root / "sdk/fixture-dev-debian-13-x86_64.tar.zst" + subprocess.run(["tar", "--zstd", "-cf", archive, "-C", stage, "."], check=True) + actual_run = subprocess.run + for current, existing in (("b" * 40, False), (sha, False), (sha, True)): + calls, commands = [], [] + temporary = root / ("run-" + current[:1] + str(existing)) + temporary.mkdir() + + def response(request, **kwargs): + path = request.full_url.removeprefix("https://api.github.com/") + data = json.loads(request.data) if request.data else None + calls.append((path, request.method, data)) + if path.endswith("/fixture"): + result = {"default_branch": "arbitrary/default"} + elif "/commits/" in path: + self.assertTrue(path.endswith("arbitrary%2Fdefault")) + result = {"sha": current} + elif path.endswith("/releases/tags/dev"): + if not existing: + raise HTTPError(request.full_url, 404, "not found", {}, None) + result = {"prerelease": True} + else: + result = {} + return io.BytesIO(json.dumps(result).encode()) + + def run(args, **kwargs): + commands.append([str(arg) for arg in args]) + if args[0] == "gh": + return subprocess.CompletedProcess(args, 0) + return actual_run(args, **kwargs) + + with directory(root), patch.dict(os.environ, {"GITHUB_REPOSITORY": "SNodeC/fixture", + "GITHUB_SHA": sha, "GH_TOKEN": "write-fixture", "RUNNER_TEMP": str(temporary)}), \ + patch("urllib.request.urlopen", side_effect=response), patch("subprocess.run", side_effect=run): + exec(code, {}) + if current != sha: + self.assertEqual(commands, []) + self.assertTrue(all(method == "GET" for _, method, _ in calls)) + else: + self.assertIn(["gh", "release", "upload", "dev", "sdk/" + archive.name, + "--clobber", "--repo", "SNodeC/fixture"], commands) + writes = [(path, data) for path, method, data in calls if method != "GET"] + self.assertTrue(all(path.endswith(("/releases", "/git/refs/tags/dev")) for path, _ in writes)) + if not existing: + release = next(data for path, data in writes if path.endswith("/releases")) + self.assertEqual(release["tag_name"], "dev") + self.assertTrue(release["prerelease"]) + self.assertEqual(release["make_latest"], "false") + self.assertEqual(writes[-1][1], {"sha": sha, "force": True})