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 能力矩阵与验收