feat(web-release): implement web release workflow and testing scripts

- Added a GitHub Actions workflow for building and releasing the web application on version tag pushes.
- Introduced Python and JavaScript scripts for release metadata generation, validation, and smoke testing of the web release image.
- Updated .gitignore to include new artifacts and Python cache directories.
- Created Docker Compose configuration for deploying the web application with environment variable management.
- Enhanced documentation for deployment instructions and usage of the official web release image.
This commit is contained in:
Kang committed 2026-10-07 20:56:55 +08:00
1 parent b35e71906a
commit 29be430b6a
8 files changed
+927 -1

No files matched your search

+276
View File
@@ -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()
+113
View File
@@ -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('<div id="root">'), "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);
}
+229
View File
@@ -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<SemVer>).")
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()
+236
View File
@@ -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
+4
View File
@@ -49,4 +49,8 @@ web-test-data/
# Isolated deployment test traces and container logs # Isolated deployment test traces and container logs
artifacts/web-e2e/ artifacts/web-e2e/
artifacts/web-local-e2e/ artifacts/web-local-e2e/
artifacts/web-release-smoke/
playwright-report/ playwright-report/
# Python release helper caches
__pycache__/
+36
View File
@@ -5,6 +5,32 @@
- `NYATERM_WEB_PASSWORD`:至少 32 字符的随机登录密码。 - `NYATERM_WEB_PASSWORD`:至少 32 字符的随机登录密码。
- `NYATERM_WEB_ENCRYPTION_KEY`:Base64 编码的 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:<manifest-digest>
```
版本示例需替换为实际已发布的版本。在仓库根目录执行:
```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 ```sh
@@ -15,4 +41,14 @@ docker compose -f deploy/web/docker-compose.yml down
访问 `http://localhost:8080/` 或 `http://服务器IP:8080/`,使用设置的密码登录。数据保存在 Docker 卷中,普通 `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<SemVer>` 标签时自动构建,也可在 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)。 HTTPS、子路径、备份及其他配置见 [部署指南](../../docs/web-deployment.md)。
+15
View File
@@ -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:
+18 -1
View File
@@ -49,6 +49,23 @@ HTTP 和 HTTPS 地址均可直接使用,无需配置访问 URL 或域名白名
部署文件集中在 [deploy/web/](../deploy/web/README.md)。以下 Docker 和 Compose 命令均在仓库根目录执行;构建上下文仍是仓库根目录,使用根目录的 `.dockerignore`。 部署文件集中在 [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:<manifest-digest>`。更新或回滚时修改镜像引用,再执行 `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 ```sh
docker build -f deploy/web/Dockerfile -t nyaterm-web . docker build -f deploy/web/Dockerfile -t nyaterm-web .
docker volume create nyaterm-data 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 或额外服务。 刷新页面时,按工作区 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 能力矩阵与验收 ## Web Beta 能力矩阵与验收