diff --git a/.github/scripts/test_web_release.py b/.github/scripts/test_web_release.py index 97382df1..d69773cf 100644 --- a/.github/scripts/test_web_release.py +++ b/.github/scripts/test_web_release.py @@ -24,6 +24,7 @@ class VersionTests(unittest.TestCase): self.assertEqual(metadata["version"], version) self.assertEqual(metadata["image_tag"], version) self.assertEqual(metadata["stable"], stable) + self.assertEqual(metadata["publish"], "true") self.assertEqual(metadata["image"], "ghcr.io/nyakang/nyaterm-web") def test_build_metadata_is_preserved_in_version_and_encoded_in_tag(self): @@ -38,6 +39,25 @@ class VersionTests(unittest.TestCase): with self.subTest(ref=ref), self.assertRaises(ValueError): release.release_metadata(ref, "1.2.12", "nyakang/nyaterm") + def test_manual_main_runs_without_publishing(self): + metadata = release.release_metadata( + "refs/heads/main", "1.2.12", "nyakang/nyaterm", "workflow_dispatch" + ) + self.assertEqual(metadata["version"], "1.2.12") + self.assertEqual(metadata["publish"], "false") + + def test_manual_tag_still_publishes(self): + metadata = release.release_metadata( + "refs/tags/v1.2.12", "1.2.12", "nyakang/nyaterm", "workflow_dispatch" + ) + self.assertEqual(metadata["publish"], "true") + + def test_other_branches_cannot_be_manually_released(self): + with self.assertRaises(ValueError): + release.release_metadata( + "refs/heads/feature", "1.2.12", "nyakang/nyaterm", "workflow_dispatch" + ) + def test_invalid_semver_fails(self): for version in [ "1.2", diff --git a/.github/scripts/web-release.py b/.github/scripts/web-release.py index 13d7b0ed..0634bc1c 100644 --- a/.github/scripts/web-release.py +++ b/.github/scripts/web-release.py @@ -33,10 +33,13 @@ def version_parts(version): 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") +def release_metadata(ref, package_version, repository, event_name="push"): + test_only = ref == "refs/heads/main" and event_name == "workflow_dispatch" + if not test_only and not ref.startswith("refs/tags/v"): + raise ValueError( + "Select main for a manual test, or a version tag for a release." + ) + version = package_version if test_only else ref.removeprefix("refs/tags/v") _, prerelease = version_parts(version) if version != package_version: raise ValueError( @@ -51,6 +54,7 @@ def release_metadata(ref, package_version, repository): "version": version, "image_tag": image_tag, "stable": str(prerelease is None).lower(), + "publish": str(not test_only).lower(), "image": f"ghcr.io/{owner}/nyaterm-web", } @@ -199,11 +203,16 @@ def main(): os.environ["GITHUB_REF"], json.loads(Path("package.json").read_text())["version"], os.environ["GITHUB_REPOSITORY"], + os.environ["GITHUB_EVENT_NAME"], ) 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']}") + mode = "release" if metadata["publish"] == "true" else "test (no publishing)" + message = f"Validated Web {mode}: {metadata['version']}" + print(message) + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as summary: + summary.write(message + "\n") elif args.command == "check-manifest": digest = verify_manifest( Path("manifest.json").read_bytes(), diff --git a/.github/workflows/build-web-release.yml b/.github/workflows/build-web-release.yml index a6c2a226..59bd52f6 100644 --- a/.github/workflows/build-web-release.yml +++ b/.github/workflows/build-web-release.yml @@ -22,23 +22,24 @@ jobs: image_tag: ${{ steps.metadata.outputs.image_tag }} image: ${{ steps.metadata.outputs.image }} stable: ${{ steps.metadata.outputs.stable }} + publish: ${{ steps.metadata.outputs.publish }} 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 + - name: Resolve release or manual test mode id: metadata run: python3 .github/scripts/web-release.py metadata - - name: Verify tag commit belongs to main + - name: Verify selected 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." + ref_commit=$(git rev-parse "${GITHUB_REF}^{commit}") + if [ "$ref_commit" != "$(git rev-parse HEAD)" ] || ! git merge-base --is-ancestor "$ref_commit" origin/main; then + echo "::error::The checked-out commit must belong to origin/main." exit 1 fi @@ -111,22 +112,32 @@ jobs: env: NYATERM_SMOKE_IMAGE: nyaterm-web:smoke run: node .github/scripts/web-release-smoke.mjs + - name: Record manual test result + if: needs.prepare.outputs.publish != 'true' + env: + ARCH: ${{ matrix.arch }} + run: | + echo "Manual main test passed: linux/$ARCH, version $VERSION. No image was published." >> "$GITHUB_STEP_SUMMARY" - uses: actions/setup-go@v6 + if: needs.prepare.outputs.publish == 'true' with: go-version: stable cache: false - name: Install crane + if: needs.prepare.outputs.publish == 'true' 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 + if: needs.prepare.outputs.publish == 'true' with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Push tested image by digest + if: needs.prepare.outputs.publish == 'true' env: ARCH: ${{ matrix.arch }} shell: bash @@ -144,6 +155,7 @@ jobs: echo "Image: $IMAGE@$digest" } >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v4 + if: needs.prepare.outputs.publish == 'true' with: name: web-release-digest-${{ matrix.arch }} path: digests/ @@ -159,6 +171,7 @@ jobs: publish: name: Publish multi-architecture version needs: [prepare, build] + if: needs.prepare.outputs.publish == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -209,7 +222,7 @@ jobs: latest: name: Promote newer stable version to latest needs: [prepare, publish] - if: needs.prepare.outputs.stable == 'true' + if: needs.prepare.outputs.publish == 'true' && needs.prepare.outputs.stable == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: diff --git a/deploy/web/README.md b/deploy/web/README.md index aae9d1da..55f42be3 100644 --- a/deploy/web/README.md +++ b/deploy/web/README.md @@ -47,6 +47,8 @@ docker compose -f deploy/web/docker-compose.yml down [Build and Release Web](../../.github/workflows/build-web-release.yml) 在推送 `v` 标签时自动构建,也可在 Actions 中选择版本标签手动补发。标签版本必须与 `package.json` 一致,且提交属于 `main`。镜像发版与桌面发版独立,不等待 GitHub Release 公开。 +测试 workflow 时,在 **Actions → Build and Release Web → Run workflow** 中选择 `main`。此模式运行完整部署 E2E、amd64 / arm64 构建及容器冒烟测试,不登录 GHCR、不发布镜像,也不更新 `latest`;结果显示在 Actions 摘要中。手动选择版本标签仍执行正常发版。Workflow 需先合入默认分支,才会出现在手动运行列表中。 + 完整版本标签不带 `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。 diff --git a/docs/web-deployment.md b/docs/web-deployment.md index cc39cb0f..6ad469a5 100644 --- a/docs/web-deployment.md +++ b/docs/web-deployment.md @@ -62,6 +62,8 @@ docker compose -f deploy/web/docker-compose.image.yml up -d `latest` 仅由更高稳定版本更新;预发布及旧版本补发不会使它回退。首次发布后维护者需确认 GHCR package 为 Public,才能匿名拉取。发版触发条件、版本标签规则和补发入口见 [镜像发版说明](../deploy/web/README.md#镜像发版)。 +如需先测试发版 workflow,可在 Actions 中手动选择 `main`,运行完整部署验收及双架构构建;此模式不发布镜像或修改 `latest`。选择版本标签则正常发版。 + 官方镜像构建路径固定为 `/`,运行时不能仅修改 `NYATERM_WEB_BASE_PATH` 来改为子路径;子路径部署需使用原源码构建方式。镜像 Compose 沿用原来的 `8080:8080`,使用本机反向代理时应改为 `127.0.0.1:8080:8080`。 ### 从源码构建