diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f01242..369ec85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,8 @@ permissions: concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Finish a main run's Pages deployment before starting its successor. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: ubuntu-checks: @@ -210,17 +211,17 @@ jobs: target/debug/deps target/debug/build target/debug/.fingerprint - target/web/deps - target/web/build - target/web/.fingerprint + target/release/deps + target/release/build + target/release/.fingerprint target/wasm32-unknown-unknown/debug/deps target/wasm32-unknown-unknown/debug/build target/wasm32-unknown-unknown/debug/.fingerprint - target/wasm32-unknown-unknown/web/deps - target/wasm32-unknown-unknown/web/build - target/wasm32-unknown-unknown/web/.fingerprint + target/wasm32-unknown-unknown/release/deps + target/wasm32-unknown-unknown/release/build + target/wasm32-unknown-unknown/release/.fingerprint target/.rustc_info.json - key: n3-v1-wasm-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock', 'vendor/**') }} + key: n3-v2-wasm-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock', 'vendor/**') }} - name: Restore pinned browser build tools id: tools uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -239,10 +240,18 @@ jobs: just web-setup # Compile and generate glue on every run. The native lanes continue to # own behavioral tests and exact guide receipts; this job needs no GPU. - - name: Lint the WASM target and generate the browser application - run: just web-verify + - name: Test and lint WASM, then build the optimized browser application + run: | + just web-test + just web-lint + just web-build --profile release - name: Check the optional WASM measurement adapter run: just benchmark-web-check + - name: Upload the production site for GitHub Pages + if: github.repository == 'gridaco/n3' && github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: build/web - name: Save verified WASM dependencies and build output if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.dependencies.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -253,15 +262,15 @@ jobs: target/debug/deps target/debug/build target/debug/.fingerprint - target/web/deps - target/web/build - target/web/.fingerprint + target/release/deps + target/release/build + target/release/.fingerprint target/wasm32-unknown-unknown/debug/deps target/wasm32-unknown-unknown/debug/build target/wasm32-unknown-unknown/debug/.fingerprint - target/wasm32-unknown-unknown/web/deps - target/wasm32-unknown-unknown/web/build - target/wasm32-unknown-unknown/web/.fingerprint + target/wasm32-unknown-unknown/release/deps + target/wasm32-unknown-unknown/release/build + target/wasm32-unknown-unknown/release/.fingerprint target/.rustc_info.json key: ${{ steps.dependencies.outputs.cache-primary-key }} - name: Save verified browser build tools @@ -276,3 +285,23 @@ jobs: .cache/web-tools/.crates.toml .cache/web-tools/.crates2.json key: ${{ steps.tools.outputs.cache-primary-key }} + + pages: + name: Publish web app to GitHub Pages + if: github.repository == 'gridaco/n3' && github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [ubuntu-checks, ubuntu-guide, macos-native, wasm] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Read the configured Pages site + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Deploy the verified site artifact + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/README.md b/README.md index db43ab1..20f0f2f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ N3 is open source, macOS-first, and in early development. ![N3 workspace with Layers, a 3D viewport, and live Properties](docs/guide/assets/workspace-layout.webp) +[Open web app](https://gridaco.github.io/n3/) · [User guide](docs/guide/README.md) · [Animated walkthroughs](docs/guide/gizmo.md) · [Contributing](CONTRIBUTING.md) @@ -55,6 +56,10 @@ generated from the application, with visible cursor, key, and gesture cues. ## Try N3 +[Open N3 in your browser](https://gridaco.github.io/n3/). Use a desktop browser +with WebGPU available. The web app follows verified `main` builds and shares the +editor UI and guide; native-only capabilities such as the shell are omitted. + Run N3 from source on macOS. Install Rust through `rustup`, `just` (verified with 1.46.0), and the macOS command-line developer tools. The repository selects Rust 1.98.1 through `rust-toolchain.toml`. From the repository root: diff --git a/docs/development.md b/docs/development.md index d2c10a8..d890781 100644 --- a/docs/development.md +++ b/docs/development.md @@ -95,21 +95,24 @@ just web-setup just web-check just web-verify just web-build +just web-build --profile release just web ``` `web-setup` installs the `wasm32-unknown-unknown` target for the repository's Rust toolchain and the matching `wasm-bindgen-cli` version, 0.2.129, under `.cache/web-tools`. `web-check` checks the library for that target with the locked -dependency graph. `web-build` uses the `web` Cargo profile and writes the static +dependency graph. `web-build` defaults to the `web` development Cargo profile; +`--profile release` selects the optimized production build. Both write the static application to ignored `build/web/`, including `pkg/n3.js`, `pkg/n3_bg.wasm`, and the bundled font licenses. It requires no Node, bundler, React installation, Docker, or application server. `web-verify` runs the wrapper lifecycle regressions with Node.js 24, Clippy with warnings denied for the WASM library, and the complete static-site build. A -dedicated Ubuntu WASM CI job installs the optional tooling and runs this gate; -it supplements the native jobs without requiring a browser GPU on that runner. +dedicated Ubuntu WASM CI job runs the same wrapper tests and lint, builds the +release artifact, and checks the optional measurement adapter. It supplements the +native jobs without requiring a browser GPU on that runner. The pinned `egui-winit` 0.36.2 adapter requires narrow WASM compatibility patches for its dropped-file trait and browser OS modifier mapping. The maintained source, upstream @@ -127,8 +130,8 @@ just web --no-open --port 8001 Keep the server running while using the application; Ctrl-C stops it. A desktop browser with WebGPU enabled and available is required. Remote hosting requires HTTPS; opening the generated HTML as a local `file:` URL is not the supported -launch path. The output is static and can be copied to an appropriate host when -deployment is separately requested. +launch path. The output is static; module and WASM URLs resolve relative to the +site, including when it is hosted under a repository subpath. Target compilation and browser runtime checks are separate from `just verify`. The native verification command continues to check shared behavior and the @@ -152,6 +155,28 @@ the input adapter exists. Keep subsequent focus events ordered through the usual adapter. The local server disables HTTP caching, but an already-open tab retains its loaded WASM until reloaded after a rebuild. +### GitHub Pages + +The canonical repository publishes the web app through +[the CI workflow](../.github/workflows/ci.yml). Its WASM job packages only +`build/web/` as a GitHub Pages artifact. The deployment job runs for a push to +`gridaco/n3`'s `main` branch after all four CI lanes succeed. Pull requests and +forks build and verify without publishing. Failed verification leaves the +previous deployment in place. + +GitHub Pages uses the **GitHub Actions** source and the `github-pages` environment. +Only the deployment job receives Pages and OIDC write permissions. Main CI runs +finish before their successor starts, so a new push cannot interrupt an active +deployment. To retry a failed deployment, rerun the failed job while its artifact +is retained; otherwise rerun the entire main CI run to rebuild the artifact. + +Generated WASM, JavaScript glue, and site copies stay in ignored `build/` and +Actions artifacts. No deployment branch or generated commits are needed. The +published site tracks verified main development; it is not a versioned release. +See GitHub's [custom Pages workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages) +for the hosting contract. A new fork must explicitly configure its own site and +adjust the canonical-repository deployment conditions before publishing. + ### Browser gestures The browser host captures wheel and WebKit gesture events on its canvas, then diff --git a/justfile b/justfile index 555ab63..2ce9c9a 100644 --- a/justfile +++ b/justfile @@ -108,8 +108,8 @@ web-test: node --test tools/tests/web_wrapper.test.mjs # Build the shared editor as WASM and write a self-contained site to build/web. -web-build: - python3 tools/web.py build +web-build *args: + python3 tools/web.py build "$@" # Optional browser gate: lint Rust and generate runnable WASM/glue output. web-verify: web-test web-lint web-build diff --git a/tools/tests/test_web.py b/tools/tests/test_web.py index e6ef767..c278c8d 100644 --- a/tools/tests/test_web.py +++ b/tools/tests/test_web.py @@ -1,4 +1,5 @@ from http.client import HTTPConnection +import io import os from pathlib import Path import subprocess @@ -106,8 +107,9 @@ def test_mismatched_generator_preserves_site_and_does_not_compile(self): def test_compile_and_glue_failures_preserve_previous_site_and_remove_staging(self): self.install_generator() self.prepare_site() - for failure in ["cargo", str(self.generator)]: - with self.subTest(failure=failure): + for profile, failure in [(profile, failure) for profile in ("web", "release") + for failure in ("cargo", str(self.generator))]: + with self.subTest(profile=profile, failure=failure): def run(command, **kwargs): if command[0] == failure: raise subprocess.CalledProcessError(1, command) @@ -115,7 +117,7 @@ def run(command, **kwargs): with patch.object(web.subprocess, "run", side_effect=run), patch.object( web.subprocess, "check_output", return_value=f"wasm-bindgen {self.version}", ), self.assertRaises(subprocess.CalledProcessError): - web.build(self.root) + web.build(self.root, profile=profile) self.assertEqual( (self.root / "build/web/index.html").read_text(), "previous runnable wrapper", ) @@ -124,6 +126,35 @@ def run(command, **kwargs): ) self.assertEqual(list((self.root / "build").glob("web-stage-*")), []) + def test_invalid_api_profile_preserves_site_without_running_tools(self): + self.prepare_site() + for profile in ("dev", "debug", "", None): + with self.subTest(profile=profile), patch.object(web, "require_bindgen") as generator, patch.object( + web.subprocess, "run", + ) as process, self.assertRaisesRegex(ValueError, "web or release"): + web.build(self.root, profile=profile) + generator.assert_not_called() + process.assert_not_called() + self.assertEqual( + (self.root / "build/web/pkg/n3_bg.wasm").read_text(), "previous runnable WASM", + ) + + def test_build_cli_defaults_to_web_and_restricts_explicit_profiles(self): + for arguments, expected in [ + (["build"], "web"), + (["build", "--profile", "web"], "web"), + (["build", "--profile", "release"], "release"), + ]: + with self.subTest(arguments=arguments), patch.object(web, "build") as build: + self.assertEqual(web.main(arguments), 0) + build.assert_called_once_with(profile=expected) + with patch.object(web, "build") as build, patch("sys.stderr", new_callable=io.StringIO), self.assertRaises( + SystemExit, + ) as error: + web.main(["build", "--profile", "dev"]) + self.assertEqual(error.exception.code, 2) + build.assert_not_called() + def test_successful_build_honors_cargo_overrides_and_ships_font_notices(self): self.install_generator() self.prepare_site() @@ -141,26 +172,35 @@ def run(command, **kwargs): (output / "n3.js").write_text("new glue", encoding="utf-8") (output / "n3_bg.wasm").write_bytes(b"new WASM") - with patch.dict(os.environ, {"CARGO_HOME": cargo_home, "CARGO_TARGET_DIR": target_dir}), patch.object( - web.subprocess, "run", side_effect=run, - ) as process, patch.object( - web.subprocess, "check_output", return_value=f"wasm-bindgen {self.version}", - ), patch("builtins.print"): - web.build(self.root) - self.assertEqual(os.environ["CARGO_TARGET_DIR"], target_dir) - compile_step, glue = process.call_args_list - self.assertEqual(compile_step.kwargs["env"]["CARGO_HOME"], cargo_home) - self.assertEqual(compile_step.kwargs["env"]["CARGO_TARGET_DIR"], target_dir) - self.assertEqual(glue.args[0][1], str(Path(target_dir) / web.TARGET / "web/n3.wasm")) - site = self.root / "build/web" - self.assertEqual((site / "index.html").read_text(), "new wrapper") - self.assertEqual((site / "n3.js").read_text(), "new interface") - self.assertEqual((site / "pkg/n3_bg.wasm").read_bytes(), b"new WASM") - self.assertEqual((site / "licenses/Inter.txt").read_text(), "Inter notice") - self.assertEqual((site / "licenses/Lucide.txt").read_text(), "Lucide notice") - self.assertFalse((site / "measure.html").exists()) - self.assertFalse((site / "measure.js").exists()) - self.assertEqual(list((self.root / "build").glob("web-stage-*")), []) + for profile in ("web", "release"): + with self.subTest(profile=profile): + with patch.dict(os.environ, {"CARGO_HOME": cargo_home, "CARGO_TARGET_DIR": target_dir}), patch.object( + web.subprocess, "run", side_effect=run, + ) as process, patch.object( + web.subprocess, "check_output", return_value=f"wasm-bindgen {self.version}", + ), patch("builtins.print"): + if profile == "web": + web.build(self.root) + else: + web.build(self.root, profile=profile) + self.assertEqual(os.environ["CARGO_TARGET_DIR"], target_dir) + compile_step, glue = process.call_args_list + self.assertEqual(compile_step.args[0], [ + "cargo", "build", "--locked", "--target", web.TARGET, + "--lib", "--profile", profile, + ]) + self.assertEqual(compile_step.kwargs["env"]["CARGO_HOME"], cargo_home) + self.assertEqual(compile_step.kwargs["env"]["CARGO_TARGET_DIR"], target_dir) + self.assertEqual(glue.args[0][1], str(Path(target_dir) / web.TARGET / profile / "n3.wasm")) + site = self.root / "build/web" + self.assertEqual((site / "index.html").read_text(), "new wrapper") + self.assertEqual((site / "n3.js").read_text(), "new interface") + self.assertEqual((site / "pkg/n3_bg.wasm").read_bytes(), b"new WASM") + self.assertEqual((site / "licenses/Inter.txt").read_text(), "Inter notice") + self.assertEqual((site / "licenses/Lucide.txt").read_text(), "Lucide notice") + self.assertFalse((site / "measure.html").exists()) + self.assertFalse((site / "measure.js").exists()) + self.assertEqual(list((self.root / "build").glob("web-stage-*")), []) class WebServerTests(unittest.TestCase): diff --git a/tools/web.py b/tools/web.py index a424280..0b3e61a 100644 --- a/tools/web.py +++ b/tools/web.py @@ -66,11 +66,13 @@ def setup(root=ROOT): require_bindgen(root) -def build(root=ROOT): +def build(root=ROOT, *, profile="web"): + if profile not in ("web", "release"): + raise ValueError("Browser build profile must be web or release") environment = cargo_environment(root) generator = require_bindgen(root) subprocess.run( - ["cargo", "build", "--locked", "--target", TARGET, "--lib", "--profile", "web"], + ["cargo", "build", "--locked", "--target", TARGET, "--lib", "--profile", profile], cwd=root, env=environment, check=True, ) # Build in a staging directory so failures preserve the last runnable site. @@ -83,7 +85,7 @@ def build(root=ROOT): licenses.mkdir(exist_ok=True) shutil.copyfile(root / "assets/fonts/inter/LICENSE.txt", licenses / "Inter.txt") shutil.copyfile(root / "assets/fonts/lucide/LICENSE", licenses / "Lucide.txt") - artifact = Path(environment["CARGO_TARGET_DIR"]) / TARGET / "web/n3.wasm" + artifact = Path(environment["CARGO_TARGET_DIR"]) / TARGET / profile / "n3.wasm" subprocess.run([ str(generator), str(artifact), "--target", "web", "--out-name", "n3", "--out-dir", str(stage / "pkg"), @@ -163,7 +165,8 @@ def main(argv=None): parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) commands.add_parser("setup", help="install the optional target and matching glue generator") - commands.add_parser("build", help="compile WASM and copy the authored web wrapper") + build_parser = commands.add_parser("build", help="compile WASM and copy the authored web wrapper") + build_parser.add_argument("--profile", choices=("web", "release"), default="web") serve = commands.add_parser("serve", help="serve the existing build on localhost") serve.add_argument("--port", type=int, default=8000) serve.add_argument("--no-open", action="store_true") @@ -173,7 +176,7 @@ def main(argv=None): setup() return 0 if args.command == "build": - build() + build(profile=args.profile) return 0 if not 0 <= args.port <= 65535: parser.error("port must be between 0 and 65535")