diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index 8198011206b..644d2fd9362 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -40,6 +40,7 @@ on: - release-winget - rpm-repo - Smoke + - Smoke toolchain update - TS - Video types: @@ -65,7 +66,7 @@ jobs: github.event.workflow_run.event != 'pull_request_target' && github.event.workflow_run.event != 'merge_group' && (github.event.workflow_run.event == 'schedule' || - !contains(fromJSON('["Interop", "Nightly", "Smoke", "TS", "Video"]'), github.event.workflow_run.name)) + !contains(fromJSON('["Interop", "Nightly", "Smoke", "Smoke toolchain update", "TS", "Video"]'), github.event.workflow_run.name)) runs-on: ubuntu-latest steps: diff --git a/.github/workflows/dependabot.yml b/.github/workflows/dependabot.yml index 48afe48d426..ba658733d32 100644 --- a/.github/workflows/dependabot.yml +++ b/.github/workflows/dependabot.yml @@ -10,7 +10,7 @@ name: Dependabot # read-only token and no access to secrets, so a `pull_request` run could # neither read APP_PRIVATE_KEY nor enable auto-merge. That trigger is only safe # because this job never checks out or executes the pull request's code; it -# calls one `gh` command against the pull request number. +# queries the pull request files and enables auto-merge through `gh`. # # The moq-bot token rather than GITHUB_TOKEN: a merge enabled by GITHUB_TOKEN # does not trigger further workflow runs, which would silently skip @@ -25,6 +25,8 @@ on: jobs: auto-merge: name: Auto-merge + permissions: + pull-requests: read # head.repo guards the `pull_request_target` trigger: Dependabot pushes its # branches to this repository, so a pull request from a fork claiming to be # Dependabot is not one. @@ -34,6 +36,18 @@ jobs: runs-on: ubuntu-latest steps: + # Smoke's flake and npm Playwright pin move together through its updater. + # A lock-only Dependabot PR is discovery, not a change safe to auto-enqueue. + - name: Require a coupled smoke update + env: + GH_TOKEN: ${{ github.token }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: >- + gh pr view "$PR_URL" --json files --jq + 'if any(.files[]; .path == "test/smoke/flake.lock") then + error("Use the Smoke toolchain update workflow for the coupled lock and Playwright bump") + else "Update can auto-merge" end' + - name: Generate moq-bot token uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3 id: token diff --git a/.github/workflows/smoke-toolchain.yml b/.github/workflows/smoke-toolchain.yml new file mode 100644 index 00000000000..e43c7955861 --- /dev/null +++ b/.github/workflows/smoke-toolchain.yml @@ -0,0 +1,73 @@ +name: Smoke toolchain update + +on: + workflow_dispatch: + schedule: + - cron: "0 9 * * 1" + +permissions: + contents: read + +concurrency: + group: smoke-toolchain-update + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + persist-credentials: false + - uses: ./.github/actions/nix + - uses: ./.github/actions/just + - name: Update the coupled pins + run: just --justfile test/smoke/justfile update-toolchain + - name: Check the updated browser toolchain + run: just --justfile test/smoke/justfile check-toolchain + # Use the existing app identity to publish the validated draft proposal. + # Mint it after evaluating nixpkgs so the update itself holds no write token. + - name: Generate moq-bot token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3 + id: token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: moq-dev + repositories: moq + permission-contents: write + permission-pull-requests: write + - name: Propose the update + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.token.outputs.token }} + branch: smoke/toolchain-update + base: main + draft: always-true + add-paths: | + test/smoke/flake.lock + test/smoke/clients/js/package.json + commit-message: "chore(smoke): refresh the toolchain and matching Playwright pin" + title: "chore(smoke): refresh the toolchain and matching Playwright pin" + body: | + ## Problem + + The published-package smoke clients need a current toolchain whose Chromium matches npm Playwright. + + ## Approach + + Refresh smoke's flake lock and derive its exact npm Playwright pin from PLAYWRIGHT_VERSION in the same change. The existing browser smoke lane consumes both pins and validates freshness. + + ## Impact + + - Public API and wire: none. Only the smoke test toolchain changes. + + ## Alternatives + + A lock-only update leaves the npm pin behind and fails freshness. + + ## Follow-ups + + The updater validated freshness and launched the updated browser. Dispatch Smoke on this branch to run the full published-package matrix before marking ready. This workflow does not enqueue or merge updates. diff --git a/quest/m1/README.md b/quest/m1/README.md index 2c7ec96538c..5fec6936185 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -114,7 +114,6 @@ lands before the cut. - [Binding check cost](/quest/m1/binding-check-cost.md) - `moq.cpp` compiles once per C++ standard and Gradle stays warm, measured before and after - [Audio devices on ARM](/quest/m1/audio-devices-arm.md) - `just rs audio-devices` passes on the hosted ARM runner - [CI housekeeping](/quest/m1/ci-housekeeping.md) - queue nextest at 24 threads, stale Pages artifacts cleared, the actionlint ignore dropped -- [Smoke flake bumps](/quest/m1/smoke-dependabot.md) - `test/smoke/flake.lock` bumps with its Playwright pin, automated or documented - [Playwright browsers from Nix](/quest/m1/playwright-nix.md) - every Playwright check runs on Nix's Chromium locally and in CI, with npm pinned to match diff --git a/quest/m1/smoke-dependabot.md b/quest/m1/smoke-dependabot.md deleted file mode 100644 index a47162e60a7..00000000000 --- a/quest/m1/smoke-dependabot.md +++ /dev/null @@ -1,27 +0,0 @@ -# [S] Smoke's flake bumps with its Playwright pin - -## Goal - -`test/smoke/flake.lock` stays current without a hand-made bump, and every -bump moves the npm `playwright` pin to the flake's `PLAYWRIGHT_VERSION` in -the same PR, so `just freshness` passes. If automating the pin costs more -than it saves, the coupled bump is documented where a maintainer will find -it when the update is due. - -## Plan - -From [moq-dev/moq#6169](https://github.com/moq-dev/moq/pull/6169). -Dependabot's nix ecosystem on main covers only `/`. #6169 gives the smoke -flake its own entry, but Dependabot updates only the lock: `freshness.sh` -then fails because the npm pin did not move. `test/smoke/README.md` already -says to bump both together by hand. - -- Prefer automation: a workflow that follows a Dependabot smoke-flake PR - and pushes the matching pin, or one scheduled job that bumps both. -- Whichever lands, a stale smoke flake must be visible, not silent. - -Public API: none. Wire: none. - -## Related - -- [Playwright from nix](https://github.com/moq-dev/moq/pull/6116) - plans the Playwright pin this bump must follow diff --git a/test/smoke/README.md b/test/smoke/README.md index a24b77b2cf7..c06491401e5 100644 --- a/test/smoke/README.md +++ b/test/smoke/README.md @@ -60,6 +60,8 @@ just check # shfmt, shellcheck, actionlint, freshness Packages resolve to latest on every run: `@moq/*` on the `latest` tag, PyPI `moq-rs`, `moq.dev/moq`, `moqdev/*` images, and GitHub release tarballs. `cloudflare.sh` and `moxygen.sh` build from unpinned Git HEAD. `test/smoke/` commits no package lock files. `flake.lock` pins the dev toolchain. -npm `playwright` is pinned to the flake's `PLAYWRIGHT_VERSION`. `just freshness` fails when that pin drifts or a lock file is committed. Bump the toolchain with `nix flake update` and the pin together. +npm `playwright` is pinned to the flake's `PLAYWRIGHT_VERSION`. `just freshness` fails when that pin drifts or a lock file is committed. The weekly [Smoke toolchain update](../../.github/workflows/smoke-toolchain.yml) workflow opens or refreshes one draft PR containing both updates; failures go through the existing alerts workflow. Dispatch that workflow when a smoke-flake Dependabot PR is due, and use the coupled PR instead of merging a lock-only update. Dependabot auto-merge refuses smoke-flake updates so a lock-only proposal cannot land ahead of its matching pin. + +For a local update, run `just update-toolchain` here (Nix and Python are required), then `just freshness` inside the refreshed smoke shell. The command updates `flake.lock`, evaluates its `PLAYWRIGHT_VERSION` without downloading the toolchains, and writes the matching npm pin. Commit both files together. `just check-toolchain` validates freshness and launches the updated browser in the smoke shell. The scheduled updater runs it before proposing a PR; dispatch Smoke on that branch for the full published-package matrix. A broken package fails only its own cells. diff --git a/test/smoke/clients/js/package.json b/test/smoke/clients/js/package.json index 699b3da1621..853d818641b 100644 --- a/test/smoke/clients/js/package.json +++ b/test/smoke/clients/js/package.json @@ -8,7 +8,7 @@ }, "devDependencies": { "esbuild": "^0.28.1", - "playwright": "1.59.1", + "playwright": "1.64.1", "vite": "^8.1.5" } } diff --git a/test/smoke/flake.lock b/test/smoke/flake.lock index 0780c74c627..302ca2375c1 100644 --- a/test/smoke/flake.lock +++ b/test/smoke/flake.lock @@ -5,11 +5,11 @@ "systems": "systems" }, "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "lastModified": 1791914995, + "narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=", "owner": "numtide", "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1779877693, - "narHash": "sha256-NOF9NAREhxr50bbBfVcVOq+ArCMSoe8dP79Pk2uyARk=", + "lastModified": 1792506237, + "narHash": "sha256-Q/8KLB6jNfpaUN1HAedE/u/Et9feE/zYSsutBLKrCFk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4100e830e085863741bc69b156ec4ccd53ab5be0", + "rev": "5adc50831c259f87faeb09ddf930d43597197473", "type": "github" }, "original": { diff --git a/test/smoke/freshness.sh b/test/smoke/freshness.sh index c5580a0dfd0..3c5e881d2a0 100755 --- a/test/smoke/freshness.sh +++ b/test/smoke/freshness.sh @@ -169,7 +169,7 @@ elif [[ -n "${PLAYWRIGHT_VERSION:-}" ]]; then if [[ "$pin" == "$PLAYWRIGHT_VERSION" ]]; then note ok "playwright $pin == toolchain $PLAYWRIGHT_VERSION" else - note FAIL "playwright pinned to $pin but the toolchain ships $PLAYWRIGHT_VERSION; bump $PKG" + note FAIL "playwright pinned to $pin but the toolchain ships $PLAYWRIGHT_VERSION; run just update-toolchain, then re-enter the smoke shell" fail=1 fi else diff --git a/test/smoke/justfile b/test/smoke/justfile index 8554f6985ba..2facc8da2de 100644 --- a/test/smoke/justfile +++ b/test/smoke/justfile @@ -95,6 +95,7 @@ negative *args: # Assert we pin nothing stale: no committed lock files, and any version we are # forced to pin (npm playwright) matches what the toolchain provides. freshness: + python3 update.test.py ./freshness.sh # moq's root `just check` lints these scripts too. shfmt discovers every shell @@ -122,3 +123,13 @@ playwright-deps: cloudflare-check: cargo update --manifest-path clients/cloudflare/Cargo.toml cargo test --manifest-path clients/cloudflare/Cargo.toml + +# Refresh the toolchain lock and the exact npm Playwright pin in the same change. +update-toolchain: + python3 update.py + +# Verify a proposed toolchain with its own version and a real browser launch. +check-toolchain: + nix develop --command just freshness + cd clients/js && nix develop --command bun install + cd clients/js && nix develop --command bun -e 'import {chromium} from "playwright"; const browser = await chromium.launch({channel: "chromium", headless: true}); await browser.close();' diff --git a/test/smoke/update.py b/test/smoke/update.py new file mode 100644 index 00000000000..f05078fcac5 --- /dev/null +++ b/test/smoke/update.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Update smoke's Nix toolchain and its matching npm Playwright pin together.""" + +import json +from pathlib import Path +import re +import subprocess + + +def main(): + smoke = Path(__file__).resolve().parent + subprocess.run(["nix", "flake", "update"], cwd=smoke, check=True) + system = subprocess.check_output( + ["nix", "eval", "--impure", "--raw", "--expr", "builtins.currentSystem"], text=True + ).strip() + # Evaluate the hook instead of entering the shell: updating a version must not + # download every client toolchain or run the refreshed shellHook. + hook = subprocess.check_output( + ["nix", "eval", "--raw", f".#devShells.{system}.default.shellHook"], cwd=smoke, text=True + ) + versions = re.findall(r'^export PLAYWRIGHT_VERSION="([0-9]+\.[0-9]+\.[0-9]+)"$', hook, re.MULTILINE) + if len(versions) != 1: + raise ValueError("smoke's shellHook must export one exact stable PLAYWRIGHT_VERSION") + version = versions[0] + manifest = smoke / "clients/js/package.json" + package = json.loads(manifest.read_text()) + package["devDependencies"]["playwright"] = version + manifest.write_text(json.dumps(package, indent="\t", ensure_ascii=False) + "\n") + print(f"smoke toolchain updated with playwright {version}") + + +if __name__ == "__main__": + main() diff --git a/test/smoke/update.test.py b/test/smoke/update.test.py new file mode 100644 index 00000000000..4a3efcc197f --- /dev/null +++ b/test/smoke/update.test.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Exercise coupled updates without downloading nixpkgs or a browser.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +class UpdateTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + shutil.copy(Path(__file__).with_name("update.py"), self.root) + (self.root / "clients/js").mkdir(parents=True) + self.manifest = self.root / "clients/js/package.json" + self.package = {"dependencies": {"@moq/watch": "latest"}, "devDependencies": {"playwright": "1.0.0"}} + self.manifest.write_text(json.dumps(self.package)) + (self.root / "flake.lock").write_text("old lock") + fake = self.root / "nix" + fake.write_text("""#!/usr/bin/env python3 +import os +from pathlib import Path +import sys +if sys.argv[1:] == ["flake", "update"]: + if os.environ.get("FAIL_UPDATE"): + sys.exit(1) + Path("flake.lock").write_text("new lock") +elif sys.argv[1:] == ["eval", "--impure", "--raw", "--expr", "builtins.currentSystem"]: + print("aarch64-linux") +elif sys.argv[1:] == ["eval", "--raw", ".#devShells.aarch64-linux.default.shellHook"]: + print(os.environ["HOOK"]) +else: + sys.exit(2) +""") + fake.chmod(0o755) + self.env = {**os.environ, "PATH": f"{self.root}:{os.environ['PATH']}"} + + def run_update(self, hook, **env): + return subprocess.run( + ["python3", str(self.root / "update.py")], + env={**self.env, "HOOK": hook, **env}, text=True, capture_output=True + ) + + def test_updates_both_files_and_preserves_latest_moq(self): + result = self.run_update('export PLAYWRIGHT_VERSION="1.65.2"') + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((self.root / "flake.lock").read_text(), "new lock") + self.package["devDependencies"]["playwright"] = "1.65.2" + self.assertEqual(json.loads(self.manifest.read_text()), self.package) + + def test_refuses_missing_ambiguous_or_unstable_version(self): + for hook in ["", 'export PLAYWRIGHT_VERSION="1.65.2-beta"', + 'export PLAYWRIGHT_VERSION="1.65.2"\nexport PLAYWRIGHT_VERSION="1.65.3"']: + with self.subTest(hook=hook): + result = self.run_update(hook) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(json.loads(self.manifest.read_text()), self.package) + + def test_failed_lock_update_does_not_move_pin(self): + result = self.run_update('export PLAYWRIGHT_VERSION="1.65.2"', FAIL_UPDATE="1") + self.assertNotEqual(result.returncode, 0) + self.assertEqual((self.root / "flake.lock").read_text(), "old lock") + self.assertEqual(json.loads(self.manifest.read_text()), self.package) + + +if __name__ == "__main__": + unittest.main()