|
| 1 | +# This Source Code Form is subject to the terms of the Mozilla Public |
| 2 | +# License, v. 2.0. If a copy of the MPL was not distributed with this |
| 3 | +# file, You can obtain one at https://mozilla.org/MPL/2.0/. |
| 4 | +# SPDX-License-Identifier: MPL-2.0 |
| 5 | +# Copyright (c) 2026 Den Rozhnovskiy |
| 6 | + |
| 7 | +"""Configuration contract of the npm jobs in ``.github/workflows/integrations.yml``. |
| 8 | +
|
| 9 | +CI installs the Node extensions under two separate contracts, and one does not |
| 10 | +replace the other: |
| 11 | +
|
| 12 | +* configuration, pinned here statically: both npm jobs install with exactly |
| 13 | + ``npm ci --ignore-scripts --include=optional``, check out without persisted |
| 14 | + credentials, the VS Code job runs the esbuild gate between the install and |
| 15 | + the build, and the token stays read-only. The YAML is parsed; nothing runs. |
| 16 | +* environment: ``extensions/vscode-codeclone/test/verifyToolchain.js`` proves on |
| 17 | + the runner that the locked esbuild is actually installed and works. It runs |
| 18 | + in CI (or in the container sandbox), never from this module. |
| 19 | +
|
| 20 | +``--ignore-scripts`` stops npm lifecycle scripts during the install, and that |
| 21 | +is all it does: the explicit ``npm run check`` and ``npm test`` steps still run |
| 22 | +dependency code. Nothing here claims those steps are safe. |
| 23 | +""" |
| 24 | + |
| 25 | +from __future__ import annotations |
| 26 | + |
| 27 | +import re |
| 28 | +import shlex |
| 29 | +from collections.abc import Mapping |
| 30 | +from pathlib import Path |
| 31 | + |
| 32 | +import pytest |
| 33 | +import yaml |
| 34 | + |
| 35 | +_REPO_ROOT = Path(__file__).resolve().parents[1] |
| 36 | +_WORKFLOW = _REPO_ROOT / ".github" / "workflows" / "integrations.yml" |
| 37 | + |
| 38 | +_VSCODE_JOB = "vscode-extension" |
| 39 | +_NPM_JOBS = (_VSCODE_JOB, "claude-desktop-bundle") |
| 40 | +_PINNED_INSTALL = "npm ci --ignore-scripts --include=optional" |
| 41 | +_TOOLCHAIN_GATE = "node test/verifyToolchain.js linux-x64" |
| 42 | +_TOOLCHAIN_GATE_SCRIPT = Path(shlex.split(_TOOLCHAIN_GATE)[1]) |
| 43 | +_VSCODE_BUILD = "npm run check" |
| 44 | +_CHECKOUT_ACTION = "actions/checkout@" |
| 45 | +_READ_ONLY_TOKEN = {"contents": "read"} |
| 46 | + |
| 47 | +# npm subcommands that install packages or rerun their lifecycle scripts, with |
| 48 | +# every alias npm accepts for them. Over-matching only fails loudly. |
| 49 | +_NPM_INSTALL_SUBCOMMANDS = frozenset( |
| 50 | + { |
| 51 | + "ci", |
| 52 | + "clean-install", |
| 53 | + "ic", |
| 54 | + "install-clean", |
| 55 | + "isntall-clean", |
| 56 | + "install", |
| 57 | + "add", |
| 58 | + "i", |
| 59 | + "in", |
| 60 | + "ins", |
| 61 | + "inst", |
| 62 | + "insta", |
| 63 | + "instal", |
| 64 | + "isnt", |
| 65 | + "isnta", |
| 66 | + "isntal", |
| 67 | + "isntall", |
| 68 | + "install-test", |
| 69 | + "it", |
| 70 | + "install-ci-test", |
| 71 | + "cit", |
| 72 | + "clean-install-test", |
| 73 | + "sit", |
| 74 | + "rebuild", |
| 75 | + "rb", |
| 76 | + } |
| 77 | +) |
| 78 | +_SHELL_SEPARATORS = frozenset({"&&", "||", ";", "|", "&"}) |
| 79 | +_ENV_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=") |
| 80 | + |
| 81 | +_Node = Mapping[object, object] |
| 82 | +_Command = tuple[str, ...] |
| 83 | + |
| 84 | + |
| 85 | +def _mapping(value: object, what: str) -> _Node: |
| 86 | + assert isinstance(value, dict), f"{what} is not a mapping: {value!r}" |
| 87 | + return value |
| 88 | + |
| 89 | + |
| 90 | +def _workflow() -> _Node: |
| 91 | + document: object = yaml.safe_load(_WORKFLOW.read_text(encoding="utf-8")) |
| 92 | + return _mapping(document, str(_WORKFLOW.relative_to(_REPO_ROOT))) |
| 93 | + |
| 94 | + |
| 95 | +def _jobs(workflow: _Node) -> _Node: |
| 96 | + return _mapping(workflow.get("jobs"), "jobs") |
| 97 | + |
| 98 | + |
| 99 | +def _steps(jobs: _Node, name: object) -> list[_Node]: |
| 100 | + job = _mapping(jobs.get(name), f"job {name!r}") |
| 101 | + steps = job.get("steps") |
| 102 | + assert isinstance(steps, list) and steps, f"job {name!r} declares no steps" |
| 103 | + return [_mapping(step, f"a step of job {name!r}") for step in steps] |
| 104 | + |
| 105 | + |
| 106 | +def _commands(step: _Node) -> list[_Command]: |
| 107 | + """Every simple shell command a ``run`` step executes, in order.""" |
| 108 | + script = step.get("run") |
| 109 | + if not isinstance(script, str): |
| 110 | + return [] |
| 111 | + commands: list[_Command] = [] |
| 112 | + for line in script.splitlines(): |
| 113 | + lexer = shlex.shlex(line, posix=True, punctuation_chars=True) |
| 114 | + lexer.whitespace_split = True |
| 115 | + current: list[str] = [] |
| 116 | + for token in lexer: |
| 117 | + if token in _SHELL_SEPARATORS: |
| 118 | + if current: |
| 119 | + commands.append(tuple(current)) |
| 120 | + current = [] |
| 121 | + else: |
| 122 | + current.append(token) |
| 123 | + if current: |
| 124 | + commands.append(tuple(current)) |
| 125 | + return commands |
| 126 | + |
| 127 | + |
| 128 | +def _program(command: _Command) -> _Command: |
| 129 | + """The command without its leading ``NAME=value`` environment assignments.""" |
| 130 | + index = 0 |
| 131 | + while index < len(command) and _ENV_ASSIGNMENT.match(command[index]): |
| 132 | + index += 1 |
| 133 | + return command[index:] |
| 134 | + |
| 135 | + |
| 136 | +def _is_npm_install(command: _Command) -> bool: |
| 137 | + program = _program(command) |
| 138 | + return ( |
| 139 | + bool(program) |
| 140 | + and program[0] == "npm" |
| 141 | + and any(token in _NPM_INSTALL_SUBCOMMANDS for token in program[1:]) |
| 142 | + ) |
| 143 | + |
| 144 | + |
| 145 | +def _rendered(command: _Command) -> str: |
| 146 | + return " ".join(command) |
| 147 | + |
| 148 | + |
| 149 | +def _kind(command: _Command) -> str: |
| 150 | + if _is_npm_install(command): |
| 151 | + return "install" |
| 152 | + if _rendered(command) == _TOOLCHAIN_GATE: |
| 153 | + return "gate" |
| 154 | + return "code" |
| 155 | + |
| 156 | + |
| 157 | +def _install_commands(jobs: _Node, name: object) -> list[str]: |
| 158 | + return [ |
| 159 | + _rendered(command) |
| 160 | + for step in _steps(jobs, name) |
| 161 | + for command in _commands(step) |
| 162 | + if _is_npm_install(command) |
| 163 | + ] |
| 164 | + |
| 165 | + |
| 166 | +def _trigger_events(workflow: _Node) -> set[str]: |
| 167 | + # PyYAML follows YAML 1.1, where a bare ``on`` key loads as boolean True. |
| 168 | + triggers = workflow.get("on", workflow.get(True)) |
| 169 | + return {str(event) for event in _mapping(triggers, "the workflow triggers")} |
| 170 | + |
| 171 | + |
| 172 | +@pytest.mark.parametrize("job_name", _NPM_JOBS) |
| 173 | +def test_npm_job_installs_without_lifecycle_scripts_keeping_optional_deps( |
| 174 | + job_name: str, |
| 175 | +) -> None: |
| 176 | + assert _install_commands(_jobs(_workflow()), job_name) == [_PINNED_INSTALL] |
| 177 | + |
| 178 | + |
| 179 | +def test_every_npm_install_in_the_workflow_is_the_pinned_command() -> None: |
| 180 | + jobs = _jobs(_workflow()) |
| 181 | + unpinned = sorted( |
| 182 | + f"{name}: {command}" |
| 183 | + for name in jobs |
| 184 | + for command in _install_commands(jobs, name) |
| 185 | + if command != _PINNED_INSTALL |
| 186 | + ) |
| 187 | + |
| 188 | + assert unpinned == [] |
| 189 | + |
| 190 | + |
| 191 | +@pytest.mark.parametrize("job_name", _NPM_JOBS) |
| 192 | +def test_npm_job_checkout_does_not_persist_credentials(job_name: str) -> None: |
| 193 | + checkouts = [ |
| 194 | + step |
| 195 | + for step in _steps(_jobs(_workflow()), job_name) |
| 196 | + if str(step.get("uses", "")).startswith(_CHECKOUT_ACTION) |
| 197 | + ] |
| 198 | + assert checkouts, f"job {job_name!r} has no {_CHECKOUT_ACTION} step" |
| 199 | + |
| 200 | + persisted = [ |
| 201 | + _mapping(step.get("with", {}), "checkout inputs").get("persist-credentials") |
| 202 | + for step in checkouts |
| 203 | + ] |
| 204 | + assert persisted == [False] * len(checkouts) |
| 205 | + |
| 206 | + |
| 207 | +def test_vscode_job_runs_the_esbuild_gate_after_the_install_and_before_the_build() -> ( |
| 208 | + None |
| 209 | +): |
| 210 | + sequence = [ |
| 211 | + (_kind(command), _rendered(command)) |
| 212 | + for step in _steps(_jobs(_workflow()), _VSCODE_JOB) |
| 213 | + for command in _commands(step) |
| 214 | + ] |
| 215 | + kinds = [label for label, _command in sequence] |
| 216 | + assert "install" in kinds, sequence |
| 217 | + |
| 218 | + after_install = sequence[kinds.index("install") :] |
| 219 | + assert [label for label, _command in after_install[:2]] == ["install", "gate"], ( |
| 220 | + sequence |
| 221 | + ) |
| 222 | + assert _VSCODE_BUILD in [command for _label, command in after_install[2:]], sequence |
| 223 | + |
| 224 | + |
| 225 | +def test_vscode_esbuild_gate_runs_the_tracked_script_from_its_directory() -> None: |
| 226 | + jobs = _jobs(_workflow()) |
| 227 | + job = _mapping(jobs.get(_VSCODE_JOB), f"job {_VSCODE_JOB!r}") |
| 228 | + defaults = _mapping(job.get("defaults", {}), "defaults") |
| 229 | + default_directory = _mapping(defaults.get("run", {}), "defaults.run").get( |
| 230 | + "working-directory", "." |
| 231 | + ) |
| 232 | + directories = [ |
| 233 | + str(step.get("working-directory", default_directory)) |
| 234 | + for step in _steps(jobs, _VSCODE_JOB) |
| 235 | + if _TOOLCHAIN_GATE in map(_rendered, _commands(step)) |
| 236 | + ] |
| 237 | + assert directories, f"job {_VSCODE_JOB!r} never runs {_TOOLCHAIN_GATE!r}" |
| 238 | + |
| 239 | + missing = [ |
| 240 | + directory |
| 241 | + for directory in directories |
| 242 | + if not (_REPO_ROOT / directory / _TOOLCHAIN_GATE_SCRIPT).is_file() |
| 243 | + ] |
| 244 | + assert missing == [] |
| 245 | + |
| 246 | + |
| 247 | +def test_workflow_token_is_read_only() -> None: |
| 248 | + workflow = _workflow() |
| 249 | + jobs = { |
| 250 | + str(name): _mapping(spec, f"job {name!r}") |
| 251 | + for name, spec in _jobs(workflow).items() |
| 252 | + } |
| 253 | + job_overrides = { |
| 254 | + name: job["permissions"] for name, job in jobs.items() if "permissions" in job |
| 255 | + } |
| 256 | + |
| 257 | + assert (workflow.get("permissions"), job_overrides) == (_READ_ONLY_TOKEN, {}) |
| 258 | + |
| 259 | + |
| 260 | +def test_workflow_never_runs_on_pull_request_target() -> None: |
| 261 | + assert "pull_request_target" not in _trigger_events(_workflow()) |
0 commit comments