diff --git a/.github/scripts/test_web_release.py b/.github/scripts/test_web_release.py
new file mode 100644
index 00000000..97382df1
--- /dev/null
+++ b/.github/scripts/test_web_release.py
@@ -0,0 +1,276 @@
+import importlib.util
+import io
+import json
+import unittest
+import urllib.error
+import urllib.request
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+spec = importlib.util.spec_from_file_location(
+ "web_release", Path(__file__).with_name("web-release.py")
+)
+release = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(release)
+
+
+class VersionTests(unittest.TestCase):
+ def test_metadata_for_stable_and_prerelease(self):
+ for version, stable in [("1.2.12", "true"), ("1.3.0-beta.1", "false")]:
+ with self.subTest(version=version):
+ metadata = release.release_metadata(
+ f"refs/tags/v{version}", version, "NyaKang/nyaterm"
+ )
+ self.assertEqual(metadata["version"], version)
+ self.assertEqual(metadata["image_tag"], version)
+ self.assertEqual(metadata["stable"], stable)
+ self.assertEqual(metadata["image"], "ghcr.io/nyakang/nyaterm-web")
+
+ def test_build_metadata_is_preserved_in_version_and_encoded_in_tag(self):
+ metadata = release.release_metadata(
+ "refs/tags/v1.2.3+build.4", "1.2.3+build.4", "nyakang/nyaterm"
+ )
+ self.assertEqual(metadata["version"], "1.2.3+build.4")
+ self.assertEqual(metadata["image_tag"], "1.2.3_build.4")
+
+ def test_branch_dispatch_and_mismatched_version_fail(self):
+ for ref in ["refs/heads/main", "refs/tags/1.2.12", "refs/tags/v1.2.11"]:
+ with self.subTest(ref=ref), self.assertRaises(ValueError):
+ release.release_metadata(ref, "1.2.12", "nyakang/nyaterm")
+
+ def test_invalid_semver_fails(self):
+ for version in [
+ "1.2",
+ "01.2.3",
+ "1.2.3-01",
+ "1.2.3-alpha.01",
+ "1.2.3-",
+ "1.2.3+",
+ "1.2.3\n",
+ ]:
+ with self.subTest(version=version), self.assertRaises(ValueError):
+ release.version_parts(version)
+
+ def test_docker_tag_length_limit(self):
+ version = "1.2.3-" + "a" * 123
+ with self.assertRaises(ValueError):
+ release.release_metadata(
+ f"refs/tags/v{version}", version, "nyakang/nyaterm"
+ )
+
+ def test_latest_creation_and_numeric_upgrade(self):
+ for candidate, current in [
+ ("1.2.12", None),
+ ("1.2.12", "1.2.9"),
+ ("1.10.0", "1.9.99"),
+ ("2.0.0", "1.99.99"),
+ ]:
+ with self.subTest(candidate=candidate, current=current):
+ self.assertTrue(release.should_promote(candidate, current))
+
+ def test_latest_never_regresses_or_accepts_prerelease(self):
+ for candidate, current in [
+ ("1.2.11", "1.2.12"),
+ ("1.2.12", "1.2.12"),
+ ("1.2.12+rebuild", "1.2.12"),
+ ("2.0.0-beta.1", "1.2.12"),
+ ("2.0.0-beta.1", None),
+ ]:
+ with self.subTest(candidate=candidate, current=current):
+ self.assertFalse(release.should_promote(candidate, current))
+
+ def test_invalid_existing_latest_fails_closed(self):
+ for current in ["unknown", "1.2.12-beta.1"]:
+ with self.subTest(current=current), self.assertRaises(ValueError):
+ release.should_promote("1.3.0", current)
+
+
+class ManifestTests(unittest.TestCase):
+ def setUp(self):
+ self.digests = {
+ ("linux", "amd64"): "sha256:" + "a" * 64,
+ ("linux", "arm64"): "sha256:" + "b" * 64,
+ }
+ self.manifests = [
+ {"platform": {"os": os, "architecture": arch}, "digest": digest}
+ for (os, arch), digest in self.digests.items()
+ ]
+
+ def test_both_tested_platforms_required(self):
+ raw = json.dumps({"manifests": self.manifests}).encode()
+ self.assertRegex(
+ release.verify_manifest(raw, self.digests), r"^sha256:[0-9a-f]{64}$"
+ )
+ for manifests in [self.manifests[:1], self.manifests + self.manifests[:1]]:
+ with self.subTest(manifests=manifests), self.assertRaises(ValueError):
+ release.verify_manifest(
+ json.dumps({"manifests": manifests}).encode(), self.digests
+ )
+
+ def test_untested_digest_rejected(self):
+ self.manifests[0]["digest"] = "sha256:" + "c" * 64
+ with self.assertRaises(ValueError):
+ release.verify_manifest(
+ json.dumps({"manifests": self.manifests}).encode(), self.digests
+ )
+
+
+class PromotionTests(unittest.TestCase):
+ def setUp(self):
+ self.image = "ghcr.io/nyakang/nyaterm-web"
+ self.digest = "sha256:" + "a" * 64
+ self.registry = Mock()
+
+ def test_first_release_and_upgrade_publish_verified_digest(self):
+ for current in [None, "1.2.9"]:
+ with self.subTest(current=current), patch.object(
+ release.subprocess, "run"
+ ) as run:
+ self.registry.image_version.side_effect = ["1.2.12", current, "1.2.12"]
+ release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
+ run.assert_called_once_with(
+ [
+ "docker",
+ "buildx",
+ "imagetools",
+ "create",
+ "--tag",
+ f"{self.image}:latest",
+ f"{self.image}@{self.digest}",
+ ],
+ check=True,
+ )
+
+ def test_old_equal_and_prerelease_do_not_write_registry(self):
+ for candidate, current in [
+ ("1.2.11", "1.2.12"),
+ ("1.2.12", "1.2.12"),
+ ("1.3.0-beta.1", "1.2.12"),
+ ]:
+ with self.subTest(candidate=candidate), patch.object(
+ release.subprocess, "run"
+ ) as run:
+ self.registry.image_version.side_effect = [candidate, current]
+ release.promote_latest(
+ self.registry, self.image, candidate, self.digest
+ )
+ run.assert_not_called()
+
+ def test_latest_read_failure_cannot_trigger_publication(self):
+ self.registry.image_version.side_effect = [
+ "1.2.12",
+ urllib.error.URLError("fixture"),
+ ]
+ with patch.object(release.subprocess, "run") as run, self.assertRaises(
+ urllib.error.URLError
+ ):
+ release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
+ run.assert_not_called()
+
+ def test_wrong_candidate_version_cannot_trigger_publication(self):
+ self.registry.image_version.return_value = "1.2.11"
+ with patch.object(release.subprocess, "run") as run, self.assertRaises(
+ ValueError
+ ):
+ release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
+ run.assert_not_called()
+
+ def test_publication_error_and_failed_verification_fail_job(self):
+ self.registry.image_version.side_effect = ["1.2.12", None]
+ with patch.object(
+ release.subprocess, "run", side_effect=RuntimeError("fixture")
+ ), self.assertRaises(RuntimeError):
+ release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
+ self.registry.image_version.side_effect = ["1.2.12", "1.2.11", "1.2.11"]
+ with patch.object(release.subprocess, "run"), self.assertRaises(ValueError):
+ release.promote_latest(self.registry, self.image, "1.2.12", self.digest)
+
+
+class RegistryTests(unittest.TestCase):
+ def setUp(self):
+ self.registry = release.Registry.__new__(release.Registry)
+ self.registry.opener = Mock()
+ self.registry.repository = "nyakang/nyaterm-web"
+ self.registry.headers = {"Authorization": "Bearer disposable-test-token"}
+
+ def http_error(self, status, code):
+ self.registry.opener.open.side_effect = urllib.error.HTTPError(
+ "https://ghcr.io/v2/nyakang/nyaterm-web/manifests/latest",
+ status,
+ "fixture",
+ {},
+ io.BytesIO(json.dumps({"errors": [{"code": code}]}).encode()),
+ )
+
+ def test_only_missing_latest_can_be_treated_as_first_release(self):
+ self.http_error(404, "MANIFEST_UNKNOWN")
+ self.assertIsNone(self.registry.manifest("latest", missing=True))
+ self.http_error(404, "MANIFEST_UNKNOWN")
+ with self.assertRaises(urllib.error.HTTPError):
+ self.registry.manifest("sha256:" + "a" * 64)
+
+ def test_auth_server_and_repository_errors_fail_closed(self):
+ for status, code in [
+ (401, "UNAUTHORIZED"),
+ (403, "DENIED"),
+ (429, "TOOMANYREQUESTS"),
+ (500, "UNKNOWN"),
+ (404, "NAME_UNKNOWN"),
+ ]:
+ with self.subTest(status=status, code=code):
+ self.http_error(status, code)
+ with self.assertRaises(urllib.error.HTTPError):
+ self.registry.manifest("latest", missing=True)
+
+ def test_network_failure_propagates(self):
+ self.registry.opener.open.side_effect = urllib.error.URLError("fixture timeout")
+ with self.assertRaises(urllib.error.URLError):
+ self.registry.manifest("latest", missing=True)
+
+ def test_version_is_read_from_amd64_config_in_index(self):
+ digest = "sha256:" + "a" * 64
+ self.registry.manifest = Mock(
+ side_effect=[
+ {
+ "manifests": [
+ {
+ "platform": {"os": "linux", "architecture": "arm64"},
+ "digest": "sha256:" + "b" * 64,
+ },
+ {
+ "platform": {"os": "linux", "architecture": "amd64"},
+ "digest": digest,
+ },
+ ]
+ },
+ {"config": {"digest": digest}},
+ ]
+ )
+ self.registry.get_json = Mock(
+ return_value={
+ "config": {"Labels": {"org.opencontainers.image.version": "1.2.12"}}
+ }
+ )
+ self.assertEqual(self.registry.image_version("latest"), "1.2.12")
+ self.registry.manifest.assert_called_with(digest)
+
+ def test_missing_version_label_is_an_error(self):
+ self.registry.manifest = Mock(
+ return_value={"config": {"digest": "sha256:" + "a" * 64}}
+ )
+ self.registry.get_json = Mock(return_value={"config": {"Labels": {}}})
+ with self.assertRaises(KeyError):
+ self.registry.image_version("latest")
+
+ def test_registry_redirect_does_not_forward_token_to_blob_storage(self):
+ request = urllib.request.Request(
+ "https://ghcr.io/v2/example/blobs/fixture", headers=self.registry.headers
+ )
+ redirect = release.RegistryRedirect().redirect_request(
+ request, None, 307, "redirect", {}, "https://storage.example/blob"
+ )
+ self.assertFalse(redirect.has_header("Authorization"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/web-release-smoke.mjs b/.github/scripts/web-release-smoke.mjs
new file mode 100644
index 00000000..59d05ade
--- /dev/null
+++ b/.github/scripts/web-release-smoke.mjs
@@ -0,0 +1,113 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { randomBytes } from "node:crypto";
+import { mkdirSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { setTimeout as delay } from "node:timers/promises";
+
+const image = process.env.NYATERM_SMOKE_IMAGE;
+assert(image, "NYATERM_SMOKE_IMAGE is required");
+const name = "nyaterm-web-release-smoke";
+const artifacts = resolve("artifacts/web-release-smoke");
+mkdirSync(artifacts, { recursive: true });
+const password = randomBytes(32).toString("base64");
+const encryptionKey = randomBytes(32).toString("base64");
+const docker = (args, optional = false) => {
+ const result = spawnSync("docker", args, {
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ NYATERM_WEB_PASSWORD: password,
+ NYATERM_WEB_ENCRYPTION_KEY: encryptionKey,
+ },
+ });
+ if (!optional && (result.error || result.status !== 0))
+ throw new Error(`Docker ${args[0]} failed: ${result.stderr}`);
+ return result;
+};
+const fetchWithTimeout = (url, options = {}) =>
+ fetch(url, { ...options, signal: AbortSignal.timeout(5000) });
+
+try {
+ docker([
+ "run",
+ "-d",
+ "--name",
+ name,
+ "--init",
+ "-p",
+ "127.0.0.1::8080",
+ "-e",
+ "NYATERM_WEB_PASSWORD",
+ "-e",
+ "NYATERM_WEB_ENCRYPTION_KEY",
+ "--read-only",
+ "--tmpfs",
+ "/tmp",
+ "--cap-drop",
+ "ALL",
+ "--security-opt",
+ "no-new-privileges:true",
+ image,
+ ]);
+ const binding = docker(["port", name, "8080/tcp"]).stdout.trim();
+ const base = `http://${binding}/`;
+ const deadline = Date.now() + 60_000;
+ let response;
+ while (Date.now() < deadline) {
+ try {
+ response = await fetchWithTimeout(base);
+ if (response.ok) break;
+ } catch {}
+ await delay(500);
+ }
+ assert(response?.ok, "Container readiness timed out");
+ const html = await response.text();
+ assert(html.includes('
'), "Missing application HTML");
+ const assets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"\s]+)"/g)];
+ assert(assets.length > 0, "No built assets found");
+ for (const [, path] of assets) {
+ const asset = await fetchWithTimeout(new URL(path, base));
+ assert.equal(asset.status, 200, `Asset unavailable: ${path}`);
+ assert(
+ !asset.headers.get("content-type")?.includes("text/html"),
+ "Asset returned SPA fallback",
+ );
+ assert((await asset.arrayBuffer()).byteLength > 0, "Empty asset");
+ }
+ const sessionUrl = new URL("api/auth/session", base);
+ assert.equal((await fetchWithTimeout(sessionUrl)).status, 401);
+ const headers = {
+ Origin: new URL(base).origin,
+ "X-Nyaterm-Request": "1",
+ "Content-Type": "application/json",
+ };
+ const loginUrl = new URL("api/auth/login", base);
+ const rejected = await fetchWithTimeout(loginUrl, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ password: "invalid" }),
+ });
+ assert.equal(rejected.status, 401);
+ const login = await fetchWithTimeout(loginUrl, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ password }),
+ });
+ assert.equal(login.status, 200, "Login failed");
+ const { csrf } = await login.json();
+ assert(csrf, "Missing CSRF token");
+ const cookie = login.headers.get("set-cookie")?.split(";")[0];
+ assert(cookie, "Missing session cookie");
+ const session = await fetchWithTimeout(sessionUrl, {
+ headers: { Cookie: cookie },
+ });
+ assert.equal(session.status, 200);
+ assert.equal((await session.json()).csrf, csrf);
+ console.log("Web release image smoke checks passed.");
+} finally {
+ const logs = docker(["logs", name], true);
+ writeFileSync(resolve(artifacts, "web.log"), logs.stdout + logs.stderr);
+ // -v removes the anonymous /data volume created by the Dockerfile.
+ docker(["rm", "-f", "-v", name], true);
+}
diff --git a/.github/scripts/web-release.py b/.github/scripts/web-release.py
new file mode 100644
index 00000000..13d7b0ed
--- /dev/null
+++ b/.github/scripts/web-release.py
@@ -0,0 +1,229 @@
+"""Version checks and registry operations for the Web release workflow."""
+
+import argparse
+import base64
+import hashlib
+import json
+import os
+import re
+import subprocess
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+
+SEMVER = re.compile(
+ r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)"
+ r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
+ r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
+)
+DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
+
+
+def version_parts(version):
+ match = SEMVER.fullmatch(version)
+ if not match:
+ raise ValueError(f"Invalid SemVer: {version!r}")
+ prerelease = match[4]
+ if prerelease and any(
+ part.isdigit() and len(part) > 1 and part.startswith("0")
+ for part in prerelease.split(".")
+ ):
+ raise ValueError(f"Invalid numeric prerelease identifier: {version!r}")
+ return tuple(int(match[i]) for i in (1, 2, 3)), prerelease
+
+
+def release_metadata(ref, package_version, repository):
+ if not ref.startswith("refs/tags/v"):
+ raise ValueError("Dispatch against a version tag (refs/tags/v).")
+ version = ref.removeprefix("refs/tags/v")
+ _, prerelease = version_parts(version)
+ if version != package_version:
+ raise ValueError(
+ f"Tag version {version!r} differs from package.json {package_version!r}."
+ )
+ # Docker tags cannot contain '+'. Keep build metadata in the OCI version label.
+ image_tag = version.replace("+", "_")
+ if len(image_tag) > 128:
+ raise ValueError("Version exceeds Docker's 128-character tag limit.")
+ owner = repository.split("/")[0].lower()
+ return {
+ "version": version,
+ "image_tag": image_tag,
+ "stable": str(prerelease is None).lower(),
+ "image": f"ghcr.io/{owner}/nyaterm-web",
+ }
+
+
+def should_promote(candidate, current):
+ candidate_version, candidate_prerelease = version_parts(candidate)
+ if candidate_prerelease:
+ return False
+ if current is None:
+ return True
+ current_version, current_prerelease = version_parts(current)
+ if current_prerelease:
+ raise ValueError(
+ "The existing latest image is a prerelease; refusing to replace it automatically."
+ )
+ return candidate_version > current_version
+
+
+def verify_manifest(raw, expected_digests):
+ manifest = json.loads(raw)
+ actual = {
+ (item["platform"]["os"], item["platform"]["architecture"]): item["digest"]
+ for item in manifest["manifests"]
+ }
+ if len(manifest["manifests"]) != 2 or actual != expected_digests:
+ raise ValueError(f"Unexpected release platforms or digests: {actual}")
+ return "sha256:" + hashlib.sha256(raw).hexdigest()
+
+
+class RegistryRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, request, fp, code, msg, headers, newurl):
+ redirected = super().redirect_request(request, fp, code, msg, headers, newurl)
+ if (
+ redirected
+ and urllib.parse.urlsplit(request.full_url).netloc
+ != urllib.parse.urlsplit(newurl).netloc
+ ):
+ redirected.remove_header("Authorization")
+ return redirected
+
+
+class Registry:
+ def __init__(self, image, username, password):
+ registry, self.repository = image.split("/", 1)
+ if registry != "ghcr.io":
+ raise ValueError("Only GHCR images are supported.")
+ self.opener = urllib.request.build_opener(RegistryRedirect())
+ credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
+ query = urllib.parse.urlencode(
+ {"service": "ghcr.io", "scope": f"repository:{self.repository}:pull"}
+ )
+ response = self.get_json(
+ f"https://ghcr.io/token?{query}", {"Authorization": f"Basic {credentials}"}
+ )
+ self.headers = {"Authorization": f"Bearer {response['token']}"}
+
+ def get_json(self, url, headers, missing_manifest=False):
+ try:
+ with self.opener.open(
+ urllib.request.Request(url, headers=headers), timeout=30
+ ) as response:
+ return json.load(response)
+ except urllib.error.HTTPError as error:
+ if missing_manifest and error.code == 404:
+ payload = json.loads(error.read())
+ codes = [item.get("code") for item in payload.get("errors", [])]
+ if codes and all(code == "MANIFEST_UNKNOWN" for code in codes):
+ return None
+ raise
+
+ def manifest(self, reference, missing=False):
+ if reference != "latest" and not DIGEST.fullmatch(reference):
+ raise ValueError("Invalid manifest digest.")
+ headers = self.headers | {
+ "Accept": ", ".join(
+ [
+ "application/vnd.oci.image.index.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json",
+ "application/vnd.oci.image.manifest.v1+json",
+ "application/vnd.docker.distribution.manifest.v2+json",
+ ]
+ )
+ }
+ return self.get_json(
+ f"https://ghcr.io/v2/{self.repository}/manifests/{reference}",
+ headers,
+ missing,
+ )
+
+ def image_version(self, reference, missing=False):
+ manifest = self.manifest(reference, missing)
+ if manifest is None:
+ return None
+ if "manifests" in manifest:
+ platform = next(
+ item
+ for item in manifest["manifests"]
+ if item.get("platform", {}).get("os") == "linux"
+ and item["platform"].get("architecture") == "amd64"
+ )
+ manifest = self.manifest(platform["digest"])
+ digest = manifest["config"]["digest"]
+ if not DIGEST.fullmatch(digest):
+ raise ValueError("Invalid image config digest.")
+ config = self.get_json(
+ f"https://ghcr.io/v2/{self.repository}/blobs/{digest}", self.headers
+ )
+ version = config["config"]["Labels"]["org.opencontainers.image.version"]
+ version_parts(version)
+ return version
+
+
+def promote_latest(registry, image, version, digest):
+ if registry.image_version(digest) != version:
+ raise ValueError(
+ "Published image version does not match the candidate release."
+ )
+ current = registry.image_version("latest", missing=True)
+ promote = should_promote(version, current)
+ if promote:
+ subprocess.run(
+ [
+ "docker",
+ "buildx",
+ "imagetools",
+ "create",
+ "--tag",
+ f"{image}:latest",
+ f"{image}@{digest}",
+ ],
+ check=True,
+ )
+ if registry.image_version("latest") != version:
+ raise ValueError("latest verification failed.")
+ return f"{'Updated' if promote else 'Kept'} latest: candidate={version}, previous={current or '(absent)'}"
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "command", choices=["metadata", "check-manifest", "promote-latest"]
+ )
+ args = parser.parse_args()
+ if args.command == "metadata":
+ metadata = release_metadata(
+ os.environ["GITHUB_REF"],
+ json.loads(Path("package.json").read_text())["version"],
+ os.environ["GITHUB_REPOSITORY"],
+ )
+ with open(os.environ["GITHUB_OUTPUT"], "a") as output:
+ for key, value in metadata.items():
+ output.write(f"{key}={value}\n")
+ print(f"Validated Web release {metadata['version']}")
+ elif args.command == "check-manifest":
+ digest = verify_manifest(
+ Path("manifest.json").read_bytes(),
+ {
+ ("linux", arch): os.environ[f"DIGEST_{arch.upper()}"]
+ for arch in ("amd64", "arm64")
+ },
+ )
+ with open(os.environ["GITHUB_OUTPUT"], "a") as output:
+ output.write(f"digest={digest}\n")
+ else:
+ image, version, digest = (
+ os.environ[key] for key in ("IMAGE", "VERSION", "DIGEST")
+ )
+ registry = Registry(image, os.environ["GITHUB_ACTOR"], os.environ["GH_TOKEN"])
+ message = promote_latest(registry, image, version, digest)
+ print(message)
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary:
+ summary.write(message + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/build-web-release.yml b/.github/workflows/build-web-release.yml
new file mode 100644
index 00000000..a6c2a226
--- /dev/null
+++ b/.github/workflows/build-web-release.yml
@@ -0,0 +1,236 @@
+name: Build and Release Web
+
+on:
+ push:
+ tags: ["v*"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: web-release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ prepare:
+ name: Validate Web release
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ outputs:
+ version: ${{ steps.metadata.outputs.version }}
+ image_tag: ${{ steps.metadata.outputs.image_tag }}
+ image: ${{ steps.metadata.outputs.image }}
+ stable: ${{ steps.metadata.outputs.stable }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: Test release policy
+ run: python3 -m unittest discover -s .github/scripts -p 'test_web_release.py'
+ - name: Validate version tag
+ id: metadata
+ run: python3 .github/scripts/web-release.py metadata
+ - name: Verify tag commit belongs to main
+ shell: bash
+ run: |
+ set -euo pipefail
+ git fetch origin main:refs/remotes/origin/main
+ tag_commit=$(git rev-parse "${GITHUB_REF}^{commit}")
+ if [ "$tag_commit" != "$(git rev-parse HEAD)" ] || ! git merge-base --is-ancestor "$tag_commit" origin/main; then
+ echo "::error::The checked-out tag commit must belong to origin/main."
+ exit 1
+ fi
+
+ acceptance:
+ name: Web deployment acceptance
+ needs: prepare
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v6
+ - uses: pnpm/action-setup@v5
+ with:
+ version: 11.10.0
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ cache: pnpm
+ - uses: docker/setup-buildx-action@v3
+ - run: pnpm install --frozen-lockfile
+ - run: pnpm exec playwright install --with-deps chromium
+ - run: pnpm test:web:e2e
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: web-release-acceptance-failure
+ path: artifacts/web-e2e/
+ retention-days: 7
+
+ build:
+ name: Build and smoke test ${{ matrix.arch }}
+ needs: [prepare, acceptance]
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 60
+ permissions:
+ contents: read
+ packages: write
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { arch: amd64, runner: ubuntu-24.04 }
+ - { arch: arm64, runner: ubuntu-24.04-arm }
+ env:
+ IMAGE: ${{ needs.prepare.outputs.image }}
+ VERSION: ${{ needs.prepare.outputs.version }}
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ - uses: docker/setup-buildx-action@v3
+ - name: Build local image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: deploy/web/Dockerfile
+ platforms: linux/${{ matrix.arch }}
+ build-args: NYATERM_WEB_BASE_PATH=/
+ load: true
+ tags: nyaterm-web:smoke
+ provenance: false
+ sbom: false
+ labels: |
+ org.opencontainers.image.version=${{ env.VERSION }}
+ org.opencontainers.image.source=https://github.com/${{ github.repository }}
+ org.opencontainers.image.revision=${{ github.sha }}
+ cache-from: type=gha,scope=web-release-${{ matrix.arch }}
+ cache-to: type=gha,scope=web-release-${{ matrix.arch }},mode=max
+ - name: Smoke test the release image
+ env:
+ NYATERM_SMOKE_IMAGE: nyaterm-web:smoke
+ run: node .github/scripts/web-release-smoke.mjs
+ - uses: actions/setup-go@v6
+ with:
+ go-version: stable
+ cache: false
+ - name: Install crane
+ env:
+ GOBIN: ${{ runner.temp }}/web-tools
+ run: |
+ go install github.com/google/go-containerregistry/cmd/crane@v0.20.3
+ echo "$GOBIN" >> "$GITHUB_PATH"
+ - uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Push tested image by digest
+ env:
+ ARCH: ${{ matrix.arch }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ archive="$RUNNER_TEMP/nyaterm-web.tar"
+ docker save nyaterm-web:smoke --output "$archive"
+ digest=$(crane digest --tarball "$archive")
+ crane push "$archive" "$IMAGE@$digest"
+ mkdir -p digests
+ printf '%s' "$digest" > "digests/$ARCH"
+ {
+ echo "### Web image: linux/$ARCH"
+ echo "Version: $VERSION"
+ echo "Image: $IMAGE@$digest"
+ } >> "$GITHUB_STEP_SUMMARY"
+ - uses: actions/upload-artifact@v4
+ with:
+ name: web-release-digest-${{ matrix.arch }}
+ path: digests/
+ if-no-files-found: error
+ retention-days: 7
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: web-release-smoke-failure-${{ matrix.arch }}
+ path: artifacts/web-release-smoke/
+ retention-days: 7
+
+ publish:
+ name: Publish multi-architecture version
+ needs: [prepare, build]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ packages: write
+ outputs:
+ digest: ${{ steps.manifest.outputs.digest }}
+ env:
+ IMAGE: ${{ needs.prepare.outputs.image }}
+ IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
+ VERSION: ${{ needs.prepare.outputs.version }}
+ steps:
+ - uses: actions/checkout@v6
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - uses: actions/download-artifact@v4
+ with:
+ pattern: web-release-digest-*
+ path: digests
+ merge-multiple: true
+ - name: Create and verify the version manifest
+ id: manifest
+ shell: bash
+ run: |
+ set -euo pipefail
+ DIGEST_AMD64=$(cat digests/amd64)
+ DIGEST_ARM64=$(cat digests/arm64)
+ export DIGEST_AMD64 DIGEST_ARM64
+ docker buildx imagetools create --tag "$IMAGE:$IMAGE_TAG" \
+ "$IMAGE@$DIGEST_AMD64" "$IMAGE@$DIGEST_ARM64"
+ docker buildx imagetools inspect --raw "$IMAGE:$IMAGE_TAG" > manifest.json
+ python3 .github/scripts/web-release.py check-manifest
+ {
+ echo "### Published Web release $VERSION"
+ echo "Image: $IMAGE:$IMAGE_TAG"
+ echo "Platforms: linux/amd64, linux/arm64"
+ } >> "$GITHUB_STEP_SUMMARY"
+ - name: Record manifest digest
+ env:
+ DIGEST: ${{ steps.manifest.outputs.digest }}
+ run: |
+ echo "Manifest digest (pin for deployment): $IMAGE@$DIGEST" >> "$GITHUB_STEP_SUMMARY"
+
+ latest:
+ name: Promote newer stable version to latest
+ needs: [prepare, publish]
+ if: needs.prepare.outputs.stable == 'true'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ packages: write
+ concurrency:
+ group: web-release-latest-${{ github.repository }}
+ cancel-in-progress: false
+ env:
+ IMAGE: ${{ needs.prepare.outputs.image }}
+ VERSION: ${{ needs.prepare.outputs.version }}
+ DIGEST: ${{ needs.publish.outputs.digest }}
+ steps:
+ - uses: actions/checkout@v6
+ - uses: docker/setup-buildx-action@v3
+ - uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Compare registry version and promote latest
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python3 .github/scripts/web-release.py promote-latest
diff --git a/.gitignore b/.gitignore
index a46998e5..506d071d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,4 +49,8 @@ web-test-data/
# Isolated deployment test traces and container logs
artifacts/web-e2e/
artifacts/web-local-e2e/
+artifacts/web-release-smoke/
playwright-report/
+
+# Python release helper caches
+__pycache__/
diff --git a/deploy/web/README.md b/deploy/web/README.md
index 7daab6b2..aae9d1da 100644
--- a/deploy/web/README.md
+++ b/deploy/web/README.md
@@ -5,6 +5,32 @@
- `NYATERM_WEB_PASSWORD`:至少 32 字符的随机登录密码。
- `NYATERM_WEB_ENCRYPTION_KEY`:Base64 编码的 32 字节随机密钥,需持久保存。
+## 使用发布镜像
+
+官方镜像 `ghcr.io/nyakang/nyaterm-web` 支持 `linux/amd64` 和 `linux/arm64`。`latest` 指向稳定版本,只由更高版本更新;生产部署可在 `.env` 中通过 `NYATERM_WEB_IMAGE` 固定完整版本或 digest:
+
+```dotenv
+NYATERM_WEB_IMAGE=ghcr.io/nyakang/nyaterm-web:1.2.12
+# 或 ghcr.io/nyakang/nyaterm-web@sha256:
+```
+
+版本示例需替换为实际已发布的版本。在仓库根目录执行:
+
+```sh
+docker compose -f deploy/web/docker-compose.image.yml pull
+docker compose -f deploy/web/docker-compose.image.yml up -d
+docker compose -f deploy/web/docker-compose.image.yml logs --tail 200 nyaterm
+docker compose -f deploy/web/docker-compose.image.yml down
+```
+
+更新时先修改 `NYATERM_WEB_IMAGE`(使用 `latest` 时无需修改),再运行 `pull` 和 `up -d`。回滚时改回原版本或 digest 后执行同样命令。更新前停服备份数据目录并保存原密钥;如果新版本迁移了数据格式,回滚还需恢复对应备份。
+
+镜像配置与源码配置使用同一项目名 `nyaterm`、服务名和数据卷。切换时单独使用一个 Compose 文件,并沿用原项目名、登录密码和加密密钥;不要运行 `down -v`。若原部署使用 `-p` 或 `COMPOSE_PROJECT_NAME`,切换后仍需使用原值。
+
+官方镜像只支持根路径 `/`。部署到 `/nyaterm/` 等子路径时,使用下面的源码构建配置。
+
+## 从源码构建
+
在仓库根目录执行:
```sh
@@ -15,4 +41,14 @@ docker compose -f deploy/web/docker-compose.yml down
访问 `http://localhost:8080/` 或 `http://服务器IP:8080/`,使用设置的密码登录。数据保存在 Docker 卷中,普通 `down` 不会删除数据。
+两种 Compose 配置的 `8080:8080` 均发布到所有主机接口;使用本机反向代理时可改为 `127.0.0.1:8080:8080`。
+
+## 镜像发版
+
+[Build and Release Web](../../.github/workflows/build-web-release.yml) 在推送 `v` 标签时自动构建,也可在 Actions 中选择版本标签手动补发。标签版本必须与 `package.json` 一致,且提交属于 `main`。镜像发版与桌面发版独立,不等待 GitHub Release 公开。
+
+完整版本标签不带 `v`,如 `1.2.12`、`1.3.0-beta.1`。SemVer 构建元数据中的 `+` 在镜像标签中编码为 `_`,OCI 版本标签仍保留原始版本。预发布、旧版本及同版本补发均不会更新 `latest`,也不发布 major/minor 浮动标签。版本和架构 digest 可在 Actions 摘要中查看。
+
+首次发布后,维护者需在 GitHub 的 `nyaterm-web` package 设置中确认可见性为 **Public**,以支持匿名拉取。Workflow 使用仓库的 `GITHUB_TOKEN` 发布,无需额外注册表密码;仓库及组织策略须允许 GitHub Actions 写入该 package。
+
HTTPS、子路径、备份及其他配置见 [部署指南](../../docs/web-deployment.md)。
diff --git a/deploy/web/docker-compose.image.yml b/deploy/web/docker-compose.image.yml
new file mode 100644
index 00000000..62f5b180
--- /dev/null
+++ b/deploy/web/docker-compose.image.yml
@@ -0,0 +1,15 @@
+name: nyaterm
+
+services:
+ nyaterm:
+ image: ${NYATERM_WEB_IMAGE:-ghcr.io/nyakang/nyaterm-web:latest}
+ ports:
+ - "8080:8080"
+ environment:
+ NYATERM_WEB_PASSWORD: ${NYATERM_WEB_PASSWORD:?Set a random login password of at least 32 characters}
+ NYATERM_WEB_ENCRYPTION_KEY: ${NYATERM_WEB_ENCRYPTION_KEY:?Set a persistent base64-encoded 32-byte encryption key}
+ volumes:
+ - nyaterm-data:/data
+ restart: unless-stopped
+volumes:
+ nyaterm-data:
diff --git a/docs/web-deployment.md b/docs/web-deployment.md
index eaac8f2d..cc39cb0f 100644
--- a/docs/web-deployment.md
+++ b/docs/web-deployment.md
@@ -49,6 +49,23 @@ HTTP 和 HTTPS 地址均可直接使用,无需配置访问 URL 或域名白名
部署文件集中在 [deploy/web/](../deploy/web/README.md)。以下 Docker 和 Compose 命令均在仓库根目录执行;构建上下文仍是仓库根目录,使用根目录的 `.dockerignore`。
+### 使用发布镜像
+
+官方镜像 `ghcr.io/nyakang/nyaterm-web` 包含前端和 Rust 服务,支持 `linux/amd64` 和 `linux/arm64`,无需本机编译。先设置下面 Compose 段落要求的登录密码和持久加密密钥,再执行:
+
+```sh
+docker compose -f deploy/web/docker-compose.image.yml pull
+docker compose -f deploy/web/docker-compose.image.yml up -d
+```
+
+默认使用 `latest`;可在 `.env` 中设置 `NYATERM_WEB_IMAGE=ghcr.io/nyakang/nyaterm-web:1.2.12`(替换为实际已发布版本),或指定 `ghcr.io/nyakang/nyaterm-web@sha256:`。更新或回滚时修改镜像引用,再执行 `pull` 和 `up -d`,保持原登录密码、加密密钥、项目名及数据卷;更新前停服备份数据,数据格式迁移后的回滚需恢复对应备份。源码配置与镜像配置单独使用,不叠加。
+
+`latest` 仅由更高稳定版本更新;预发布及旧版本补发不会使它回退。首次发布后维护者需确认 GHCR package 为 Public,才能匿名拉取。发版触发条件、版本标签规则和补发入口见 [镜像发版说明](../deploy/web/README.md#镜像发版)。
+
+官方镜像构建路径固定为 `/`,运行时不能仅修改 `NYATERM_WEB_BASE_PATH` 来改为子路径;子路径部署需使用原源码构建方式。镜像 Compose 沿用原来的 `8080:8080`,使用本机反向代理时应改为 `127.0.0.1:8080:8080`。
+
+### 从源码构建
+
```sh
docker build -f deploy/web/Dockerfile -t nyaterm-web .
docker volume create nyaterm-data
@@ -167,7 +184,7 @@ Web 当前不支持本地 Shell、Serial、RDP、ProxyCommand、SSH agent/X11/
刷新页面时,按工作区 pane ID 重新附着当前登录的有效会话;旧连接租期结束后,保存的连接按原有工作区恢复流程重新创建。SFTP 仅对 SSH 会话提供。浏览器剪贴板需要 HTTPS 和浏览器权限,未授权时显示提示,画面和键鼠仍可使用。网络面板可管理代理及分组,Web 子窗口使用同源 iframe。单镜像部署,无需 noVNC、Guacamole 或额外服务。
-更新后请重新构建镜像并重建容器。普通 `docker compose down` 保留数据卷;`down -v` 会删除数据卷。恢复停服备份时,使用原加密密钥、恢复整个数据目录及 UID/GID 10001 的读写权限,再启动单个实例。浏览器中的密码备份用于配置迁移,不能代替整个数据目录的备份。
+更新时,源码部署需重新构建镜像,发布镜像部署需拉取目标版本,随后重建容器。普通 `docker compose down` 保留数据卷;`down -v` 会删除数据卷。恢复停服备份时,使用原加密密钥、恢复整个数据目录及 UID/GID 10001 的读写权限,再启动单个实例。浏览器中的密码备份用于配置迁移,不能代替整个数据目录的备份。
## Web Beta 能力矩阵与验收