diff --git a/.github/actions/wix-gateway-proxy/action.yml b/.github/actions/wix-gateway-proxy/action.yml new file mode 100644 index 0000000..6078a88 --- /dev/null +++ b/.github/actions/wix-gateway-proxy/action.yml @@ -0,0 +1,46 @@ +name: wix gateway proxy +description: >- + Mandatory first step of every job: routes registry.npmjs.org through the + Wix npm embargo gateway by pinning the registry hostname to the gateway IP, + trusting the gateway CA system-wide, and pointing npm at the CA bundle. + Being a local action, the repository must be checked out before it runs. + Enforced for every job by .github/workflows/check-wix-proxy.yml. + +inputs: + proxy-ip: + description: IP address of the Wix npm embargo gateway + required: false + default: "23.21.39.196" + +runs: + using: composite + steps: + - name: Point registry.npmjs.org at the gateway + shell: bash + run: | + echo "${{ inputs.proxy-ip }} registry.npmjs.org" | sudo tee -a /etc/hosts + getent hosts registry.npmjs.org + + - name: Trust the gateway CA + shell: bash + run: | + sudo cp "$GITHUB_ACTION_PATH/../../certs/wix-embargo.pem" /usr/local/share/ca-certificates/wix-embargo.crt + sudo update-ca-certificates + + - name: Trust the gateway CA in Node + # Node ignores the OS trust store; NODE_EXTRA_CA_CERTS covers npm, pnpm, + # corepack, and npx. Never use `npm config set cafile` here — it REPLACES + # the trust bundle and breaks other registries (npm.jsr.io), whereas + # NODE_EXTRA_CA_CERTS appends. The /usr/local/share copy survives later + # checkouts and workspace cleans, unlike the in-workspace pem. + shell: bash + run: echo "NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/wix-embargo.crt" >> "$GITHUB_ENV" + + - name: Trust the gateway CA in Deno + # Deno (rustls) reads neither the OS store nor NODE_EXTRA_CA_CERTS by + # default; "system" points it at the OS store where the gateway CA is + # installed, "mozilla" keeps public roots for every other host. + # NOTE: rustls still rejects the gateway until it serves a CA-signed + # leaf cert instead of the CA cert itself (CaUsedAsEndEntity). + shell: bash + run: echo "DENO_TLS_CA_STORE=system,mozilla" >> "$GITHUB_ENV" diff --git a/.github/certs/wix-embargo.pem b/.github/certs/wix-embargo.pem new file mode 100644 index 0000000..7172e08 --- /dev/null +++ b/.github/certs/wix-embargo.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDzjCCAragAwIBAgIUVpbbERZ/oqaGxJAznohpw7hc/9swDQYJKoZIhvcNAQEL +BQAwZzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCFNlY3VyaXR5MQ0wCwYDVQQHDARN +SVRNMRkwFwYDVQQKDBBOUE0gUG9saWN5IFByb3h5MRswGQYDVQQDDBJyZWdpc3Ry +eS5ucG1qcy5vcmcwHhcNMjYwMTE0MTUwMDQwWhcNMjcwMTE0MTUwMDQwWjBnMQsw +CQYDVQQGEwJVUzERMA8GA1UECAwIU2VjdXJpdHkxDTALBgNVBAcMBE1JVE0xGTAX +BgNVBAoMEE5QTSBQb2xpY3kgUHJveHkxGzAZBgNVBAMMEnJlZ2lzdHJ5Lm5wbWpz +Lm9yZzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKBX6fToCIZ4mSvQ +02/sSVh8xn82DHXW33a5MpVRqMTNa9r7S5TeCLJTmdfJQYi1GYnjRjgIiUK7UwBn +cmyV4fK9PACGsdzmstbGsBQNBmLrCNpFIPG96EmE7u93CyZgVQzzoM0WxTkKmCFR +e4WbhsY2DGSvOQwa/Bj9rusEb3WwHvbzDLfF2BOfiauizHl6dVdRcj8xmN748Kxp +H2AZ3Q3sTe3sfeJVkFKbQ61JBE8YYLe5PtXwZGsumT6K6MjRtlFeXK59D9pXJKmC +rS3zVsExx/NaZmqXUBx/ikdStBbTNEqAkuEHXMArAZYMXXCEbPChCkn/pDCvBofV +S/WAvnUCAwEAAaNyMHAwHQYDVR0OBBYEFG9FHU6u08PGgzDhY3X6RSnUXz1OMB8G +A1UdIwQYMBaAFG9FHU6u08PGgzDhY3X6RSnUXz1OMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0RBBYwFIIScmVnaXN0cnkubnBtanMub3JnMA0GCSqGSIb3DQEBCwUAA4IB +AQAG6u7BXGfLe+VttUVZYbqOV3uW0skwAsjK6wcbwW7WEKk0k5oJRCbRGYzSe7hc +fRXkezENYfgsWJOXhOrIm89F2e0dbJTNmefFzS+56RRllHmBEQxxI2f446qBx4w0 +/N6QqpE1QxzuvAJDs/wki3CMsnz5Eu3IdM/1Els6Ap794xBXJAwgh7fNa0V5NMLT +hknLtKy6nu1nfoyfZ9fTLr8IKuEfB6vYJ00FDVyVmWGfXam5yAfL8uhCQcBd1AI9 +pRHzoBDRNqIWaRF2lWQCWV4pt132oxln0n3iG/TSodbAXp3WFJaKQ0HwXSb5NXjI +nbDI4K0FXvrAhfXRomBSQ3WM +-----END CERTIFICATE----- \ No newline at end of file diff --git a/.github/scripts/check_wix_proxy_steps.py b/.github/scripts/check_wix_proxy_steps.py new file mode 100644 index 0000000..8110a74 --- /dev/null +++ b/.github/scripts/check_wix_proxy_steps.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Fail if any GitHub Actions job skips the mandatory Wix gateway proxy action. + +There is no opt-out marker by design. A job that genuinely cannot run the proxy +(a non-ubuntu runner, say) changes this script in the same PR, so the exception +gets reviewed in the open. +""" + +from __future__ import annotations + +import pathlib +import sys + +import yaml + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +PROXY_ACTION = "./.github/actions/wix-gateway-proxy" +# The action copies .github/certs/wix-embargo.pem via a path relative to itself, +# so a sparse checkout has to materialize both directories. +REQUIRED_PATHS = (".github/actions/wix-gateway-proxy", ".github/certs") + +FIX_HINT = """Every job must run the Wix gateway proxy immediately after a checkout that +puts it on disk, or that job's npm installs bypass the Wix embargo gateway. + +Job that already checks out this repo first: + + - uses: actions/checkout@v7 + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + +Job with no leading same-repo checkout -- prepend a bootstrap one. The action +installs the CA under /usr/local/share, so a later full checkout does not undo it: + + - name: Checkout for wix gateway proxy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + sparse-checkout: .github + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + +Non-cone sparse checkout -- list both paths explicitly: + + with: + sparse-checkout: | + + .github/actions/wix-gateway-proxy + .github/certs + sparse-checkout-cone-mode: false +""" + + +def _uses(step: dict) -> str: + return str(step.get("uses", "")).strip().rstrip("/") + + +def _covers(pattern: str, path: str) -> bool: + return path == pattern or path.startswith(pattern + "/") + + +def _overlaps(pattern: str, path: str) -> bool: + # An exclusion breaks the action whether it removes the whole directory or a + # single file inside it, so overlap in either direction disqualifies. + return _covers(pattern, path) or _covers(path, pattern) + + +def _sparse_covers_action(patterns: str) -> bool: + listed = [p.strip().rstrip("/") for p in patterns.splitlines() if p.strip()] + included = [p for p in listed if not p.startswith("!")] + excluded = [p[1:] for p in listed if p.startswith("!")] + return all( + any(_covers(p, required) for p in included) + and not any(_overlaps(p, required) for p in excluded) + for required in REQUIRED_PATHS + ) + + +def _checkout_problem(step: dict) -> str | None: + if not _uses(step).startswith("actions/checkout"): + return f'is preceded by "{_uses(step) or "a run step"}" instead of a checkout' + with_ = step.get("with") or {} + if with_.get("repository"): + return f'is preceded by a checkout of {with_["repository"]}' + if with_.get("path"): + return f'is preceded by a checkout into {with_["path"]}/, not the workspace root' + patterns = with_.get("sparse-checkout") + if patterns and not _sparse_covers_action(str(patterns)): + return "is preceded by a sparse checkout that omits " + " or ".join(REQUIRED_PATHS) + return None + + +def job_problem(job: dict, workflows: frozenset[str]) -> str | None: + """Describe why this job fails to run the proxy, or None if it runs it correctly.""" + if "uses" in job: + target = str(job["uses"]).strip() + if target.removeprefix("./") in workflows: + return None + return f"calls {target}, whose jobs this check cannot verify" + + steps = job.get("steps") or [] + index = next((i for i, s in enumerate(steps) if _uses(s) == PROXY_ACTION), None) + if index is None: + return "does not run the Wix gateway proxy" + if index == 0: + return "runs the Wix gateway proxy first, but a local action needs a checkout before it" + if index > 1: + return f"runs the Wix gateway proxy at step {index + 1}; it must be step 2" + step = steps[index] + # Presence, not truthiness: `if: false` parses to False and would skip the step. + if "if" in step: + return "guards the Wix gateway proxy behind an if:, but it is mandatory" + if step.get("continue-on-error"): + return "lets the Wix gateway proxy fail via continue-on-error, but it is mandatory" + with_ = step.get("with") or {} + if "proxy-ip" in with_ and not str(with_["proxy-ip"]).strip(): + return "passes an empty proxy-ip, leaving registry.npmjs.org resolving publicly" + return _checkout_problem(steps[0]) + + +def _job_lines(text: str) -> dict[str, int]: + document = yaml.compose(text) + if document is None: + return {} + for key, value in document.value: + if key.value == "jobs": + return {job.value: job.start_mark.line + 1 for job, _ in value.value} + return {} + + +def main(repo_root: pathlib.Path = REPO_ROOT) -> int: + workflows_dir = repo_root / ".github" / "workflows" + paths = sorted(p for p in workflows_dir.iterdir() if p.suffix in (".yml", ".yaml")) + workflows = frozenset(p.relative_to(repo_root).as_posix() for p in paths) + problems = [] + jobs = calls = 0 + + for path in paths: + text = path.read_text(encoding="utf-8") + lines = _job_lines(text) + for job_id, job in ((yaml.safe_load(text) or {}).get("jobs") or {}).items(): + job = job or {} + jobs += 1 + calls += "uses" in job + problem = job_problem(job, workflows) + if problem: + problems.append((path.relative_to(repo_root), lines[job_id], job_id, problem)) + + for path, line, job_id, problem in problems: + print(f'::error file={path},line={line}::Job "{job_id}" {problem}.') + + if problems: + print(f"\n{len(problems)} job(s) skip the Wix gateway proxy.\n") + print(FIX_HINT) + return 1 + + print( + f"Wix gateway proxy: verified {jobs - calls} of {jobs} jobs across " + f"{len(paths)} workflows ({calls} reusable-workflow calls delegate to " + f"the workflow they call)." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/requirements-check-wix-proxy.txt b/.github/scripts/requirements-check-wix-proxy.txt new file mode 100644 index 0000000..8efbe23 --- /dev/null +++ b/.github/scripts/requirements-check-wix-proxy.txt @@ -0,0 +1,78 @@ +# PyYAML for .github/scripts/check_wix_proxy_steps.py, hash-pinned so the +# supply-chain guard does not itself install an unverified package. +# PyYAML has no dependencies; hashes are PyPI's published sha256 set for +# pyyaml 6.0.3, ported verbatim from base44-dev/vite-plugin. +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 diff --git a/.github/scripts/test_check_wix_proxy_steps.py b/.github/scripts/test_check_wix_proxy_steps.py new file mode 100644 index 0000000..a407a0f --- /dev/null +++ b/.github/scripts/test_check_wix_proxy_steps.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +import contextlib +import io +import pathlib +import re +import tempfile +import textwrap +import unittest + +import yaml + +import check_wix_proxy_steps as checker + +COMPLIANT_WORKFLOW = """\ +name: Good +on: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy +""" + +WORKFLOWS = frozenset({".github/workflows/pr-agent-scope.yml"}) + +PROXY_STEP = """\ +- name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy +""" + +COMPLIANT = { + "plain checkout then proxy": """ + steps: + - uses: actions/checkout@v7 + + """, + "pinned-sha checkout then proxy": """ + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + """, + "cone sparse checkout of .github": """ + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: .github + + """, + "cone sparse checkout listing both paths": """ + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/actions/publish-pr-insight + .github/actions/wix-gateway-proxy + .github/certs + + """, + "non-cone sparse checkout listing both paths": """ + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/scripts/forward_pr_comment.py + .github/actions/wix-gateway-proxy + .github/certs + sparse-checkout-cone-mode: false + + """, + "job-level if is fine": """ + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v7 + + """, + "local reusable workflow call": """ + uses: ./.github/workflows/pr-agent-scope.yml + """, +} + +VIOLATIONS = { + "no proxy step at all": (""" + steps: + - uses: actions/checkout@v7 + - run: npm ci + """, "does not run the Wix gateway proxy"), + + "proxy as the very first step": (""" + steps: + + - uses: actions/checkout@v7 + """, "needs a checkout before it"), + + "proxy after an unrelated step": (""" + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + + """, "at step 3"), + + "proxy behind an if": (""" + steps: + - uses: actions/checkout@v7 + - name: Wix gateway proxy (mandatory) + if: github.event_name == 'push' + uses: ./.github/actions/wix-gateway-proxy + """, "behind an if:"), + + "proxy behind a falsy if that skips the step": (""" + steps: + - uses: actions/checkout@v7 + - name: Wix gateway proxy (mandatory) + if: false + uses: ./.github/actions/wix-gateway-proxy + """, "behind an if:"), + + "proxy behind a null if": (""" + steps: + - uses: actions/checkout@v7 + - name: Wix gateway proxy (mandatory) + if: null + uses: ./.github/actions/wix-gateway-proxy + """, "behind an if:"), + + "proxy allowed to fail": (""" + steps: + - uses: actions/checkout@v7 + - name: Wix gateway proxy (mandatory) + continue-on-error: true + uses: ./.github/actions/wix-gateway-proxy + """, "continue-on-error"), + + "proxy given an empty proxy-ip": (""" + steps: + - uses: actions/checkout@v7 + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + with: + proxy-ip: '' + """, "empty proxy-ip"), + + "preceded by a run step": (""" + steps: + - run: echo hello + + """, "instead of a checkout"), + + "checkout into a subdirectory": (""" + steps: + - uses: actions/checkout@v7 + with: + path: repo + + """, "checkout into repo/"), + + "checkout of another repository": (""" + steps: + - uses: actions/checkout@v7 + with: + repository: wix-private/base44-mobile + + """, "checkout of wix-private/base44-mobile"), + + "non-cone sparse checkout omitting the certs": (""" + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/actions/wix-gateway-proxy + sparse-checkout-cone-mode: false + + """, "omits"), + + "cone sparse checkout of an unrelated directory": (""" + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: .github/scripts + + """, "omits"), + + "sparse checkout negating the certs directory": (""" + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github + !.github/certs + sparse-checkout-cone-mode: false + + """, "omits"), + + "sparse checkout negating the cert file inside the directory": (""" + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github + !.github/certs/wix-embargo.pem + sparse-checkout-cone-mode: false + + """, "omits"), + + "external reusable workflow call": (""" + uses: wix-private/shared/.github/workflows/build.yml@main + """, "cannot verify"), + + "reusable call to a workflow that does not exist": (""" + uses: ./.github/workflows/pr-agent-scoep.yml + """, "cannot verify"), +} + + +def job(source: str) -> dict: + expanded = re.sub( + r"^( *)$", + lambda match: textwrap.indent(PROXY_STEP, match.group(1)), + textwrap.dedent(source), + flags=re.MULTILINE, + ) + return yaml.safe_load(expanded) + + +class JobProblemTests(unittest.TestCase): + def test_compliant_jobs_pass(self): + for label, source in COMPLIANT.items(): + with self.subTest(job=label): + self.assertIsNone(checker.job_problem(job(source), WORKFLOWS)) + + def test_violations_are_reported(self): + for label, (source, expected) in VIOLATIONS.items(): + with self.subTest(job=label): + problem = checker.job_problem(job(source), WORKFLOWS) + self.assertIsNotNone(problem, f"{label} should have been rejected") + self.assertIn(expected, problem) + + +class JobLineTests(unittest.TestCase): + def test_job_ids_map_to_their_line_numbers(self): + text = textwrap.dedent("""\ + name: Example + on: + pull_request: + + jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo build + deploy: + runs-on: ubuntu-latest + steps: + - run: echo deploy + """) + + self.assertEqual(checker._job_lines(text), {"build": 6, "deploy": 10}) + + +@contextlib.contextmanager +def fixture_repo(**workflows: str): + """A throwaway repo root containing just .github/workflows/.""" + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + directory = root / ".github" / "workflows" + directory.mkdir(parents=True) + for name, content in workflows.items(): + (directory / f"{name}.yml").write_text(content, encoding="utf-8") + yield root + + +def run_main(root: pathlib.Path) -> tuple[int, str]: + stdout = io.StringIO() + with contextlib.redirect_stdout(stdout): + code = checker.main(root) + return code, stdout.getvalue() + + +class MainTests(unittest.TestCase): + def test_offending_job_is_annotated_at_its_own_line(self): + bad = textwrap.dedent("""\ + name: Bad + on: + pull_request: + + jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - run: npm ci + """) + + with fixture_repo(bad=bad) as root: + code, output = run_main(root) + + self.assertEqual(code, 1) + self.assertIn( + '::error file=.github/workflows/bad.yml,line=6::' + 'Job "build" does not run the Wix gateway proxy.', + output, + ) + self.assertIn("sparse-checkout: .github", output) + + def test_compliant_workflow_passes(self): + with fixture_repo(good=COMPLIANT_WORKFLOW) as root: + code, output = run_main(root) + + self.assertEqual(code, 0) + self.assertIn("verified 1 of 1 jobs", output) + + def test_empty_and_comment_only_workflows_are_skipped(self): + with fixture_repo( + good=COMPLIANT_WORKFLOW, + empty="", + commented="# workflow temporarily disabled\n", + ) as root: + code, output = run_main(root) + + self.assertEqual(code, 0) + self.assertIn("verified 1 of 1 jobs", output) + + def test_job_with_no_body_is_reported_as_missing_the_proxy(self): + with fixture_repo(stub="name: Stub\non:\n pull_request:\n\njobs:\n build:\n") as root: + code, output = run_main(root) + + self.assertEqual(code, 1) + self.assertIn('Job "build" does not run the Wix gateway proxy.', output) + + +class RepositoryTests(unittest.TestCase): + def test_every_job_in_this_repository_runs_the_proxy(self): + self.assertEqual(checker.main(), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/check-wix-proxy.yml b/.github/workflows/check-wix-proxy.yml new file mode 100644 index 0000000..7321799 --- /dev/null +++ b/.github/workflows/check-wix-proxy.yml @@ -0,0 +1,37 @@ +name: Check Wix Gateway Proxy + +# No paths or branches filter: this is meant to be a required status check, and +# a filtered workflow leaves unrelated PRs pending forever. +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + check-wix-proxy: + name: Wix Gateway Proxy + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install PyYAML + run: pip install --require-hashes -r .github/scripts/requirements-check-wix-proxy.txt + + - name: Test the proxy-step checker + run: python3 .github/scripts/test_check_wix_proxy_steps.py + + - name: Check every job runs the Wix gateway proxy + run: python3 .github/scripts/check_wix_proxy_steps.py diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd..613a383 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -31,6 +31,9 @@ jobs: with: fetch-depth: 1 + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + - name: Run Claude Code Review id: claude-review uses: anthropics/claude-code-action@v1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index d300267..c415251 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -30,6 +30,9 @@ jobs: with: fetch-depth: 1 + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + - name: Run Claude Code id: claude uses: anthropics/claude-code-action@v1 diff --git a/.github/workflows/notify-slack-skill-sync.yml b/.github/workflows/notify-slack-skill-sync.yml index 43ba011..607334b 100644 --- a/.github/workflows/notify-slack-skill-sync.yml +++ b/.github/workflows/notify-slack-skill-sync.yml @@ -27,6 +27,14 @@ jobs: github.event.action == 'labeled' && github.event.label.name == 'auto-skill-update' steps: + - name: Checkout for wix gateway proxy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + sparse-checkout: .github + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + - name: Notify Slack env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/readme-check.yml b/.github/workflows/readme-check.yml index 27fb7f5..06de99b 100644 --- a/.github/workflows/readme-check.yml +++ b/.github/workflows/readme-check.yml @@ -25,6 +25,9 @@ jobs: fetch-depth: 1 token: ${{ secrets.GITHUB_TOKEN }} + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + - name: Run Claude README Check id: claude-readme-check uses: anthropics/claude-code-action@v1 diff --git a/.github/workflows/sync-cli-skill.yml b/.github/workflows/sync-cli-skill.yml index 4e308f1..ee7a45c 100644 --- a/.github/workflows/sync-cli-skill.yml +++ b/.github/workflows/sync-cli-skill.yml @@ -27,6 +27,9 @@ jobs: - name: Checkout skills repo uses: actions/checkout@v4 + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + - name: Checkout CLI repo uses: actions/checkout@v4 with: diff --git a/.github/workflows/sync-sdk-skill.yml b/.github/workflows/sync-sdk-skill.yml index 61ebe4b..cd080ef 100644 --- a/.github/workflows/sync-sdk-skill.yml +++ b/.github/workflows/sync-sdk-skill.yml @@ -27,6 +27,9 @@ jobs: - name: Checkout skills repo uses: actions/checkout@v4 + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + - name: Checkout SDK repo uses: actions/checkout@v4 with: diff --git a/.github/workflows/wix-gateway-proxy-check.yml b/.github/workflows/wix-gateway-proxy-check.yml new file mode 100644 index 0000000..8aa8207 --- /dev/null +++ b/.github/workflows/wix-gateway-proxy-check.yml @@ -0,0 +1,31 @@ +name: wix gateway proxy check + +# Dummy connectivity check for the Wix npm embargo gateway: runs the +# wix-gateway-proxy action, then curls the registry so the request resolves +# through the gateway and prints the result. +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: wix-gateway-proxy-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + proxy-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Wix gateway proxy (mandatory) + uses: ./.github/actions/wix-gateway-proxy + + - name: Connection check through the gateway + # -v prints the negotiated chain, which is the actual signal here: the + # subject/issuer must be the embargo proxy, not npm's real Cloudflare cert. + run: curl -vI https://registry.npmjs.org/react + + - name: NPM install for verification + run: npm install mermaid-rs-wasm@0.0.3