From 81cdfcfc3e9bff710e7c9fd893a630b50cce16e4 Mon Sep 17 00:00:00 2001 From: Hxgh <3088816598@qq.com> Date: Wed, 3 Jun 2026 12:55:25 +0800 Subject: [PATCH 1/7] feat: add release facts CI workflow --- .github/workflows/sync-live-state.yml | 251 +++++++ README.md | 15 +- .../template-deployment-contract.md | 29 + docs/operations/client-distribution-model.md | 6 +- .../release-and-live-state-model.md | 87 ++- docs/operations/release-status-codes.md | 35 + package.json | 6 +- scripts/lib/release-facts.mjs | 525 ++++++++++++++ scripts/lib/release-status-contract.mjs | 117 +++ .../release/check-client-release-surface.mjs | 67 +- scripts/release/check-client-release.mjs | 67 ++ scripts/release/check-release-status.mjs | 676 ++++++++++++++++++ scripts/release/check-runtime-freshness.mjs | 195 +---- scripts/release/prepare-live-state-pr.mjs | 376 ++++++++++ scripts/release/run-live-state-pr-ci.mjs | 369 ++++++++++ scripts/release/run-release-status-ci.mjs | 234 ++++++ scripts/release/sync-client-release-state.mjs | 313 ++------ scripts/release/sync-live-state.mjs | 154 +--- .../template/check-template-derivation.mjs | 60 ++ tests/client-release-context.test.mjs | 11 +- tests/helpers/release-fixtures.mjs | 232 ++++++ tests/live-state-pr-ci.test.mjs | 166 +++++ tests/live-state-pr.test.mjs | 193 +++++ tests/release-status-ci.test.mjs | 126 ++++ tests/release-status-contract.test.mjs | 43 ++ tests/release-status.test.mjs | 195 +++++ tests/sync-live-state-workflow.test.mjs | 43 ++ 27 files changed, 3985 insertions(+), 606 deletions(-) create mode 100644 .github/workflows/sync-live-state.yml create mode 100644 docs/operations/release-status-codes.md create mode 100644 scripts/lib/release-facts.mjs create mode 100644 scripts/lib/release-status-contract.mjs create mode 100644 scripts/release/check-client-release.mjs create mode 100644 scripts/release/check-release-status.mjs create mode 100644 scripts/release/prepare-live-state-pr.mjs create mode 100644 scripts/release/run-live-state-pr-ci.mjs create mode 100644 scripts/release/run-release-status-ci.mjs create mode 100644 tests/helpers/release-fixtures.mjs create mode 100644 tests/live-state-pr-ci.test.mjs create mode 100644 tests/live-state-pr.test.mjs create mode 100644 tests/release-status-ci.test.mjs create mode 100644 tests/release-status-contract.test.mjs create mode 100644 tests/release-status.test.mjs create mode 100644 tests/sync-live-state-workflow.test.mjs diff --git a/.github/workflows/sync-live-state.yml b/.github/workflows/sync-live-state.yml new file mode 100644 index 0000000..7342b27 --- /dev/null +++ b/.github/workflows/sync-live-state.yml @@ -0,0 +1,251 @@ +# 业务源码仓 liveState 同步入口。 +# deploy 仓完成部署并生成 runtime facts 后,可通过 workflow_dispatch 或 +# repository_dispatch 触发本 workflow。业务仓只消费非敏感 facts,不生成运行事实。 + +name: sync-live-state + +on: + workflow_dispatch: + inputs: + source_repository: + description: Repository that uploaded runtime facts, for example owner/rtnn-deploy. + required: true + type: string + source_run_id: + description: Deploy workflow run id that uploaded runtime facts. + required: true + type: string + runtime_facts_artifact: + description: Runtime facts artifact name. + required: false + default: rtnn-runtime-facts + type: string + runtime_facts_file: + description: Runtime facts JSON path inside downloaded artifact. + required: false + default: runtime-facts.json + type: string + client_artifacts_artifact: + description: Optional client release artifacts name. + required: false + default: "" + type: string + environment: + description: Environment to check or sync. Empty means all environments in facts. + required: false + default: "" + type: string + mode: + description: Run read-only status or prepare a liveState-only PR. + required: false + default: status + type: choice + options: + - status + - prepare-pr + base_branch: + description: PR base branch. + required: false + default: main + type: string + create_pr: + description: Create PR when mode=prepare-pr and liveState changed. + required: false + default: true + type: boolean + repository_dispatch: + types: + - sync-rtnn-live-state + +permissions: + actions: read + contents: write + pull-requests: write + +concurrency: + group: sync-live-state-${{ github.event.client_payload.environment || inputs.environment || github.ref }} + cancel-in-progress: false + +jobs: + sync-live-state: + name: Sync Live State + if: ${{ vars.RTNN_BUSINESS_SOURCE_WORKFLOWS_ENABLED == 'true' }} + runs-on: ${{ vars.RTNN_RELEASE_EXECUTION_MODE == 'github-hosted' && 'ubuntu-latest' || 'self-hosted' }} + timeout-minutes: 20 + + steps: + - name: Resolve inputs + id: input + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_SOURCE_RUN_ID: ${{ inputs.source_run_id }} + INPUT_SOURCE_REPOSITORY: ${{ inputs.source_repository }} + INPUT_RUNTIME_ARTIFACT: ${{ inputs.runtime_facts_artifact }} + INPUT_RUNTIME_FILE: ${{ inputs.runtime_facts_file }} + INPUT_CLIENT_ARTIFACT: ${{ inputs.client_artifacts_artifact }} + INPUT_ENVIRONMENT: ${{ inputs.environment }} + INPUT_MODE: ${{ inputs.mode }} + INPUT_BASE_BRANCH: ${{ inputs.base_branch }} + INPUT_CREATE_PR: ${{ inputs.create_pr }} + PAYLOAD_SOURCE_RUN_ID: ${{ github.event.client_payload.source_run_id }} + PAYLOAD_SOURCE_REPOSITORY: ${{ github.event.client_payload.source_repository }} + PAYLOAD_RUNTIME_ARTIFACT: ${{ github.event.client_payload.runtime_facts_artifact }} + PAYLOAD_RUNTIME_FILE: ${{ github.event.client_payload.runtime_facts_file }} + PAYLOAD_CLIENT_ARTIFACT: ${{ github.event.client_payload.client_artifacts_artifact }} + PAYLOAD_ENVIRONMENT: ${{ github.event.client_payload.environment }} + PAYLOAD_MODE: ${{ github.event.client_payload.mode }} + PAYLOAD_BASE_BRANCH: ${{ github.event.client_payload.base_branch }} + PAYLOAD_CREATE_PR: ${{ github.event.client_payload.create_pr }} + run: | + if [[ "${EVENT_NAME}" == "repository_dispatch" ]]; then + source_run_id="${PAYLOAD_SOURCE_RUN_ID}" + source_repository="${PAYLOAD_SOURCE_REPOSITORY}" + runtime_artifact="${PAYLOAD_RUNTIME_ARTIFACT:-rtnn-runtime-facts}" + runtime_file="${PAYLOAD_RUNTIME_FILE:-runtime-facts.json}" + client_artifact="${PAYLOAD_CLIENT_ARTIFACT}" + environment="${PAYLOAD_ENVIRONMENT}" + mode="${PAYLOAD_MODE:-status}" + base_branch="${PAYLOAD_BASE_BRANCH:-main}" + create_pr="${PAYLOAD_CREATE_PR:-true}" + else + source_run_id="${INPUT_SOURCE_RUN_ID}" + source_repository="${INPUT_SOURCE_REPOSITORY}" + runtime_artifact="${INPUT_RUNTIME_ARTIFACT:-rtnn-runtime-facts}" + runtime_file="${INPUT_RUNTIME_FILE:-runtime-facts.json}" + client_artifact="${INPUT_CLIENT_ARTIFACT}" + environment="${INPUT_ENVIRONMENT}" + mode="${INPUT_MODE:-status}" + base_branch="${INPUT_BASE_BRANCH:-main}" + create_pr="${INPUT_CREATE_PR:-true}" + fi + + [[ -n "${source_run_id}" ]] || { echo "source_run_id is required"; exit 1; } + [[ -n "${source_repository}" ]] || { echo "source_repository is required"; exit 1; } + + { + echo "source_run_id=${source_run_id}" + echo "source_repository=${source_repository}" + echo "runtime_artifact=${runtime_artifact}" + echo "runtime_file=${runtime_file}" + echo "client_artifact=${client_artifact}" + echo "environment=${environment}" + echo "mode=${mode}" + echo "base_branch=${base_branch}" + echo "create_pr=${create_pr}" + } >> "${GITHUB_OUTPUT}" + + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 10.17.0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: | + package.json + pnpm-workspace.yaml + apps/admin/package.json + apps/app/package.json + apps/backend/package.json + apps/weapp/package.json + clients/*/package.json + packages/*/package.json + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Download runtime facts + uses: actions/download-artifact@v4 + with: + run-id: ${{ steps.input.outputs.source_run_id }} + repository: ${{ steps.input.outputs.source_repository }} + github-token: ${{ secrets.DEPLOY_REPOSITORY_READ_TOKEN || secrets.DEPLOY_REPOSITORY_DISPATCH_TOKEN || secrets.GITHUB_TOKEN }} + name: ${{ steps.input.outputs.runtime_artifact }} + path: artifacts/runtime-facts + + - name: Download client release artifacts + if: ${{ steps.input.outputs.client_artifact != '' }} + uses: actions/download-artifact@v4 + with: + run-id: ${{ steps.input.outputs.source_run_id }} + repository: ${{ steps.input.outputs.source_repository }} + github-token: ${{ secrets.DEPLOY_REPOSITORY_READ_TOKEN || secrets.DEPLOY_REPOSITORY_DISPATCH_TOKEN || secrets.GITHUB_TOKEN }} + name: ${{ steps.input.outputs.client_artifact }} + path: artifacts/client-release + + - name: Release status + id: status + continue-on-error: ${{ steps.input.outputs.mode == 'prepare-pr' }} + shell: bash + env: + RUNTIME_FILE: artifacts/runtime-facts/${{ steps.input.outputs.runtime_file }} + CLIENT_ARTIFACT: ${{ steps.input.outputs.client_artifact }} + ENVIRONMENT: ${{ steps.input.outputs.environment }} + run: | + args=(--facts-file "${RUNTIME_FILE}" --output-dir artifacts/release-status) + if [[ -n "${ENVIRONMENT}" ]]; then + args+=(--environment "${ENVIRONMENT}") + fi + if [[ -n "${CLIENT_ARTIFACT}" ]]; then + args+=(--client-artifacts-dir artifacts/client-release) + fi + + node scripts/release/run-release-status-ci.mjs "${args[@]}" + + - name: Block invalid release facts before PR + if: ${{ steps.input.outputs.mode == 'prepare-pr' && (steps.status.outputs.status == 'blocked' || steps.status.outputs.status == '') }} + shell: bash + run: | + echo "release status is blocked; refusing to prepare liveState PR" + echo "code=${{ steps.status.outputs.code }}" + exit 1 + + - name: Prepare liveState PR + id: live_state_pr + if: ${{ steps.input.outputs.mode == 'prepare-pr' && steps.status.outputs.status != 'blocked' && steps.status.outputs.status != '' }} + shell: bash + env: + RUNTIME_FILE: artifacts/runtime-facts/${{ steps.input.outputs.runtime_file }} + CLIENT_ARTIFACT: ${{ steps.input.outputs.client_artifact }} + ENVIRONMENT: ${{ steps.input.outputs.environment }} + BASE_BRANCH: ${{ steps.input.outputs.base_branch }} + CREATE_PR: ${{ steps.input.outputs.create_pr }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + args=(--facts-file "${RUNTIME_FILE}" --output-dir artifacts/live-state-pr --base-branch "${BASE_BRANCH}") + if [[ -n "${ENVIRONMENT}" ]]; then + args+=(--environment "${ENVIRONMENT}") + fi + if [[ -n "${CLIENT_ARTIFACT}" ]]; then + args+=(--client-artifacts-dir artifacts/client-release) + fi + if [[ "${CREATE_PR}" == "true" ]]; then + args+=(--create-pr) + else + args+=(--no-push) + fi + + node scripts/release/run-live-state-pr-ci.mjs "${args[@]}" + + - name: Upload release status + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: rtnn-release-status + path: artifacts/release-status + + - name: Upload liveState PR summary + if: ${{ always() && steps.input.outputs.mode == 'prepare-pr' }} + uses: actions/upload-artifact@v4 + with: + name: rtnn-live-state-pr + path: artifacts/live-state-pr diff --git a/README.md b/README.md index 88dad23..48c8379 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ pnpm run template:init -- --project-id=acme --brand-name=ACME --rewrite-source - - 业务仓手动执行 `promote-production` 发起正式发布 - 上游模板仓 `rtnn` 默认不直接拥有任何业务环境发布权 - `rtnn` 保留 `release-images / promote-production` 作为模板源码的一部分,但只有 `project.role=business-source` 的业务仓会真正执行它们 -- deploy 仓生成运行事实报告,业务仓用 `pnpm run release:sync-live-state` 校验或写回 `.rtnn/project.json liveState` +- deploy 仓生成运行事实报告,业务仓用 `pnpm run release:status` 只读校验线上状态,并用显式 sync/PR 准备命令写回 `.rtnn/project.json liveState` ## 验收入口 @@ -112,8 +112,13 @@ pnpm run check:quick pnpm run check:backend-release pnpm run check:template-bootstrap pnpm run check:template-derivation +pnpm run check:client-release pnpm run profile:doctor pnpm run check:release-candidate +pnpm run release:status -- --facts-file /tmp/rtnn-runtime-facts.json +pnpm run release:status -- --facts-file /tmp/rtnn-runtime-facts.json --summary-md --output /tmp/rtnn-release-status.json +pnpm run release:status:ci -- --facts-file /tmp/rtnn-runtime-facts.json --output-dir /tmp/rtnn-release-status +pnpm run release:prepare-live-state-pr:ci -- --facts-file /tmp/rtnn-runtime-facts.json --environment testing --no-push pnpm run release:check-runtime-freshness -- --facts-file /tmp/rtnn-runtime-facts.json pnpm run smoke:admin pnpm run check @@ -122,8 +127,14 @@ pnpm run check - `check:quick`:不主动启动 PostgreSQL,覆盖 lint、typecheck、admin UI 规则、模板中立性与契约漂移。 - `check:backend-release`:会在本地数据库配置下启动 PostgreSQL,执行测试 schema 残留预检、backend 发布基线、integration/e2e 并行隔离检查与测试后残留审计。 - `check:release-candidate`:发布候选入口,覆盖模板派生、契约、backend 发布门禁;设置 `RTNN_RUN_UI_SMOKE=true` 后追加多端 UI smoke。 +- `check:client-release`:客户端发布链路统一检查入口,覆盖 release facts 解析、client liveState、release status、liveState PR 准备、surface gate 与相关脚本测试。 - `profile:doctor`:读取 `.rtnn/project.json` 和 project profile,输出启用服务、客户端构建目标、警告与接入风险,适合业务仓初次接入或配置回归检查。 -- `release:check-runtime-freshness`:读取部署仓 runtime facts,判断 `.rtnn/project.json liveState` 是否代表线上实际版本;只读不写,写回仍使用 `release:sync-live-state`。 +- `release:status`:回答“线上是否最新”的只读统一入口,输出稳定 `status/code/findings`,支持 `--summary-md` 和 `--output` 供 CI/PR comment 消费;不写回 `.rtnn/project.json`。 +- `release:check-runtime-freshness`:底层 runtime freshness gate,读取部署仓 runtime facts,判断 `.rtnn/project.json liveState` 是否代表线上实际版本;只读不写,写回仍使用 `release:sync-live-state`。 +- `release:prepare-live-state-pr`:CI 用 liveState-only PR 准备入口,只允许改写 `.rtnn/project.json liveState`,不提交、不推送、不创建 PR。 +- `release:status:ci`:GitHub Actions / deploy 回调入口,运行 `release:status` 并写出 JSON、Markdown、GitHub outputs 和 step summary。 +- `release:prepare-live-state-pr:ci`:liveState-only PR 编排入口,负责 branch/commit/push/可选 PR;写回逻辑仍由 `release:prepare-live-state-pr` 控制。 +- `.github/workflows/sync-live-state.yml`:从 deploy 仓 workflow artifact 下载 runtime facts,并选择只读检查或准备 liveState-only PR;状态判断必须使用机器字段 `status/code`,code 语义见 `docs/operations/release-status-codes.md`。 - `smoke:*:ui`:CI 中使用 Playwright Chromium;Codex App 普通本地页面核验优先使用内置 Browser,不为普通本地验收安装 Chromium;显式设置 `RTNN_RUN_UI_SMOKE=true` 时缺浏览器会失败。 - `check`:本地完整质量门禁,覆盖静态检查、模板派生、契约、backend 发布门禁与多端构建。 diff --git a/docs/architecture/template-deployment-contract.md b/docs/architecture/template-deployment-contract.md index 8fc8277..85979e1 100644 --- a/docs/architecture/template-deployment-contract.md +++ b/docs/architecture/template-deployment-contract.md @@ -100,8 +100,37 @@ backend 必须公开以下无鉴权探活/版本端点: 业务源码仓负责执行: ```bash +pnpm run release:status -- --facts-file /tmp/rtnn-runtime-facts.json +pnpm run release:status -- --facts-file /tmp/rtnn-runtime-facts.json --summary-md --output /tmp/rtnn-release-status.json pnpm run release:sync-live-state -- --facts-file /tmp/rtnn-runtime-facts.json --check pnpm run release:sync-live-state -- --facts-file /tmp/rtnn-runtime-facts.json --write +pnpm run release:prepare-live-state-pr -- --facts-file /tmp/rtnn-runtime-facts.json --summary-md /tmp/live-state-pr.md --json +pnpm run release:status:ci -- --facts-file /tmp/rtnn-runtime-facts.json --output-dir /tmp/rtnn-release-status +pnpm run release:prepare-live-state-pr:ci -- --facts-file /tmp/rtnn-runtime-facts.json --environment testing --no-push ``` `liveState` 是业务仓的非敏感事实,不是 deploy 仓的发布决策来源。 +`release:status` 是只读入口,用来回答线上 runtime facts 是否与业务仓 +`liveState` 一致;写回必须显式使用 sync 命令,或由 CI 使用 +`release:prepare-live-state-pr` 准备 liveState-only PR。 + +业务源码仓还提供 `.github/workflows/sync-live-state.yml`。部署仓可在完成 +deploy/smoke 后上传 `rtnn-runtime-facts` artifact,并以 +`repository_dispatch` 的 `sync-rtnn-live-state` 事件触发业务仓: + +```json +{ + "event_type": "sync-rtnn-live-state", + "client_payload": { + "source_repository": "owner/rtnn-deploy", + "source_run_id": "1234567890", + "runtime_facts_artifact": "rtnn-runtime-facts", + "runtime_facts_file": "runtime-facts.json", + "environment": "testing", + "mode": "prepare-pr" + } +} +``` + +`mode=status` 只产出 release status artifact;`mode=prepare-pr` 会在业务仓 +准备 liveState-only PR。CI 判断必须读取 `status/code`,不要解析人类文案。 diff --git a/docs/operations/client-distribution-model.md b/docs/operations/client-distribution-model.md index a8526f4..fa3bbfe 100644 --- a/docs/operations/client-distribution-model.md +++ b/docs/operations/client-distribution-model.md @@ -51,8 +51,10 @@ The release center should reflect runtime facts rather than invent a parallel release truth. When a deploy executor syncs client release facts, the backend stores accepted package metadata and the business repository can mirror non-sensitive client state into `.rtnn/project.json liveState..clients`. -Use `release:sync-client-live-state` for the write-back/check flow and -`release:check-runtime-freshness` for environment freshness. +Use `release:status` for the read-only operator answer, optionally passing +`--client-artifacts-dir` to include client release facts. Use +`release:sync-client-live-state` only for the explicit write-back/check flow, or +`release:prepare-live-state-pr` when CI should prepare a liveState-only PR. ## Build Operations diff --git a/docs/operations/release-and-live-state-model.md b/docs/operations/release-and-live-state-model.md index 48a0eb3..161a4b9 100644 --- a/docs/operations/release-and-live-state-model.md +++ b/docs/operations/release-and-live-state-model.md @@ -34,25 +34,88 @@ Business project metadata may include a `liveState` section, but it should be tr Do not maintain live state manually in README files, chat notes, or ad hoc documents. -Use the read-only freshness gate when answering whether an environment is -actually on the expected release: +Use the read-only status gate when answering whether an environment is actually +on the expected release: ```bash -pnpm run release:check-runtime-freshness -- --facts-file /tmp/rtnn-runtime-facts.json -pnpm run release:check-runtime-freshness -- --facts-file /tmp/rtnn-runtime-facts.json --environment testing +pnpm run release:status -- --facts-file /tmp/rtnn-runtime-facts.json +pnpm run release:status -- --facts-file /tmp/rtnn-runtime-facts.json --environment testing +pnpm run release:status -- --facts-file /tmp/rtnn-runtime-facts.json --environment testing --client-artifacts-dir /tmp/client-release +pnpm run release:status -- --facts-file /tmp/rtnn-runtime-facts.json --summary-md --output /tmp/rtnn-release-status.json +pnpm run release:status:ci -- --facts-file /tmp/rtnn-runtime-facts.json --output-dir /tmp/rtnn-release-status ``` -If the freshness gate reports stale state, either the environment is not running -the expected release or `liveState` has not been refreshed from the deploy -executor. After verifying the deploy facts, update the derived snapshot with: +`release:status` combines the project profile preflight, runtime freshness, and +optional client release liveState checks. It never writes project metadata. Its +JSON output uses stable top-level `status`, `code`, `summary`, `checks`, and +`findings` fields. Valid status values are `fresh`, `stale`, `blocked`, and +`skipped`; CI should make decisions from `status` / `code`, not from human text. + +If the status gate reports stale runtime state, either the environment is not +running the expected release or `liveState` has not been refreshed from the +deploy executor. After verifying the deploy facts, update the derived snapshot +with: ```bash pnpm run release:sync-live-state -- --facts-file /tmp/rtnn-runtime-facts.json --write ``` -`release:check-runtime-freshness` never writes project metadata. It is intended -for CI gates, operator checks, and quick answers to "is production/testing -latest?". +If the status gate reports stale client liveState, verify the client release +artifacts and then update the derived snapshot with: + +```bash +pnpm run release:sync-client-live-state -- --artifacts-dir /tmp/client-release --environment testing --write +``` + +For CI-driven write-back, prepare a liveState-only PR working tree instead of +silently writing to the main branch: + +```bash +pnpm run release:prepare-live-state-pr -- --facts-file /tmp/rtnn-runtime-facts.json --environment testing --client-artifacts-dir /tmp/client-release --summary-md /tmp/live-state-pr.md --json +``` + +`release:prepare-live-state-pr` only writes `.rtnn/project.json liveState`. It +does not commit, push, or create a PR. The caller is responsible for running a +liveState-only change check before opening a PR. + +`release:check-runtime-freshness` remains the lower-level runtime-only gate for +CI jobs that do not need the profile or client release checks. + +## CI Artifact Flow + +Deploy repositories should upload runtime facts as workflow artifacts and then +trigger the business repository `sync-live-state` workflow. The business +repository never invents runtime facts. It only downloads the deploy artifact and +runs the same local release status contracts. + +Manual workflow dispatch and repository dispatch both support: + +- `source_run_id`: deploy workflow run id that uploaded facts; +- `source_repository`: repository that uploaded facts, for example + `owner/rtnn-deploy`; +- `runtime_facts_artifact`: runtime facts artifact name, default + `rtnn-runtime-facts`; +- `runtime_facts_file`: JSON file inside the artifact, default + `runtime-facts.json`; +- `client_artifacts_artifact`: optional client release artifact name; +- `environment`: optional environment filter; +- `mode`: `status` or `prepare-pr`. + +`mode=status` runs `release:status:ci` and uploads `rtnn-release-status` +containing: + +- `release-status.json`; +- `release-status.md`. + +`mode=prepare-pr` first runs the same status check, then runs +`release:prepare-live-state-pr:ci`. If liveState changed, the CI helper creates a +branch, commits only `.rtnn/project.json`, optionally pushes it, and can create a +PR with the generated summary. If nothing changed, it emits `changed=false` and +does not commit. + +The generated PR must remain liveState-only. CI should still run +`detect-live-state-only-change` or equivalent branch policy before merging. Code +semantics are documented in `docs/operations/release-status-codes.md`. ## Verification Layers @@ -65,6 +128,10 @@ Local and CI verification are intentionally separated: - `profile:doctor` is the business-repository entry point for checking which services, client targets, and release modes are actually enabled before any deploy or smoke work starts. +- `release:status` is the operator entry point for answering whether the live + environment and optional client release facts are fresh. +- `check:client-release` is a JS orchestrator so release checks keep labeled + steps rather than a long package-script command chain. The local Playwright wrapper fails in CI or when `RTNN_RUN_UI_SMOKE=true` and Chromium is missing. Ordinary local smoke commands skip early with a message that diff --git a/docs/operations/release-status-codes.md b/docs/operations/release-status-codes.md new file mode 100644 index 0000000..aa3b11e --- /dev/null +++ b/docs/operations/release-status-codes.md @@ -0,0 +1,35 @@ +# Release Status Codes + +`release:status` and `release:status:ci` expose stable `status` and `code` +fields. CI and deploy integrations should branch on these fields instead of +parsing human-readable messages. + +| Code | Status | Meaning | Next action | +| --- | --- | --- | --- | +| `OK` | `fresh` | All enabled checks passed. | No liveState write-back is needed. | +| `PROFILE_SKIPPED` | `skipped` | The caller explicitly skipped profile preflight. | Use only when an equivalent profile gate already ran. | +| `PROFILE_WARNING` | `blocked` | Project profile warnings are blocking under strict profile mode. | Fix delivery/profile metadata and rerun. | +| `PROFILE_ERROR` | `blocked` | Project profile cannot be resolved. | Fix `.rtnn/project.json` or template environment. | +| `MISSING_PROJECT_METADATA` | `blocked` | `.rtnn/project.json` is missing. | Run template initialization or sync project metadata. | +| `INVALID_PROJECT_METADATA` | `blocked` | Project metadata violates the business repository contract. | Fix repository, deployment, environment, or release execution metadata. | +| `RUNTIME_FACTS_MISSING` | `blocked` | No runtime facts file was provided. | Download or pass the deploy runtime facts artifact. | +| `RUNTIME_FACTS_INVALID` | `blocked` | Runtime facts JSON, schema, or environment data cannot be parsed. | Fix deploy facts generation or pass the expected environment. | +| `RUNTIME_FACTS_UNSAFE` | `blocked` | Runtime facts contain suspected secrets, tokens, or connection strings. | Stop write-back, clean artifacts, and fix deploy output boundaries. | +| `RUNTIME_BINDING_MISMATCH` | `blocked` | Runtime facts binding does not match the business repository. | Check source repository, application, image prefix, and event configuration. | +| `RUNTIME_FACTS_STALE` | `stale` | Runtime facts and `.rtnn/project.json liveState` differ. | Confirm the live runtime, then prepare a liveState-only PR or debug deployment. | +| `CLIENT_ARTIFACTS_MISSING` | `blocked` | Client release checking was requested without artifacts. | Download release-clients artifacts. | +| `CLIENT_ARTIFACTS_INVALID` | `blocked` | Client release artifacts cannot be parsed or have no valid manifest. | Regenerate client release artifacts. | +| `CLIENT_LIVE_STATE_STALE` | `stale` | Client release facts and `liveState..clients` differ. | Confirm client facts, then prepare a liveState-only PR. | +| `CLIENT_LIVE_STATE_SKIPPED` | `skipped` | Client release checking was skipped because no artifacts were provided. | Keep skipped for runtime-only checks, or pass client artifacts. | + +Status semantics: + +- `fresh`: the checked runtime/client facts match the business repository. +- `stale`: the facts are valid but differ from the derived `liveState`. +- `blocked`: a contract, safety, or input problem prevents a trustworthy answer. +- `skipped`: an optional check was explicitly or implicitly skipped. + +`stale` is not automatically safe to write back. It means the facts and +business repository disagree. Write-back should happen only through +`release:prepare-live-state-pr` or `release:prepare-live-state-pr:ci`, and the +resulting PR must remain liveState-only. diff --git a/package.json b/package.json index 2e63f1f..c53719d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "generate:sdk": "pnpm run contracts:sync", "release:sync-live-state": "node scripts/release/sync-live-state.mjs", "release:check-runtime-freshness": "node scripts/release/check-runtime-freshness.mjs", + "release:status": "node scripts/release/check-release-status.mjs", + "release:prepare-live-state-pr": "node scripts/release/prepare-live-state-pr.mjs", + "release:status:ci": "node scripts/release/run-release-status-ci.mjs", + "release:prepare-live-state-pr:ci": "node scripts/release/run-live-state-pr-ci.mjs", "release:sync-client-live-state": "node scripts/release/sync-client-release-state.mjs", "profile:doctor": "node scripts/template/profile-doctor.mjs", "lint": "node scripts/runtime/run-profiled-task.mjs lint", @@ -49,7 +53,7 @@ "check:template-bootstrap": "node scripts/bootstrap/check-template-bootstrap.mjs", "check:clients": "node scripts/client/check-tauri-clients.mjs && node scripts/client/check-app-mobile-boundary.mjs", "check:admin-ui": "node scripts/admin/check-admin-ui-patterns.mjs", - "check:client-release": "node --check scripts/release/resolve-client-release-context.mjs && node --check scripts/release/prepare-tauri-remote-web-url.mjs && node --check scripts/release/write-client-release-manifest.mjs && node --check scripts/release/collect-client-artifacts.mjs && node --check scripts/release/check-client-artifact-urls.mjs && node --check scripts/client/check-android-apk-package.mjs && node --check scripts/release/prepare-tauri-updater-signing.mjs && node --check scripts/release/prepare-android-signing.mjs && node --check scripts/release/prime-android-gradle.mjs && node --check scripts/release/prepare-google-play-upload.mjs && node --check scripts/release/write-google-play-release-report.mjs && node --check scripts/release/prepare-ios-signing.mjs && node --check scripts/release/prepare-app-store-connect-upload.mjs && node --check scripts/release/write-app-store-connect-release-report.mjs && node --check scripts/release/write-tauri-updater-manifest.mjs && node --check scripts/release/merge-tauri-updater-fragments.mjs && node --check scripts/release/collect-client-github-release-assets.mjs && node --check scripts/release/write-mobile-release-boundary.mjs && node --check scripts/release/check-client-build-capacity.mjs && node --check scripts/release/with-client-build-lock.mjs && node --check scripts/release/cleanup-client-build-artifacts.mjs && node --check scripts/release/sync-client-release-state.mjs && node --check scripts/release/check-client-release-github-prereqs.mjs && node --check scripts/release/run-client-release-github-dry-run.mjs && node scripts/release/check-client-release-surface.mjs && node --test tests/client-build-lock.test.mjs && node --test tests/client-release-context.test.mjs && node --test tests/client-release-state.test.mjs && node --test tests/client-release-github-prereqs.test.mjs && node --test tests/tauri-remote-web-url.test.mjs && node --test tests/admin-client-release-policy-action.test.mjs && node --test tests/android-gradle-prime.test.mjs", + "check:client-release": "node scripts/release/check-client-release.mjs", "release:clients:github-dry-run": "node scripts/release/run-client-release-github-dry-run.mjs --channel testing --watch", "check:template-neutrality": "node scripts/template/check-template-neutrality.mjs", "check:native-bridge": "pnpm --filter @rtnn/native-bridge build && node --test tests/native-bridge.test.mjs && node --test tests/app-native-core.test.mjs", diff --git a/scripts/lib/release-facts.mjs b/scripts/lib/release-facts.mjs new file mode 100644 index 0000000..e4077ff --- /dev/null +++ b/scripts/lib/release-facts.mjs @@ -0,0 +1,525 @@ +import { + existsSync, + readdirSync, + readFileSync, +} from "node:fs"; +import path from "node:path"; + +export const RUNTIME_FACTS_SCHEMA_VERSION = + "rtnn.deploy.runtime-facts.v1"; +export const CLIENT_RELEASE_MANIFEST_SCHEMA_VERSION = + "rtnn.client-release.v1"; + +const SENSITIVE_KEY_PATTERN = + /token|secret|password|authorization|cookie|database_?url|connection_?string|ssh/i; + +export function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function readJsonFile(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +export function sortObject(value) { + if (Array.isArray(value)) { + return value.map(sortObject); + } + + if (!isPlainObject(value)) { + return value; + } + + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, sortObject(value[key])]), + ); +} + +export function stableStringify(value) { + return JSON.stringify(sortObject(value)); +} + +export function normalizeMetadataWithoutLiveState(metadata) { + if (!isPlainObject(metadata)) { + return metadata; + } + + const normalized = { ...metadata }; + delete normalized.liveState; + return normalized; +} + +export function isMetadataLiveStateOnlyChange(beforeMetadata, afterMetadata) { + return ( + stableStringify(normalizeMetadataWithoutLiveState(beforeMetadata)) === + stableStringify(normalizeMetadataWithoutLiveState(afterMetadata)) + ); +} + +export function readRuntimeFacts(factsFile) { + const report = readJsonFile(factsFile); + + if (report.schemaVersion !== RUNTIME_FACTS_SCHEMA_VERSION) { + throw new Error("runtime facts schemaVersion 不匹配"); + } + + if (!Array.isArray(report.environments)) { + throw new Error("runtime facts 缺少 environments 数组"); + } + + return report; +} + +function collectSensitiveKeyPaths(value, prefix = "") { + if (Array.isArray(value)) { + return value.flatMap((item, index) => + collectSensitiveKeyPaths(item, `${prefix}[${index}]`), + ); + } + + if (!isPlainObject(value)) { + return []; + } + + const paths = []; + for (const [key, child] of Object.entries(value)) { + const nextPath = prefix ? `${prefix}.${key}` : key; + if (SENSITIVE_KEY_PATTERN.test(key)) { + paths.push(nextPath); + continue; + } + paths.push(...collectSensitiveKeyPaths(child, nextPath)); + } + return paths; +} + +export function assertRuntimeFactsSafe(report) { + const sensitivePaths = collectSensitiveKeyPaths(report); + if (sensitivePaths.length > 0) { + throw new Error( + `runtime facts 包含疑似敏感字段: ${sensitivePaths.join(", ")}`, + ); + } +} + +export function assertRuntimeBindingMatches(metadata, report) { + const errors = []; + const binding = isPlainObject(report.binding) ? report.binding : {}; + const expected = { + sourceRepository: metadata.project.repo, + application: metadata.deployment.application, + imageNamePrefix: metadata.deployment.imageNamePrefix, + dispatchEventType: metadata.deployment.dispatchEventType, + }; + + for (const [key, value] of Object.entries(expected)) { + if (binding[key] !== value) { + errors.push(`${key}: ${value} != ${binding[key] ?? "(missing)"}`); + } + } + + if (errors.length > 0) { + throw new Error(`runtime facts 绑定关系不匹配: ${errors.join(";")}`); + } +} + +export function selectRuntimeEnvironmentFacts(report, requestedEnvironments) { + const factsByEnvironment = new Map( + report.environments.map((environmentFact) => [ + environmentFact.environment, + environmentFact, + ]), + ); + const environments = + requestedEnvironments.length > 0 + ? requestedEnvironments + : report.environments.map((environmentFact) => environmentFact.environment); + + return environments.map((environment) => { + const environmentFact = factsByEnvironment.get(environment); + if (!environmentFact) { + throw new Error(`runtime facts 缺少环境: ${environment}`); + } + return environmentFact; + }); +} + +export function readObservedRuntimeVersion(environmentFact) { + const versionResult = environmentFact.health?.results?.version; + const body = versionResult?.body; + + if (!versionResult?.ok || !isPlainObject(body)) { + return { + deployVersion: "", + sourceSha: "", + }; + } + + return { + deployVersion: String(body.version ?? "").trim(), + sourceSha: String(body.sourceSha ?? "").trim(), + }; +} + +export function buildObservedRuntimeState(environmentFact) { + const release = isPlainObject(environmentFact.release) + ? environmentFact.release + : {}; + const observedVersion = readObservedRuntimeVersion(environmentFact); + const activeRelease = + observedVersion.deployVersion || String(release.deployVersion ?? "").trim(); + const sourceSha = + observedVersion.sourceSha || String(release.sourceSha ?? "").trim(); + + return { + activeRelease, + sourceSha, + health: { + version: Boolean(environmentFact.health?.results?.version?.ok), + readyz: Boolean(environmentFact.health?.results?.readyz?.ok), + healthz: Boolean(environmentFact.health?.results?.healthz?.ok), + }, + }; +} + +export function compareRuntimeEnvironment(metadata, environmentFact) { + const environment = environmentFact.environment; + const current = metadata.liveState?.[environment] ?? {}; + const observed = buildObservedRuntimeState(environmentFact); + const mismatches = []; + + if (!observed.activeRelease) { + mismatches.push({ + field: "activeRelease", + expected: current.activeRelease ?? "", + actual: "", + reason: "runtime facts 缺少可识别 DEPLOY_VERSION 或 /version.version", + }); + } else if (current.activeRelease !== observed.activeRelease) { + mismatches.push({ + field: "activeRelease", + expected: current.activeRelease ?? "", + actual: observed.activeRelease, + }); + } + + if (observed.sourceSha && current.sourceSha !== observed.sourceSha) { + mismatches.push({ + field: "sourceSha", + expected: current.sourceSha ?? "", + actual: observed.sourceSha, + }); + } + + return { + environment, + fresh: mismatches.length === 0, + current: { + activeRelease: current.activeRelease ?? "", + sourceSha: current.sourceSha ?? "", + }, + observed, + mismatches, + }; +} + +export function compareRuntimeLiveState( + metadata, + report, + requestedEnvironments = [], +) { + return selectRuntimeEnvironmentFacts(report, requestedEnvironments).map( + (environmentFact) => compareRuntimeEnvironment(metadata, environmentFact), + ); +} + +export function buildDesiredRuntimeLiveState(environmentFact) { + const release = isPlainObject(environmentFact.release) + ? environmentFact.release + : {}; + const observedVersion = readObservedRuntimeVersion(environmentFact); + const deployVersion = + observedVersion.deployVersion || String(release.deployVersion ?? "").trim(); + const sourceSha = + observedVersion.sourceSha || String(release.sourceSha ?? "").trim(); + + if (!environmentFact.source?.exists && !observedVersion.deployVersion) { + throw new Error(`${environmentFact.environment} 缺少可用 runtime source`); + } + + if (!deployVersion) { + throw new Error(`${environmentFact.environment} 缺少 DEPLOY_VERSION`); + } + + return { + activeRelease: deployVersion, + ...(sourceSha ? { sourceSha } : {}), + }; +} + +export function diffRuntimeLiveState(current, desired) { + const changes = []; + + for (const [key, value] of Object.entries(desired)) { + if (current?.[key] !== value) { + changes.push({ key, before: current?.[key] ?? "", after: value }); + } + } + + return changes; +} + +export function collectRuntimeLiveStateChanges( + metadata, + report, + requestedEnvironments = [], +) { + return selectRuntimeEnvironmentFacts(report, requestedEnvironments).map( + (environmentFact) => { + const desired = buildDesiredRuntimeLiveState(environmentFact); + const current = metadata.liveState?.[environmentFact.environment] ?? {}; + const changes = diffRuntimeLiveState(current, desired); + + return { + environment: environmentFact.environment, + desired, + current, + changes, + }; + }, + ); +} + +function listJsonFiles(dir) { + if (!existsSync(dir)) { + return []; + } + + return readdirSync(dir) + .filter((fileName) => fileName.endsWith(".json")) + .sort() + .map((fileName) => path.join(dir, fileName)); +} + +function readReleaseManifests(artifactsDir) { + const manifests = listJsonFiles(artifactsDir) + .map(readJsonFile) + .filter((item) => item.schemaVersion === CLIENT_RELEASE_MANIFEST_SCHEMA_VERSION); + + if (manifests.length === 0) { + throw new Error("客户端 release artifacts 缺少 rtnn.client-release.v1 manifest"); + } + + return manifests; +} + +function readNamedReports(artifactsDir, subdir, schemaVersion) { + const reports = new Map(); + for (const report of listJsonFiles(path.join(artifactsDir, subdir)).map( + readJsonFile, + )) { + if (report.schemaVersion === schemaVersion) { + reports.set(report.artifactName, report); + } + } + + return reports; +} + +function readUpdaterIndex(artifactsDir) { + const indexPath = path.join(artifactsDir, "updater", "index.json"); + if (!existsSync(indexPath)) { + return new Map(); + } + + const index = readJsonFile(indexPath); + if (index.schemaVersion !== "rtnn.tauri-updater-index.v1") { + return new Map(); + } + + return new Map( + index.manifests.map((item) => [ + item.shell, + { + file: item.file, + version: item.version, + platforms: item.platforms, + }, + ]), + ); +} + +export function buildDesiredClientLiveState( + manifests, + mobileReports, + desktopSigningReports, + googlePlayReports, + appStoreConnectReports, + updaterByShell, +) { + const clients = {}; + + for (const manifest of manifests) { + const clientState = clients[manifest.client] ?? {}; + const targetState = { + releaseVersion: manifest.releaseVersion, + shellVersion: manifest.shellVersion, + channel: manifest.channel, + releaseKind: manifest.releaseKind, + sourceSha: manifest.sourceSha, + sourceRef: manifest.sourceRef, + artifactName: manifest.artifactName, + webUrl: manifest.webUrl, + }; + const updater = updaterByShell.get(manifest.shell); + const mobileReport = mobileReports.get(manifest.artifactName); + const desktopSigningReport = desktopSigningReports.get(manifest.artifactName); + const googlePlayReport = googlePlayReports.get(manifest.artifactName); + const appStoreConnectReport = appStoreConnectReports.get( + manifest.artifactName, + ); + + if (updater && updater.version === manifest.releaseVersion) { + targetState.updater = updater; + } + + if (desktopSigningReport) { + targetState.desktop = { + status: desktopSigningReport.status, + signingConfigured: Boolean(desktopSigningReport.signing?.configured), + updaterConfigured: Boolean(desktopSigningReport.updater?.configured), + updaterEndpoint: + desktopSigningReport.updater?.endpoint || undefined, + blockers: desktopSigningReport.blockers ?? [], + }; + } + + if (mobileReport) { + targetState.mobile = { + status: mobileReport.status, + buildStatus: mobileReport.build?.status, + buildImplemented: Boolean(mobileReport.build?.implemented), + buildArtifactDir: mobileReport.build?.artifactDir || undefined, + artifactType: mobileReport.policy?.artifactType, + storeProvider: mobileReport.policy?.store?.provider, + blockers: mobileReport.policy?.blockers ?? [], + }; + } + + if (googlePlayReport) { + targetState.mobile = { + ...(targetState.mobile ?? {}), + storeRelease: { + provider: "google-play", + status: googlePlayReport.status, + track: googlePlayReport.track, + releaseStatus: googlePlayReport.releaseStatus, + packageName: googlePlayReport.packageName, + releaseFileName: googlePlayReport.releaseFileName, + committedEditId: googlePlayReport.committedEditId || undefined, + reason: googlePlayReport.reason || undefined, + }, + }; + } + + if (appStoreConnectReport) { + targetState.mobile = { + ...(targetState.mobile ?? {}), + storeRelease: { + provider: "app-store-connect", + status: appStoreConnectReport.status, + distribution: appStoreConnectReport.distribution, + bundleId: appStoreConnectReport.bundleId, + ipaFileName: appStoreConnectReport.ipaFileName, + reason: appStoreConnectReport.reason || undefined, + }, + }; + } + + clientState[manifest.target] = targetState; + clients[manifest.client] = clientState; + } + + return clients; +} + +export function readClientReleaseFacts(artifactsDir) { + const manifests = readReleaseManifests(artifactsDir); + const mobileReports = readNamedReports( + artifactsDir, + "mobile-boundary", + "rtnn.mobile-release-boundary.v1", + ); + const desktopSigningReports = readNamedReports( + artifactsDir, + "desktop-signing", + "rtnn.desktop-signing-boundary.v1", + ); + const googlePlayReports = readNamedReports( + artifactsDir, + "google-play", + "rtnn.google-play-release.v1", + ); + const appStoreConnectReports = readNamedReports( + artifactsDir, + "app-store-connect", + "rtnn.app-store-connect-release.v1", + ); + const updaterByShell = readUpdaterIndex(artifactsDir); + const desiredClients = buildDesiredClientLiveState( + manifests, + mobileReports, + desktopSigningReports, + googlePlayReports, + appStoreConnectReports, + updaterByShell, + ); + + return { + manifests, + mobileReports, + desktopSigningReports, + googlePlayReports, + appStoreConnectReports, + updaterByShell, + desiredClients, + }; +} + +export function diffClientLiveState(currentClients, desiredClients) { + const changes = []; + + for (const [client, targets] of Object.entries(desiredClients)) { + for (const [target, desired] of Object.entries(targets)) { + const current = currentClients?.[client]?.[target] ?? null; + if (stableStringify(current) !== stableStringify(desired)) { + changes.push({ client, target, before: current, after: desired }); + } + } + } + + return changes; +} + +export function compareClientLiveState(metadata, artifactsDir, environment) { + const facts = readClientReleaseFacts(artifactsDir); + const currentEnvironment = metadata.liveState?.[environment] ?? {}; + const currentClients = currentEnvironment.clients ?? {}; + const changes = diffClientLiveState( + currentClients, + facts.desiredClients, + ); + + return { + ok: changes.length === 0, + environment, + artifactsDir, + changeCount: changes.length, + changes, + desiredClients: facts.desiredClients, + currentEnvironment, + currentClients, + }; +} diff --git a/scripts/lib/release-status-contract.mjs b/scripts/lib/release-status-contract.mjs new file mode 100644 index 0000000..2ae2b8d --- /dev/null +++ b/scripts/lib/release-status-contract.mjs @@ -0,0 +1,117 @@ +export const RELEASE_STATUS_VALUES = Object.freeze({ + FRESH: "fresh", + STALE: "stale", + BLOCKED: "blocked", + SKIPPED: "skipped", +}); + +export const RELEASE_STATUS_CODES = Object.freeze({ + OK: "OK", + PROFILE_SKIPPED: "PROFILE_SKIPPED", + PROFILE_WARNING: "PROFILE_WARNING", + PROFILE_ERROR: "PROFILE_ERROR", + MISSING_PROJECT_METADATA: "MISSING_PROJECT_METADATA", + INVALID_PROJECT_METADATA: "INVALID_PROJECT_METADATA", + RUNTIME_FACTS_MISSING: "RUNTIME_FACTS_MISSING", + RUNTIME_FACTS_INVALID: "RUNTIME_FACTS_INVALID", + RUNTIME_FACTS_UNSAFE: "RUNTIME_FACTS_UNSAFE", + RUNTIME_BINDING_MISMATCH: "RUNTIME_BINDING_MISMATCH", + RUNTIME_FACTS_STALE: "RUNTIME_FACTS_STALE", + CLIENT_ARTIFACTS_MISSING: "CLIENT_ARTIFACTS_MISSING", + CLIENT_ARTIFACTS_INVALID: "CLIENT_ARTIFACTS_INVALID", + CLIENT_LIVE_STATE_STALE: "CLIENT_LIVE_STATE_STALE", + CLIENT_LIVE_STATE_SKIPPED: "CLIENT_LIVE_STATE_SKIPPED", +}); + +export const RELEASE_STATUS_CODE_DETAILS = Object.freeze([ + { + code: RELEASE_STATUS_CODES.OK, + status: RELEASE_STATUS_VALUES.FRESH, + meaning: "所有启用检查均通过,线上事实与业务仓派生状态一致。", + nextAction: "无需写回 liveState。", + }, + { + code: RELEASE_STATUS_CODES.PROFILE_SKIPPED, + status: RELEASE_STATUS_VALUES.SKIPPED, + meaning: "调用方显式跳过 project profile 预检。", + nextAction: "仅在 CI 已有等价 profile gate 时使用。", + }, + { + code: RELEASE_STATUS_CODES.PROFILE_WARNING, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "project profile 存在 warning,且 strict profile 模式要求 warning 阻断。", + nextAction: "补齐业务仓 delivery/profile 配置后重跑。", + }, + { + code: RELEASE_STATUS_CODES.PROFILE_ERROR, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "project profile 无法解析。", + nextAction: "先修复 .rtnn/project.json 或模板环境。", + }, + { + code: RELEASE_STATUS_CODES.MISSING_PROJECT_METADATA, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "业务仓缺少 .rtnn/project.json。", + nextAction: "运行模板初始化或同步项目事实文件。", + }, + { + code: RELEASE_STATUS_CODES.INVALID_PROJECT_METADATA, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: ".rtnn/project.json 不满足业务仓事实契约。", + nextAction: "修复项目、部署仓、环境或 releaseExecution 配置。", + }, + { + code: RELEASE_STATUS_CODES.RUNTIME_FACTS_MISSING, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "调用方没有提供 runtime facts 文件。", + nextAction: "从 deploy 仓下载或传入 runtime facts artifact。", + }, + { + code: RELEASE_STATUS_CODES.RUNTIME_FACTS_INVALID, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "runtime facts schema、环境数据或 JSON 内容无法解析。", + nextAction: "修复 deploy 仓 facts 生成逻辑或传入正确环境。", + }, + { + code: RELEASE_STATUS_CODES.RUNTIME_FACTS_UNSAFE, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "runtime facts 包含疑似 secret/token/连接串等敏感字段。", + nextAction: "立即停止写回,清理 facts 产物并修复 deploy 仓输出边界。", + }, + { + code: RELEASE_STATUS_CODES.RUNTIME_BINDING_MISMATCH, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "runtime facts 声明的 source/application/image/event 与业务仓绑定不一致。", + nextAction: "确认 deploy 仓和业务仓是否配错仓库或应用。", + }, + { + code: RELEASE_STATUS_CODES.RUNTIME_FACTS_STALE, + status: RELEASE_STATUS_VALUES.STALE, + meaning: "线上 runtime facts 与业务仓 liveState 不一致。", + nextAction: "确认线上真实状态后准备 liveState-only PR,或排查部署未生效。", + }, + { + code: RELEASE_STATUS_CODES.CLIENT_ARTIFACTS_MISSING, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "调用方要求检查 client release,但缺少 artifacts。", + nextAction: "从 release-clients workflow 下载 client-release artifacts。", + }, + { + code: RELEASE_STATUS_CODES.CLIENT_ARTIFACTS_INVALID, + status: RELEASE_STATUS_VALUES.BLOCKED, + meaning: "client release artifacts 无法解析或缺少合法 manifest。", + nextAction: "修复客户端发布产物或重新运行 release-clients。", + }, + { + code: RELEASE_STATUS_CODES.CLIENT_LIVE_STATE_STALE, + status: RELEASE_STATUS_VALUES.STALE, + meaning: "客户端 release facts 与业务仓 liveState..clients 不一致。", + nextAction: "确认客户端 facts 后准备 liveState-only PR。", + }, + { + code: RELEASE_STATUS_CODES.CLIENT_LIVE_STATE_SKIPPED, + status: RELEASE_STATUS_VALUES.SKIPPED, + meaning: "未传入 client artifacts,客户端 liveState 检查已跳过。", + nextAction: "如果本次只关心 runtime,可以保持跳过。", + }, +]); diff --git a/scripts/release/check-client-release-surface.mjs b/scripts/release/check-client-release-surface.mjs index 5e047db..2e21164 100644 --- a/scripts/release/check-client-release-surface.mjs +++ b/scripts/release/check-client-release-surface.mjs @@ -80,6 +80,25 @@ function checkWorkflow(findings) { message: "release-clients 不应由 v* 业务发布 tag 触发", }, ]); + + const liveStateWorkflowPath = ".github/workflows/sync-live-state.yml"; + const liveStateWorkflow = read(liveStateWorkflowPath); + assertIncludes( + findings, + liveStateWorkflowPath, + liveStateWorkflow, + [ + "repository_dispatch:", + "sync-rtnn-live-state", + "actions/download-artifact@v4", + "scripts/release/run-release-status-ci.mjs", + "scripts/release/run-live-state-pr-ci.mjs", + "actions/upload-artifact@v4", + "contents: write", + "pull-requests: write", + ], + "liveState 同步 workflow 必须保留 runtime facts 下载、状态检查、PR 准备和产物上传", + ); } function checkBackendContract(findings) { @@ -137,10 +156,21 @@ function checkAdminSurface(findings) { const listPath = "apps/admin/app/(dashboard)/client-releases/page.tsx"; const packagePath = "apps/admin/app/(dashboard)/client-releases/packages/page.tsx"; + const packageTablePath = + "apps/admin/app/(dashboard)/client-releases/package-table.tsx"; const detailPath = "apps/admin/app/(dashboard)/client-releases/[id]/page.tsx"; + const detailComponentsPath = + "apps/admin/app/(dashboard)/client-releases/detail-components.tsx"; const actionPath = "apps/admin/app/(dashboard)/client-releases/actions.ts"; - for (const filePath of [listPath, packagePath, detailPath, actionPath]) { + for (const filePath of [ + listPath, + packagePath, + packageTablePath, + detailPath, + detailComponentsPath, + actionPath, + ]) { if (!existsSync(path.join(ROOT_DIR, filePath))) { addFinding(findings, filePath, "客户端发布后台页面/动作文件缺失"); } @@ -168,11 +198,9 @@ function checkAdminSurface(findings) { "发布中心必须保留版本、渠道、客户端、平台、状态、可下载数和同步时间", ); - const packageContent = read(packagePath); - assertIncludes( + assertIncludesInAny( findings, - packagePath, - packageContent, + [packagePath, packageTablePath], [ "formatClientPackageName(item.client, item.target, locale)", "dictionary.clientReleases.artifact", @@ -188,11 +216,9 @@ function checkAdminSurface(findings) { "包列表必须保留文件、大小、SHA256、自托管地址、GitHub 来源、状态和同步时间", ); - const detailContent = read(detailPath); - assertIncludes( + assertIncludesInAny( findings, - detailPath, - detailContent, + [detailPath, detailComponentsPath], [ "dictionary.clientReleases.generatedAt", "dictionary.clientReleases.syncedAt", @@ -263,13 +289,32 @@ function checkScriptWiring(findings) { const checkClientRelease = packageJson.scripts?.["check:client-release"] ?? ""; - if (!checkClientRelease.includes("check-client-release-surface.mjs")) { + if (!checkClientRelease.includes("check-client-release.mjs")) { addFinding( findings, packageJsonPath, - "check:client-release 必须包含客户端发布闭环 surface 检查", + "check:client-release 必须使用客户端发布闭环统一检查入口", ); } + + const orchestratorPath = "scripts/release/check-client-release.mjs"; + const orchestratorContent = read(orchestratorPath); + assertIncludes( + findings, + orchestratorPath, + orchestratorContent, + [ + "scripts/lib/release-facts.mjs", + "scripts/release/check-client-release-surface.mjs", + "tests/release-status-contract.test.mjs", + "tests/release-status.test.mjs", + "tests/live-state-pr.test.mjs", + "tests/release-status-ci.test.mjs", + "tests/live-state-pr-ci.test.mjs", + "tests/sync-live-state-workflow.test.mjs", + ], + "客户端发布统一检查入口必须覆盖 facts 库、surface gate、release status、CI 编排与 liveState PR 测试", + ); } function main() { diff --git a/scripts/release/check-client-release.mjs b/scripts/release/check-client-release.mjs new file mode 100644 index 0000000..7a968b1 --- /dev/null +++ b/scripts/release/check-client-release.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; + +const checks = [ + ["node", ["--check", "scripts/lib/release-facts.mjs"], "release facts lib syntax"], + ["node", ["--check", "scripts/release/resolve-client-release-context.mjs"], "client release context syntax"], + ["node", ["--check", "scripts/release/prepare-tauri-remote-web-url.mjs"], "tauri remote web url syntax"], + ["node", ["--check", "scripts/release/write-client-release-manifest.mjs"], "client release manifest syntax"], + ["node", ["--check", "scripts/release/collect-client-artifacts.mjs"], "client artifact collection syntax"], + ["node", ["--check", "scripts/release/check-client-artifact-urls.mjs"], "client artifact url check syntax"], + ["node", ["--check", "scripts/client/check-android-apk-package.mjs"], "android package check syntax"], + ["node", ["--check", "scripts/release/prepare-tauri-updater-signing.mjs"], "tauri updater signing syntax"], + ["node", ["--check", "scripts/release/prepare-android-signing.mjs"], "android signing syntax"], + ["node", ["--check", "scripts/release/prime-android-gradle.mjs"], "android gradle prime syntax"], + ["node", ["--check", "scripts/release/prepare-google-play-upload.mjs"], "google play upload syntax"], + ["node", ["--check", "scripts/release/write-google-play-release-report.mjs"], "google play report syntax"], + ["node", ["--check", "scripts/release/prepare-ios-signing.mjs"], "ios signing syntax"], + ["node", ["--check", "scripts/release/prepare-app-store-connect-upload.mjs"], "app store connect upload syntax"], + ["node", ["--check", "scripts/release/write-app-store-connect-release-report.mjs"], "app store connect report syntax"], + ["node", ["--check", "scripts/release/write-tauri-updater-manifest.mjs"], "tauri updater manifest syntax"], + ["node", ["--check", "scripts/release/merge-tauri-updater-fragments.mjs"], "tauri updater merge syntax"], + ["node", ["--check", "scripts/release/collect-client-github-release-assets.mjs"], "github release asset collection syntax"], + ["node", ["--check", "scripts/release/write-mobile-release-boundary.mjs"], "mobile release boundary syntax"], + ["node", ["--check", "scripts/release/check-client-build-capacity.mjs"], "client build capacity syntax"], + ["node", ["--check", "scripts/release/with-client-build-lock.mjs"], "client build lock syntax"], + ["node", ["--check", "scripts/release/cleanup-client-build-artifacts.mjs"], "client build cleanup syntax"], + ["node", ["--check", "scripts/release/sync-client-release-state.mjs"], "client liveState sync syntax"], + ["node", ["--check", "scripts/release/check-release-status.mjs"], "release status syntax"], + ["node", ["--check", "scripts/release/prepare-live-state-pr.mjs"], "liveState PR preparation syntax"], + ["node", ["--check", "scripts/release/run-release-status-ci.mjs"], "release status CI syntax"], + ["node", ["--check", "scripts/release/run-live-state-pr-ci.mjs"], "liveState PR CI syntax"], + ["node", ["--check", "scripts/release/check-client-release-github-prereqs.mjs"], "github prereqs syntax"], + ["node", ["--check", "scripts/release/run-client-release-github-dry-run.mjs"], "github dry-run syntax"], + ["node", ["scripts/release/check-client-release-surface.mjs"], "client release surface gate"], + ["node", ["--test", "tests/client-build-lock.test.mjs"], "client build lock tests"], + ["node", ["--test", "tests/client-release-context.test.mjs"], "client release context tests"], + ["node", ["--test", "tests/client-release-state.test.mjs"], "client release state tests"], + ["node", ["--test", "tests/release-status-contract.test.mjs"], "release status contract tests"], + ["node", ["--test", "tests/release-status.test.mjs"], "release status tests"], + ["node", ["--test", "tests/live-state-pr.test.mjs"], "liveState PR tests"], + ["node", ["--test", "tests/release-status-ci.test.mjs"], "release status CI tests"], + ["node", ["--test", "tests/live-state-pr-ci.test.mjs"], "liveState PR CI tests"], + ["node", ["--test", "tests/sync-live-state-workflow.test.mjs"], "sync liveState workflow tests"], + ["node", ["--test", "tests/client-release-github-prereqs.test.mjs"], "github prereqs tests"], + ["node", ["--test", "tests/tauri-remote-web-url.test.mjs"], "tauri remote web url tests"], + ["node", ["--test", "tests/admin-client-release-policy-action.test.mjs"], "admin policy action tests"], + ["node", ["--test", "tests/android-gradle-prime.test.mjs"], "android gradle prime tests"], +]; + +function run(command, args, label) { + console.log(`[client-release-check] ${label}`); + const result = spawnSync(command, args, { + stdio: "inherit", + shell: false, + }); + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +for (const [command, args, label] of checks) { + run(command, args, label); +} + +console.log("[client-release-check] 客户端发布链路校验通过"); diff --git a/scripts/release/check-release-status.mjs b/scripts/release/check-release-status.mjs new file mode 100644 index 0000000..c0224e9 --- /dev/null +++ b/scripts/release/check-release-status.mjs @@ -0,0 +1,676 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + assertRuntimeBindingMatches, + assertRuntimeFactsSafe, + compareClientLiveState, + compareRuntimeLiveState, + readRuntimeFacts, +} from "../lib/release-facts.mjs"; +import { + PROJECT_METADATA_FILE, + readProjectMetadata, + validateBusinessProjectMetadata, +} from "../lib/project-metadata.mjs"; +import { resolveProjectProfile } from "../lib/project-profile.mjs"; +import { + RELEASE_STATUS_CODES as CODES, + RELEASE_STATUS_VALUES as STATUS, +} from "../lib/release-status-contract.mjs"; + +function usage() { + return `用法: + node scripts/release/check-release-status.mjs --facts-file [options] + +选项: + --facts-file 独立部署执行仓生成的 runtime facts JSON + --environment 只检查某个环境,可重复或用逗号分隔 + --client-artifacts-dir release-clients workflow 产出的 artifacts/client-release 目录 + --skip-profile 跳过 profile doctor 预检 + --strict-profile profile doctor warnings 也按失败处理 + --json 输出机器可读 JSON + --summary-md 输出可用于 PR comment 的 Markdown 摘要 + --output 将 JSON 结果写入文件 + +说明: + 本脚本是回答“线上是否最新”的只读入口。它只读取 deploy runtime facts、 + 客户端 release facts 与 .rtnn/project.json,不写回 liveState。 + 如需写回 runtime liveState,使用 release:sync-live-state。 + 如需写回客户端 liveState,使用 release:sync-client-live-state。 +`; +} + +function parseArgs(argv) { + const args = { + factsFile: "", + environments: [], + clientArtifactsDir: "", + skipProfile: false, + strictProfile: false, + json: false, + summaryMd: false, + outputFile: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + + switch (item) { + case "--": + break; + case "--facts-file": + args.factsFile = String(argv[++index] ?? "").trim(); + break; + case "--environment": + args.environments.push(...String(argv[++index] ?? "").split(",")); + break; + case "--client-artifacts-dir": + args.clientArtifactsDir = String(argv[++index] ?? "").trim(); + break; + case "--skip-profile": + args.skipProfile = true; + break; + case "--strict-profile": + args.strictProfile = true; + break; + case "--json": + args.json = true; + break; + case "--summary-md": + args.summaryMd = true; + break; + case "--output": + args.outputFile = String(argv[++index] ?? "").trim(); + break; + case "--help": + case "-h": + console.log(usage()); + process.exit(0); + default: + throw new Error(`未知参数: ${item}`); + } + } + + args.environments = args.environments + .map((environment) => environment.trim()) + .filter(Boolean); + + if (!args.factsFile) { + throw new Error("必须传入 --facts-file;线上状态只能从 deploy runtime facts 判断"); + } + + if (!existsSync(args.factsFile)) { + throw new Error(`runtime facts 文件不存在: ${args.factsFile}`); + } + + if (args.clientArtifactsDir && !existsSync(args.clientArtifactsDir)) { + throw new Error(`客户端 release artifacts 目录不存在: ${args.clientArtifactsDir}`); + } + + return args; +} + +function finding(level, code, message, details = {}) { + return { + level, + code, + message, + ...(Object.keys(details).length > 0 ? { details } : {}), + }; +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function isProfileWarning(profile) { + return profile.isBusinessSource && !profile.deliveryConfigured; +} + +function runProfileCheck(rootDir, args) { + if (args.skipProfile) { + return { + ok: true, + status: STATUS.SKIPPED, + code: CODES.OK, + skipped: true, + reason: "skip-profile", + findings: [ + finding( + "info", + CODES.PROFILE_SKIPPED, + "profile doctor 预检已按参数跳过", + ), + ], + }; + } + + try { + const profile = resolveProjectProfile(rootDir); + const findings = []; + + if (isProfileWarning(profile)) { + findings.push( + finding( + "warn", + CODES.PROFILE_WARNING, + "业务源码仓未显式声明 delivery 配置,当前仍按兼容模式启用全部服务交付面", + ), + ); + } + + for (const warning of profile.warnings ?? []) { + findings.push(finding("warn", CODES.PROFILE_WARNING, warning)); + } + + const warningCount = findings.filter((item) => item.level === "warn").length; + const ok = !args.strictProfile || warningCount === 0; + + return { + ok, + status: ok ? STATUS.FRESH : STATUS.BLOCKED, + code: ok ? CODES.OK : CODES.PROFILE_WARNING, + summary: { + warningCount, + errorCount: 0, + }, + profile: { + source: profile.source, + projectRole: profile.projectRole, + deliveryConfigured: profile.deliveryConfigured, + enabledServices: profile.enabledServices, + enabledClients: profile.enabledClients, + enabledClientBuildTargets: profile.enabledClientBuildTargets, + }, + findings, + }; + } catch (error) { + return { + ok: false, + status: STATUS.BLOCKED, + code: CODES.PROFILE_ERROR, + error: errorMessage(error), + findings: [ + finding( + "error", + CODES.PROFILE_ERROR, + "project profile 无法解析", + { error: errorMessage(error) }, + ), + ], + }; + } +} + +function readValidatedMetadata(rootDir) { + const metadataPath = path.join(rootDir, PROJECT_METADATA_FILE); + if (!readProjectMetadata(rootDir)) { + return { + ok: false, + code: CODES.MISSING_PROJECT_METADATA, + error: `缺少项目事实文件: ${metadataPath}`, + metadata: null, + }; + } + + try { + return { + ok: true, + code: CODES.OK, + metadata: validateBusinessProjectMetadata(rootDir, { + requireConcreteRepositories: true, + }), + }; + } catch (error) { + return { + ok: false, + code: CODES.INVALID_PROJECT_METADATA, + error: errorMessage(error), + metadata: null, + }; + } +} + +function runRuntimeCheck(rootDir, args, metadataResult) { + if (!metadataResult.ok) { + return { + ok: false, + status: STATUS.BLOCKED, + code: metadataResult.code, + error: metadataResult.error, + findings: [ + finding( + "error", + metadataResult.code, + "无法读取业务仓项目事实,不能判断线上运行状态", + { error: metadataResult.error }, + ), + ], + }; + } + + let report; + try { + report = readRuntimeFacts(path.resolve(rootDir, args.factsFile)); + } catch (error) { + return { + ok: false, + status: STATUS.BLOCKED, + code: CODES.RUNTIME_FACTS_INVALID, + error: errorMessage(error), + findings: [ + finding( + "error", + CODES.RUNTIME_FACTS_INVALID, + "runtime facts 无法解析", + { error: errorMessage(error) }, + ), + ], + }; + } + + try { + assertRuntimeFactsSafe(report); + } catch (error) { + return { + ok: false, + status: STATUS.BLOCKED, + code: CODES.RUNTIME_FACTS_UNSAFE, + error: errorMessage(error), + findings: [ + finding( + "error", + CODES.RUNTIME_FACTS_UNSAFE, + "runtime facts 包含疑似敏感字段", + { error: errorMessage(error) }, + ), + ], + }; + } + + try { + assertRuntimeBindingMatches(metadataResult.metadata, report); + } catch (error) { + return { + ok: false, + status: STATUS.BLOCKED, + code: CODES.RUNTIME_BINDING_MISMATCH, + error: errorMessage(error), + findings: [ + finding( + "error", + CODES.RUNTIME_BINDING_MISMATCH, + "runtime facts 绑定关系与业务仓不一致", + { error: errorMessage(error) }, + ), + ], + }; + } + + try { + const environments = compareRuntimeLiveState( + metadataResult.metadata, + report, + args.environments, + ); + const staleEnvironments = environments.filter((item) => !item.fresh); + const findings = staleEnvironments.flatMap((environment) => + environment.mismatches.map((mismatch) => + finding( + "error", + CODES.RUNTIME_FACTS_STALE, + `${environment.environment}.${mismatch.field} 与 runtime facts 不一致`, + { + environment: environment.environment, + field: mismatch.field, + liveState: mismatch.expected, + runtime: mismatch.actual, + ...(mismatch.reason ? { reason: mismatch.reason } : {}), + }, + ), + ), + ); + + return { + ok: staleEnvironments.length === 0, + status: + staleEnvironments.length === 0 ? STATUS.FRESH : STATUS.STALE, + code: + staleEnvironments.length === 0 + ? CODES.OK + : CODES.RUNTIME_FACTS_STALE, + project: { + repo: metadataResult.metadata.project.repo, + deploymentRepo: metadataResult.metadata.deployment.repo, + application: metadataResult.metadata.deployment.application, + }, + environments, + findings, + }; + } catch (error) { + return { + ok: false, + status: STATUS.BLOCKED, + code: CODES.RUNTIME_FACTS_INVALID, + error: errorMessage(error), + findings: [ + finding( + "error", + CODES.RUNTIME_FACTS_INVALID, + "runtime facts 环境数据无法比较", + { error: errorMessage(error) }, + ), + ], + }; + } +} + +function resolveClientEnvironments(args, runtimeCheck) { + if (args.environments.length > 0) { + return args.environments; + } + + return (runtimeCheck.environments ?? []) + .map((environment) => String(environment.environment ?? "").trim()) + .filter(Boolean); +} + +function runClientLiveStateCheck(rootDir, args, metadataResult, runtimeCheck) { + if (!args.clientArtifactsDir) { + return { + ok: true, + status: STATUS.SKIPPED, + code: CODES.CLIENT_LIVE_STATE_SKIPPED, + skipped: true, + reason: "client-artifacts-dir-not-provided", + findings: [ + finding( + "info", + CODES.CLIENT_LIVE_STATE_SKIPPED, + "未传入 --client-artifacts-dir,已跳过客户端 release liveState 检查", + ), + ], + }; + } + + if (!metadataResult.ok) { + return { + ok: false, + status: STATUS.BLOCKED, + code: metadataResult.code, + error: metadataResult.error, + findings: [ + finding( + "error", + metadataResult.code, + "无法读取业务仓项目事实,不能判断客户端 liveState", + { error: metadataResult.error }, + ), + ], + }; + } + + const environments = resolveClientEnvironments(args, runtimeCheck); + if (environments.length === 0) { + return { + ok: false, + status: STATUS.BLOCKED, + code: CODES.RUNTIME_FACTS_INVALID, + error: "无法确定客户端 liveState 环境;请传入 --environment ", + findings: [ + finding( + "error", + CODES.RUNTIME_FACTS_INVALID, + "无法确定客户端 liveState 环境;请传入 --environment ", + ), + ], + }; + } + + const results = environments.map((environment) => { + try { + const comparison = compareClientLiveState( + metadataResult.metadata, + path.resolve(rootDir, args.clientArtifactsDir), + environment, + ); + return { + ok: comparison.ok, + status: comparison.ok ? STATUS.FRESH : STATUS.STALE, + code: comparison.ok ? CODES.OK : CODES.CLIENT_LIVE_STATE_STALE, + environment, + changeCount: comparison.changeCount, + changes: comparison.changes, + }; + } catch (error) { + return { + ok: false, + status: STATUS.BLOCKED, + code: CODES.CLIENT_ARTIFACTS_INVALID, + environment, + error: errorMessage(error), + }; + } + }); + + const findings = results.flatMap((result) => { + if (result.code === CODES.CLIENT_ARTIFACTS_INVALID) { + return [ + finding( + "error", + CODES.CLIENT_ARTIFACTS_INVALID, + `${result.environment} 客户端 release artifacts 无法解析`, + { environment: result.environment, error: result.error }, + ), + ]; + } + + return (result.changes ?? []).map((change) => + finding( + "error", + CODES.CLIENT_LIVE_STATE_STALE, + `${result.environment}.clients.${change.client}.${change.target} 与客户端 release facts 不一致`, + { + environment: result.environment, + client: change.client, + target: change.target, + }, + ), + ); + }); + + const ok = results.every((result) => result.ok); + const blocked = results.some((result) => result.status === STATUS.BLOCKED); + + return { + ok, + status: ok ? STATUS.FRESH : blocked ? STATUS.BLOCKED : STATUS.STALE, + code: ok + ? CODES.OK + : blocked + ? CODES.CLIENT_ARTIFACTS_INVALID + : CODES.CLIENT_LIVE_STATE_STALE, + artifactsDir: args.clientArtifactsDir, + environments: results, + findings, + }; +} + +function flattenFindings(checks) { + return Object.values(checks).flatMap((check) => check.findings ?? []); +} + +function summarizeStatus(checks) { + if (Object.values(checks).some((check) => check.status === STATUS.BLOCKED)) { + return STATUS.BLOCKED; + } + + if (Object.values(checks).some((check) => check.status === STATUS.STALE)) { + return STATUS.STALE; + } + + return STATUS.FRESH; +} + +function summarizeCode(status, checks) { + if (status === STATUS.FRESH) { + return CODES.OK; + } + + for (const check of [ + checks.runtime, + checks.clientLiveState, + checks.profile, + ]) { + if (!check.ok && check.code) { + return check.code; + } + } + + return status === STATUS.STALE + ? CODES.RUNTIME_FACTS_STALE + : CODES.RUNTIME_FACTS_INVALID; +} + +function buildResult(rootDir, args) { + const metadataResult = readValidatedMetadata(rootDir); + const profile = runProfileCheck(rootDir, args); + const runtime = runRuntimeCheck(rootDir, args, metadataResult); + const clientLiveState = runClientLiveStateCheck( + rootDir, + args, + metadataResult, + runtime, + ); + const checks = { + profile, + runtime, + clientLiveState, + }; + const status = summarizeStatus(checks); + const findings = flattenFindings(checks); + + return { + ok: Object.values(checks).every((check) => check.ok), + status, + code: summarizeCode(status, checks), + rootDir, + summary: { + status, + findingCount: findings.length, + errorCount: findings.filter((item) => item.level === "error").length, + warningCount: findings.filter((item) => item.level === "warn").length, + infoCount: findings.filter((item) => item.level === "info").length, + }, + checks, + findings, + }; +} + +function markdownEscape(value) { + return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " "); +} + +function buildSummaryMarkdown(result) { + const lines = [ + "## RTNN Release Status", + "", + `**Conclusion:** ${result.status}`, + "", + "| Check | Status | Code | Notes |", + "| --- | --- | --- | --- |", + ]; + + for (const [name, check] of Object.entries(result.checks)) { + lines.push( + `| ${markdownEscape(name)} | ${markdownEscape(check.status)} | ${markdownEscape(check.code)} | ${markdownEscape(check.error ?? check.reason ?? "-")} |`, + ); + } + + if (result.findings.length > 0) { + lines.push("", "### Findings", ""); + for (const item of result.findings.slice(0, 20)) { + lines.push(`- \`${item.code}\` ${item.message}`); + } + if (result.findings.length > 20) { + lines.push(`- ... ${result.findings.length - 20} more`); + } + } + + return `${lines.join("\n")}\n`; +} + +function printHuman(result) { + console.log(`[release-status] root=${result.rootDir}`); + console.log(`[release-status] conclusion=${result.status}`); + + const profile = result.checks.profile; + console.log( + `[release-status] profile: ${profile.status} code=${profile.code} services=${profile.profile?.enabledServices?.join(",") || "-"} clients=${profile.profile?.enabledClients?.join(",") || "-"}`, + ); + + const runtime = result.checks.runtime; + console.log( + `[release-status] runtime: ${runtime.status} code=${runtime.code} project=${runtime.project?.repo ?? "-"}`, + ); + for (const environment of runtime.environments ?? []) { + const status = environment.fresh ? "fresh" : "stale"; + const health = Object.entries(environment.observed?.health ?? {}) + .filter(([, value]) => value) + .map(([key]) => key) + .join(","); + + console.log( + `[release-status] runtime.${environment.environment}: ${status} live=${environment.current?.activeRelease || "-"} runtime=${environment.observed?.activeRelease || "-"} sha=${environment.observed?.sourceSha || "-"} health=${health || "-"}`, + ); + } + + const clientLiveState = result.checks.clientLiveState; + console.log( + `[release-status] client-live-state: ${clientLiveState.status} code=${clientLiveState.code}`, + ); + for (const environment of clientLiveState.environments ?? []) { + console.log( + `[release-status] client-live-state.${environment.environment}: ${environment.status} changes=${environment.changeCount ?? 0}`, + ); + } + + for (const item of result.findings) { + console.log(`[release-status] ${item.level}: ${item.code} - ${item.message}`); + } +} + +function writeOutputFile(outputFile, result) { + if (!outputFile) { + return; + } + + const resolved = path.resolve(process.cwd(), outputFile); + mkdirSync(path.dirname(resolved), { recursive: true }); + writeFileSync(resolved, `${JSON.stringify(result, null, 2)}\n`); +} + +try { + const args = parseArgs(process.argv.slice(2)); + const rootDir = process.cwd(); + const result = buildResult(rootDir, args); + + writeOutputFile(args.outputFile, result); + + if (args.summaryMd) { + console.log(buildSummaryMarkdown(result)); + } else if (args.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printHuman(result); + } + + if (!result.ok) { + process.exit(1); + } +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/release/check-runtime-freshness.mjs b/scripts/release/check-runtime-freshness.mjs index 85266c0..f353c1c 100644 --- a/scripts/release/check-runtime-freshness.mjs +++ b/scripts/release/check-runtime-freshness.mjs @@ -1,17 +1,19 @@ #!/usr/bin/env node -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; +import { + assertRuntimeBindingMatches, + assertRuntimeFactsSafe, + compareRuntimeLiveState, + readRuntimeFacts, +} from "../lib/release-facts.mjs"; import { PROJECT_METADATA_FILE, readProjectMetadata, validateBusinessProjectMetadata, } from "../lib/project-metadata.mjs"; -const RUNTIME_FACTS_SCHEMA_VERSION = "rtnn.deploy.runtime-facts.v1"; -const SENSITIVE_KEY_PATTERN = - /token|secret|password|authorization|cookie|database_?url|connection_?string|ssh/i; - function usage() { return `用法: node scripts/release/check-runtime-freshness.mjs --facts-file [--environment ] [--json] @@ -73,182 +75,6 @@ function parseArgs(argv) { return args; } -function readJson(filePath) { - return JSON.parse(readFileSync(filePath, "utf8")); -} - -function readRuntimeFacts(factsFile) { - const report = readJson(factsFile); - - if (report.schemaVersion !== RUNTIME_FACTS_SCHEMA_VERSION) { - throw new Error("runtime facts schemaVersion 不匹配"); - } - - if (!Array.isArray(report.environments)) { - throw new Error("runtime facts 缺少 environments 数组"); - } - - return report; -} - -function isPlainObject(value) { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function collectSensitiveKeyPaths(value, prefix = "") { - if (Array.isArray(value)) { - return value.flatMap((item, index) => - collectSensitiveKeyPaths(item, `${prefix}[${index}]`), - ); - } - - if (!isPlainObject(value)) { - return []; - } - - const paths = []; - for (const [key, child] of Object.entries(value)) { - const nextPath = prefix ? `${prefix}.${key}` : key; - if (SENSITIVE_KEY_PATTERN.test(key)) { - paths.push(nextPath); - continue; - } - paths.push(...collectSensitiveKeyPaths(child, nextPath)); - } - return paths; -} - -function assertRuntimeFactsSafe(report) { - const sensitivePaths = collectSensitiveKeyPaths(report); - if (sensitivePaths.length > 0) { - throw new Error( - `runtime facts 包含疑似敏感字段: ${sensitivePaths.join(", ")}`, - ); - } -} - -function assertBindingMatches(metadata, report) { - const errors = []; - const binding = isPlainObject(report.binding) ? report.binding : {}; - - const expected = { - sourceRepository: metadata.project.repo, - application: metadata.deployment.application, - imageNamePrefix: metadata.deployment.imageNamePrefix, - dispatchEventType: metadata.deployment.dispatchEventType, - }; - - for (const [key, value] of Object.entries(expected)) { - if (binding[key] !== value) { - errors.push(`${key}: ${value} != ${binding[key] ?? "(missing)"}`); - } - } - - if (errors.length > 0) { - throw new Error(`runtime facts 绑定关系不匹配: ${errors.join(";")}`); - } -} - -function selectEnvironmentFacts(report, requestedEnvironments) { - const factsByEnvironment = new Map( - report.environments.map((environmentFact) => [ - environmentFact.environment, - environmentFact, - ]), - ); - const environments = - requestedEnvironments.length > 0 - ? requestedEnvironments - : report.environments.map((environmentFact) => environmentFact.environment); - - return environments.map((environment) => { - const environmentFact = factsByEnvironment.get(environment); - if (!environmentFact) { - throw new Error(`runtime facts 缺少环境: ${environment}`); - } - return environmentFact; - }); -} - -function readObservedVersion(environmentFact) { - const versionResult = environmentFact.health?.results?.version; - const body = versionResult?.body; - - if (!versionResult?.ok || !isPlainObject(body)) { - return { - deployVersion: "", - sourceSha: "", - }; - } - - return { - deployVersion: String(body.version ?? "").trim(), - sourceSha: String(body.sourceSha ?? "").trim(), - }; -} - -function buildObservedState(environmentFact) { - const release = isPlainObject(environmentFact.release) - ? environmentFact.release - : {}; - const observedVersion = readObservedVersion(environmentFact); - const activeRelease = - observedVersion.deployVersion || String(release.deployVersion ?? "").trim(); - const sourceSha = - observedVersion.sourceSha || String(release.sourceSha ?? "").trim(); - - return { - activeRelease, - sourceSha, - health: { - version: Boolean(environmentFact.health?.results?.version?.ok), - readyz: Boolean(environmentFact.health?.results?.readyz?.ok), - healthz: Boolean(environmentFact.health?.results?.healthz?.ok), - }, - }; -} - -function compareEnvironment(metadata, environmentFact) { - const environment = environmentFact.environment; - const current = metadata.liveState?.[environment] ?? {}; - const observed = buildObservedState(environmentFact); - const mismatches = []; - - if (!observed.activeRelease) { - mismatches.push({ - field: "activeRelease", - expected: current.activeRelease ?? "", - actual: "", - reason: "runtime facts 缺少可识别 DEPLOY_VERSION 或 /version.version", - }); - } else if (current.activeRelease !== observed.activeRelease) { - mismatches.push({ - field: "activeRelease", - expected: current.activeRelease ?? "", - actual: observed.activeRelease, - }); - } - - if (observed.sourceSha && current.sourceSha !== observed.sourceSha) { - mismatches.push({ - field: "sourceSha", - expected: current.sourceSha ?? "", - actual: observed.sourceSha, - }); - } - - return { - environment, - fresh: mismatches.length === 0, - current: { - activeRelease: current.activeRelease ?? "", - sourceSha: current.sourceSha ?? "", - }, - observed, - mismatches, - }; -} - function printHumanResult(result) { console.log(`[runtime-freshness] project=${result.project.repo}`); @@ -286,12 +112,9 @@ function main() { const report = readRuntimeFacts(path.resolve(rootDir, args.factsFile)); assertRuntimeFactsSafe(report); - assertBindingMatches(metadata, report); + assertRuntimeBindingMatches(metadata, report); - const environments = selectEnvironmentFacts( - report, - args.environments, - ).map((environmentFact) => compareEnvironment(metadata, environmentFact)); + const environments = compareRuntimeLiveState(metadata, report, args.environments); const result = { ok: environments.every((environment) => environment.fresh), project: { diff --git a/scripts/release/prepare-live-state-pr.mjs b/scripts/release/prepare-live-state-pr.mjs new file mode 100644 index 0000000..3d07c02 --- /dev/null +++ b/scripts/release/prepare-live-state-pr.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { + assertRuntimeBindingMatches, + collectRuntimeLiveStateChanges, + compareClientLiveState, + readRuntimeFacts, +} from "../lib/release-facts.mjs"; +import { + PROJECT_METADATA_FILE, + readProjectMetadata, + validateBusinessProjectMetadata, + writeProjectMetadata, +} from "../lib/project-metadata.mjs"; + +function usage() { + return `用法: + node scripts/release/prepare-live-state-pr.mjs --facts-file [options] + +选项: + --facts-file 独立部署执行仓生成的 runtime facts JSON + --environment 只处理某个环境,可重复或用逗号分隔 + --client-artifacts-dir release-clients workflow 产出的 artifacts/client-release 目录 + --summary-md 写入 PR body Markdown,默认不写 + --json 输出机器可读 JSON + +说明: + 本脚本用于 CI 准备 liveState-only PR。它只允许改写 .rtnn/project.json + 的 liveState 字段,不提交、不推送、不创建 PR。 +`; +} + +function parseArgs(argv) { + const args = { + factsFile: "", + environments: [], + clientArtifactsDir: "", + summaryMdFile: "", + json: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + + switch (item) { + case "--facts-file": + args.factsFile = String(argv[++index] ?? "").trim(); + break; + case "--environment": + args.environments.push(...String(argv[++index] ?? "").split(",")); + break; + case "--client-artifacts-dir": + args.clientArtifactsDir = String(argv[++index] ?? "").trim(); + break; + case "--summary-md": + args.summaryMdFile = String(argv[++index] ?? "").trim(); + break; + case "--json": + args.json = true; + break; + case "--help": + case "-h": + console.log(usage()); + process.exit(0); + default: + throw new Error(`未知参数: ${item}`); + } + } + + args.environments = args.environments + .map((environment) => environment.trim()) + .filter(Boolean); + + if (!args.factsFile) { + throw new Error("必须传入 --facts-file"); + } + + if (!existsSync(args.factsFile)) { + throw new Error(`runtime facts 文件不存在: ${args.factsFile}`); + } + + if (args.clientArtifactsDir && !existsSync(args.clientArtifactsDir)) { + throw new Error(`客户端 release artifacts 目录不存在: ${args.clientArtifactsDir}`); + } + + return args; +} + +function git(args) { + return execFileSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function gitQuiet(args) { + const result = execFileSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return result; +} + +function normalizeStatusPath(filePath) { + if (filePath === "rtnn/project.json") { + return PROJECT_METADATA_FILE; + } + return filePath; +} + +function listChangedFiles() { + return git(["status", "--porcelain", "--untracked-files=all"]) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => normalizeStatusPath(line.slice(3).trim())) + .filter(Boolean); +} + +function normalizeRelativePath(rootDir, filePath) { + if (!filePath) { + return ""; + } + + return path.relative(rootDir, path.resolve(rootDir, filePath)); +} + +function pathIsUnder(value, dir) { + return value === dir || value.startsWith(`${dir}/`); +} + +function isInsideWorkspace(relativePath) { + return relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); +} + +function isTrackedFile(filePath) { + try { + gitQuiet(["ls-files", "--error-unmatch", "--", filePath]); + return true; + } catch { + return false; + } +} + +function assertSummaryPathAllowed(rootDir, filePath) { + if (!filePath) { + return; + } + + const relativePath = normalizeRelativePath(rootDir, filePath); + if (!isInsideWorkspace(relativePath)) { + return; + } + + const generatedDirs = ["tmp", ".tmp", "artifacts"]; + if (!generatedDirs.some((dir) => pathIsUnder(relativePath, dir))) { + throw new Error( + `--summary-md 写入仓库内部时只能使用临时/产物目录: ${generatedDirs.join(", ")}`, + ); + } + + if (isTrackedFile(relativePath)) { + throw new Error(`--summary-md 不能覆盖 git 已跟踪文件: ${relativePath}`); + } +} + +function assertOnlyProjectMetadataChanged(rootDir, allowedPaths = []) { + const changedFiles = listChangedFiles(); + const allowed = new Set([PROJECT_METADATA_FILE, ...allowedPaths.filter(Boolean)]); + const allowedDirs = allowedPaths + .filter((item) => item && !path.extname(item)) + .map((item) => item.replace(/\/+$/, "")); + const unexpected = changedFiles.filter( + (filePath) => + !allowed.has(filePath) && + !allowedDirs.some((dir) => pathIsUnder(filePath, dir)), + ); + + if (unexpected.length > 0) { + throw new Error( + `liveState PR 准备只允许修改 ${PROJECT_METADATA_FILE},当前工作区还有: ${unexpected.join(", ")}`, + ); + } +} + +function updateRuntimeLiveState(metadata, report, environments) { + const environmentChanges = collectRuntimeLiveStateChanges( + metadata, + report, + environments, + ); + const changed = []; + + metadata.liveState = metadata.liveState ?? {}; + for (const item of environmentChanges) { + if (item.changes.length > 0) { + changed.push({ environment: item.environment, changes: item.changes }); + } + metadata.liveState[item.environment] = { + ...item.current, + ...item.desired, + }; + } + + return changed; +} + +function updateClientLiveState(metadata, artifactsDir, environments) { + if (!artifactsDir) { + return []; + } + + const changed = []; + for (const environment of environments) { + const comparison = compareClientLiveState(metadata, artifactsDir, environment); + if (comparison.changes.length > 0) { + changed.push({ + environment, + changes: comparison.changes.map(({ client, target }) => ({ + client, + target, + })), + }); + } + + metadata.liveState = metadata.liveState ?? {}; + metadata.liveState[environment] = { + ...comparison.currentEnvironment, + clients: { + ...comparison.currentClients, + ...Object.fromEntries( + Object.entries(comparison.desiredClients).map(([client, targets]) => [ + client, + { + ...(comparison.currentClients[client] ?? {}), + ...targets, + }, + ]), + ), + }, + }; + } + + return changed; +} + +function buildMarkdown(result) { + const lines = [ + "# Sync RTNN liveState", + "", + "This PR refreshes the derived non-sensitive `.rtnn/project.json liveState` snapshot from deploy/runtime facts.", + "", + "## Runtime changes", + ]; + + if (result.runtimeChanges.length === 0) { + lines.push("", "- No runtime liveState changes."); + } else { + lines.push(""); + for (const item of result.runtimeChanges) { + for (const change of item.changes) { + lines.push( + `- ${item.environment}.${change.key}: ${change.before || "-"} -> ${change.after}`, + ); + } + } + } + + lines.push("", "## Client changes"); + if (result.clientChanges.length === 0) { + lines.push("", "- No client liveState changes."); + } else { + lines.push(""); + for (const item of result.clientChanges) { + for (const change of item.changes) { + lines.push( + `- ${item.environment}.clients.${change.client}.${change.target}`, + ); + } + } + } + + lines.push( + "", + "This update must remain liveState-only. Do not include source or contract changes in this PR.", + "", + ); + + return lines.join("\n"); +} + +function writeOptionalFile(filePath, content) { + if (!filePath) { + return; + } + + const resolved = path.resolve(process.cwd(), filePath); + mkdirSync(path.dirname(resolved), { recursive: true }); + writeFileSync(resolved, content); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const rootDir = process.cwd(); + const allowedWorkspacePaths = [ + normalizeRelativePath(rootDir, args.factsFile), + normalizeRelativePath(rootDir, args.clientArtifactsDir), + normalizeRelativePath(rootDir, args.summaryMdFile), + ]; + + assertSummaryPathAllowed(rootDir, args.summaryMdFile); + assertOnlyProjectMetadataChanged(rootDir, allowedWorkspacePaths); + + const metadata = validateBusinessProjectMetadata(rootDir, { + requireConcreteRepositories: true, + }); + const nextMetadata = readProjectMetadata(rootDir); + const report = readRuntimeFacts(path.resolve(rootDir, args.factsFile)); + assertRuntimeBindingMatches(metadata, report); + + const environments = + args.environments.length > 0 + ? args.environments + : report.environments.map((environmentFact) => environmentFact.environment); + const runtimeChanges = updateRuntimeLiveState( + nextMetadata, + report, + environments, + ); + const clientChanges = updateClientLiveState( + nextMetadata, + args.clientArtifactsDir + ? path.resolve(rootDir, args.clientArtifactsDir) + : "", + environments, + ); + + const metadataPath = writeProjectMetadata(rootDir, nextMetadata); + assertOnlyProjectMetadataChanged(rootDir, allowedWorkspacePaths); + + const result = { + ok: true, + changed: runtimeChanges.length > 0 || clientChanges.length > 0, + metadataPath, + pr: { + title: "chore: sync RTNN liveState", + body: buildMarkdown({ runtimeChanges, clientChanges }), + }, + runtimeChanges, + clientChanges, + }; + + writeOptionalFile(args.summaryMdFile, result.pr.body); + assertOnlyProjectMetadataChanged(rootDir, allowedWorkspacePaths); + + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`[live-state-pr] changed=${result.changed ? "true" : "false"}`); + console.log(`[live-state-pr] metadata=${metadataPath}`); + console.log(`[live-state-pr] title=${result.pr.title}`); + if (args.summaryMdFile) { + console.log(`[live-state-pr] summary=${args.summaryMdFile}`); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/release/run-live-state-pr-ci.mjs b/scripts/release/run-live-state-pr-ci.mjs new file mode 100644 index 0000000..53c9e6f --- /dev/null +++ b/scripts/release/run-live-state-pr-ci.mjs @@ -0,0 +1,369 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const DEFAULT_OUTPUT_DIR = "artifacts/live-state-pr"; +const DEFAULT_BRANCH_PREFIX = "automation/rtnn-live-state"; +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function usage() { + return `用法: + node scripts/release/run-live-state-pr-ci.mjs --facts-file [options] + +选项: + --facts-file deploy 仓生成的 runtime facts JSON + --environment 只处理某个环境,可重复或用逗号分隔 + --client-artifacts-dir 可选 client release artifacts 目录 + --output-dir 输出目录,默认 artifacts/live-state-pr + --branch liveState-only 分支名,默认自动生成 + --base-branch PR base,默认 main + --create-pr 使用 gh 创建 PR + --no-push 不 push 分支 + +说明: + 本脚本用于 GitHub Actions 编排 liveState-only PR。核心写回仍由 + prepare-live-state-pr 完成;本脚本只负责 branch、commit、push 和可选 PR。 +`; +} + +function parseArgs(argv) { + const args = { + factsFile: "", + environments: [], + clientArtifactsDir: "", + outputDir: DEFAULT_OUTPUT_DIR, + branch: "", + baseBranch: "main", + createPr: false, + push: true, + }; + + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + + switch (item) { + case "--facts-file": + args.factsFile = String(argv[++index] ?? "").trim(); + break; + case "--environment": + args.environments.push(...String(argv[++index] ?? "").split(",")); + break; + case "--client-artifacts-dir": + args.clientArtifactsDir = String(argv[++index] ?? "").trim(); + break; + case "--output-dir": + args.outputDir = String(argv[++index] ?? "").trim(); + break; + case "--branch": + args.branch = String(argv[++index] ?? "").trim(); + break; + case "--base-branch": + args.baseBranch = String(argv[++index] ?? "").trim(); + break; + case "--create-pr": + args.createPr = true; + break; + case "--no-push": + args.push = false; + break; + case "--help": + case "-h": + console.log(usage()); + process.exit(0); + default: + throw new Error(`未知参数: ${item}`); + } + } + + args.environments = args.environments + .map((environment) => environment.trim()) + .filter(Boolean); + + if (!args.factsFile) { + throw new Error("必须传入 --facts-file"); + } + + if (args.createPr && !args.push) { + throw new Error("--create-pr 不能与 --no-push 同时使用"); + } + + if (!existsSync(args.factsFile)) { + throw new Error(`runtime facts 文件不存在: ${args.factsFile}`); + } + + if (args.clientArtifactsDir && !existsSync(args.clientArtifactsDir)) { + throw new Error(`客户端 release artifacts 目录不存在: ${args.clientArtifactsDir}`); + } + + return args; +} + +function git(args) { + return execFileSync("git", args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error(result.stderr || `${command} ${args.join(" ")} failed`); + } + + return result.stdout.trim(); +} + +function normalizeRelativePath(filePath) { + if (!filePath) { + return ""; + } + + return path.relative(process.cwd(), path.resolve(process.cwd(), filePath)); +} + +function pathIsUnder(value, dir) { + return value === dir || value.startsWith(`${dir}/`); +} + +function changedFiles() { + return git(["status", "--porcelain", "--untracked-files=all"]) + .split(/\r?\n/) + .filter(Boolean); +} + +function assertCleanWorkspace(allowedPaths = []) { + const allowed = allowedPaths.filter(Boolean); + const allowedDirs = allowed + .filter((item) => !path.extname(item)) + .map((item) => item.replace(/\/+$/, "")); + const files = changedFiles().filter((line) => { + const filePath = line.slice(3).trim(); + return ( + !allowed.includes(filePath) && + !allowedDirs.some((dir) => pathIsUnder(filePath, dir)) + ); + }); + + if (files.length > 0) { + throw new Error( + `liveState PR CI 需要干净工作区,当前还有: ${files.join(", ")}`, + ); + } +} + +function currentShortSha() { + try { + return git(["rev-parse", "--short=12", "HEAD"]); + } catch { + return "local"; + } +} + +function defaultBranchName(args) { + const envSuffix = + args.environments.length > 0 ? args.environments.join("-") : "all"; + return `${DEFAULT_BRANCH_PREFIX}/${envSuffix}-${currentShortSha()}`; +} + +function checkoutBranch(branch) { + run("git", ["checkout", "-B", branch]); +} + +function runPrepare(args, summaryPath) { + const commandArgs = [ + path.join(ROOT_DIR, "scripts/release/prepare-live-state-pr.mjs"), + "--facts-file", + args.factsFile, + "--summary-md", + summaryPath, + "--json", + ]; + + for (const environment of args.environments) { + commandArgs.push("--environment", environment); + } + + if (args.clientArtifactsDir) { + commandArgs.push("--client-artifacts-dir", args.clientArtifactsDir); + } + + const stdout = run(process.execPath, commandArgs); + return JSON.parse(stdout); +} + +function ensureGitIdentity() { + const existingName = spawnSync("git", ["config", "user.name"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const existingEmail = spawnSync("git", ["config", "user.email"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + + if (!existingName.stdout.trim()) { + run("git", ["config", "user.name", "github-actions[bot]"]); + } + + if (!existingEmail.stdout.trim()) { + run( + "git", + [ + "config", + "user.email", + "41898282+github-actions[bot]@users.noreply.github.com", + ], + ); + } +} + +function commitLiveState() { + run("git", ["add", ".rtnn/project.json"]); + run("git", ["commit", "-m", "chore: sync RTNN liveState"]); +} + +function pushBranch(branch) { + run("git", ["push", "--force-with-lease", "origin", `HEAD:${branch}`]); +} + +async function githubRequest(apiPath, options = {}) { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("--create-pr 需要 GITHUB_TOKEN"); + } + + const response = await fetch(`https://api.github.com${apiPath}`, { + ...options, + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + ...(options.headers ?? {}), + }, + }); + const text = await response.text(); + const body = text ? JSON.parse(text) : null; + + return { + ok: response.ok, + status: response.status, + body, + }; +} + +async function createPullRequest(args, branch, bodyPath) { + const repository = process.env.GITHUB_REPOSITORY; + if (!repository) { + throw new Error("--create-pr 需要 GITHUB_REPOSITORY"); + } + + const body = readFileSync(path.resolve(process.cwd(), bodyPath), "utf8"); + const create = await githubRequest(`/repos/${repository}/pulls`, { + method: "POST", + body: JSON.stringify({ + title: "chore: sync RTNN liveState", + head: branch, + base: args.baseBranch, + body, + }), + }); + + if (create.ok) { + return create.body.html_url; + } + + const [owner] = repository.split("/"); + const existing = await githubRequest( + `/repos/${repository}/pulls?state=open&head=${encodeURIComponent(`${owner}:${branch}`)}`, + ); + if (existing.ok && Array.isArray(existing.body) && existing.body.length > 0) { + return existing.body[0].html_url; + } + + throw new Error( + `GitHub PR 创建失败: ${create.status} ${JSON.stringify(create.body)}`, + ); +} + +function writeGithubOutput(result) { + if (!process.env.GITHUB_OUTPUT) { + return; + } + + appendFileSync( + process.env.GITHUB_OUTPUT, + [ + `changed=${result.changed ? "true" : "false"}`, + `branch=${result.branch}`, + `commit=${result.commit ?? ""}`, + `pr_url=${result.prUrl ?? ""}`, + `output_dir=${result.outputDir}`, + "", + ].join("\n"), + ); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const outputDir = path.resolve(process.cwd(), args.outputDir); + const summaryPath = path.join(args.outputDir, "live-state-pr.md"); + const resultPath = path.join(outputDir, "live-state-pr.json"); + const branch = args.branch || defaultBranchName(args); + mkdirSync(outputDir, { recursive: true }); + const allowedWorkspacePaths = [ + normalizeRelativePath(args.factsFile), + normalizeRelativePath(args.clientArtifactsDir), + normalizeRelativePath(args.outputDir), + ]; + + assertCleanWorkspace(allowedWorkspacePaths); + checkoutBranch(branch); + + const prepared = runPrepare(args, summaryPath); + const result = { + ok: true, + changed: prepared.changed, + branch, + outputDir: args.outputDir, + prTitle: prepared.pr?.title ?? "chore: sync RTNN liveState", + runtimeChanges: prepared.runtimeChanges, + clientChanges: prepared.clientChanges, + }; + + if (prepared.changed) { + ensureGitIdentity(); + commitLiveState(); + result.commit = git(["rev-parse", "HEAD"]); + + if (args.push) { + pushBranch(branch); + result.pushed = true; + } else { + result.pushed = false; + } + + if (args.createPr) { + result.prUrl = await createPullRequest(args, branch, summaryPath); + } + } + + writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`); + writeGithubOutput(result); + console.log(JSON.stringify(result, null, 2)); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/release/run-release-status-ci.mjs b/scripts/release/run-release-status-ci.mjs new file mode 100644 index 0000000..02ee23b --- /dev/null +++ b/scripts/release/run-release-status-ci.mjs @@ -0,0 +1,234 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); + +function usage() { + return `用法: + node scripts/release/run-release-status-ci.mjs --facts-file [options] + +选项: + --facts-file deploy 仓生成的 runtime facts JSON + --environment 只检查某个环境,可重复或用逗号分隔 + --client-artifacts-dir 可选 client release artifacts 目录 + --output-dir 输出目录,默认 artifacts/release-status + --strict-profile profile warning 也按失败处理 + --skip-profile 跳过 profile 预检 + +说明: + CI 入口只编排 release:status 输出,不写回 liveState。 + 输出目录会包含 release-status.json 与 release-status.md。 +`; +} + +function parseArgs(argv) { + const args = { + factsFile: "", + environments: [], + clientArtifactsDir: "", + outputDir: "artifacts/release-status", + strictProfile: false, + skipProfile: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const item = argv[index]; + + switch (item) { + case "--facts-file": + args.factsFile = String(argv[++index] ?? "").trim(); + break; + case "--environment": + args.environments.push(...String(argv[++index] ?? "").split(",")); + break; + case "--client-artifacts-dir": + args.clientArtifactsDir = String(argv[++index] ?? "").trim(); + break; + case "--output-dir": + args.outputDir = String(argv[++index] ?? "").trim(); + break; + case "--strict-profile": + args.strictProfile = true; + break; + case "--skip-profile": + args.skipProfile = true; + break; + case "--help": + case "-h": + console.log(usage()); + process.exit(0); + default: + throw new Error(`未知参数: ${item}`); + } + } + + args.environments = args.environments + .map((environment) => environment.trim()) + .filter(Boolean); + + if (!args.factsFile) { + throw new Error("必须传入 --facts-file"); + } + + if (!existsSync(args.factsFile)) { + throw new Error(`runtime facts 文件不存在: ${args.factsFile}`); + } + + if (args.clientArtifactsDir && !existsSync(args.clientArtifactsDir)) { + throw new Error(`客户端 release artifacts 目录不存在: ${args.clientArtifactsDir}`); + } + + return args; +} + +function runStatus(args, jsonPath) { + const commandArgs = [ + path.join(ROOT_DIR, "scripts/release/check-release-status.mjs"), + "--facts-file", + args.factsFile, + "--json", + "--output", + jsonPath, + ]; + + for (const environment of args.environments) { + commandArgs.push("--environment", environment); + } + + if (args.clientArtifactsDir) { + commandArgs.push("--client-artifacts-dir", args.clientArtifactsDir); + } + + if (args.strictProfile) { + commandArgs.push("--strict-profile"); + } + + if (args.skipProfile) { + commandArgs.push("--skip-profile"); + } + + return spawnSync(process.execPath, commandArgs, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function readJsonFile(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function markdownEscape(value) { + return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " "); +} + +function buildMarkdown(result) { + const lines = [ + "## RTNN Release Status", + "", + `**Conclusion:** ${result.status}`, + "", + "| Field | Value |", + "| --- | --- |", + `| Status | ${markdownEscape(result.status)} |`, + `| Code | ${markdownEscape(result.code)} |`, + `| Errors | ${markdownEscape(result.summary?.errorCount ?? 0)} |`, + `| Warnings | ${markdownEscape(result.summary?.warningCount ?? 0)} |`, + "", + "| Check | Status | Code | Notes |", + "| --- | --- | --- | --- |", + ]; + + for (const [name, check] of Object.entries(result.checks ?? {})) { + lines.push( + `| ${markdownEscape(name)} | ${markdownEscape(check.status)} | ${markdownEscape(check.code)} | ${markdownEscape(check.error ?? check.reason ?? "-")} |`, + ); + } + + if ((result.findings ?? []).length > 0) { + lines.push("", "### Findings", ""); + for (const item of result.findings.slice(0, 20)) { + lines.push(`- \`${item.code}\` ${item.message}`); + } + if (result.findings.length > 20) { + lines.push(`- ... ${result.findings.length - 20} more`); + } + } + + return `${lines.join("\n")}\n`; +} + +function writeGithubOutput(result, outputDir) { + if (!process.env.GITHUB_OUTPUT) { + return; + } + + appendFileSync( + process.env.GITHUB_OUTPUT, + [ + `ok=${result.ok ? "true" : "false"}`, + `status=${result.status}`, + `code=${result.code}`, + `output_dir=${outputDir}`, + "", + ].join("\n"), + ); +} + +function appendStepSummary(markdown) { + if (!process.env.GITHUB_STEP_SUMMARY) { + return; + } + + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${markdown}\n`); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const outputDir = path.resolve(process.cwd(), args.outputDir); + const jsonPath = path.join(outputDir, "release-status.json"); + const markdownPath = path.join(outputDir, "release-status.md"); + mkdirSync(outputDir, { recursive: true }); + + const statusRun = runStatus(args, jsonPath); + if (statusRun.stderr) { + process.stderr.write(statusRun.stderr); + } + + let result; + if (existsSync(jsonPath)) { + result = readJsonFile(jsonPath); + } else { + throw new Error(statusRun.stderr || "release:status 未写出 JSON 结果"); + } + + const markdown = buildMarkdown(result); + writeFileSync(markdownPath, markdown); + writeGithubOutput(result, args.outputDir); + appendStepSummary(markdown); + + const payload = { + ok: result.ok, + status: result.status, + code: result.code, + outputDir: args.outputDir, + jsonPath, + markdownPath, + }; + + console.log(JSON.stringify(payload, null, 2)); + + if (statusRun.status !== 0) { + process.exit(statusRun.status ?? 1); + } +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/release/sync-client-release-state.mjs b/scripts/release/sync-client-release-state.mjs index 8ae6e05..95689d8 100644 --- a/scripts/release/sync-client-release-state.mjs +++ b/scripts/release/sync-client-release-state.mjs @@ -2,10 +2,9 @@ import { existsSync, - readdirSync, - readFileSync, } from "node:fs"; import path from "node:path"; +import { compareClientLiveState } from "../lib/release-facts.mjs"; import { PROJECT_METADATA_FILE, readProjectMetadata, @@ -21,6 +20,7 @@ function usage() { --environment 写入 liveState 的环境,例如 testing 或 production --check 只校验 liveState 是否与客户端 release facts 一致,默认行为 --write 写回 .rtnn/project.json 的 liveState + --json 输出机器可读 JSON `; } @@ -29,6 +29,7 @@ function parseArgs(argv) { artifactsDir: "", environment: "", mode: "check", + json: false, }; for (let index = 0; index < argv.length; index += 1) { @@ -47,6 +48,9 @@ function parseArgs(argv) { case "--write": args.mode = "write"; break; + case "--json": + args.json = true; + break; case "--help": case "-h": console.log(usage()); @@ -74,233 +78,20 @@ function parseArgs(argv) { return args; } -function isPlainObject(value) { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function readJson(filePath) { - return JSON.parse(readFileSync(filePath, "utf8")); -} - -function listJsonFiles(dir) { - if (!existsSync(dir)) { - return []; - } - - return readdirSync(dir) - .filter((fileName) => fileName.endsWith(".json")) - .sort() - .map((fileName) => path.join(dir, fileName)); -} - -function readReleaseManifests(artifactsDir) { - const manifests = listJsonFiles(artifactsDir) - .map(readJson) - .filter((item) => item.schemaVersion === "rtnn.client-release.v1"); - - if (manifests.length === 0) { - throw new Error("客户端 release artifacts 缺少 rtnn.client-release.v1 manifest"); - } - - return manifests; -} - -function readMobileBoundaryReports(artifactsDir) { - const reports = new Map(); - for (const report of listJsonFiles(path.join(artifactsDir, "mobile-boundary")).map(readJson)) { - if (report.schemaVersion === "rtnn.mobile-release-boundary.v1") { - reports.set(report.artifactName, report); - } - } - - return reports; -} - -function readDesktopSigningReports(artifactsDir) { - const reports = new Map(); - for (const report of listJsonFiles(path.join(artifactsDir, "desktop-signing")).map(readJson)) { - if (report.schemaVersion === "rtnn.desktop-signing-boundary.v1") { - reports.set(report.artifactName, report); - } - } - - return reports; -} - -function readGooglePlayReports(artifactsDir) { - const reports = new Map(); - for (const report of listJsonFiles(path.join(artifactsDir, "google-play")).map(readJson)) { - if (report.schemaVersion === "rtnn.google-play-release.v1") { - reports.set(report.artifactName, report); - } - } - - return reports; -} - -function readAppStoreConnectReports(artifactsDir) { - const reports = new Map(); - for (const report of listJsonFiles(path.join(artifactsDir, "app-store-connect")).map(readJson)) { - if (report.schemaVersion === "rtnn.app-store-connect-release.v1") { - reports.set(report.artifactName, report); - } - } - - return reports; -} - -function readUpdaterIndex(artifactsDir) { - const indexPath = path.join(artifactsDir, "updater", "index.json"); - if (!existsSync(indexPath)) { - return new Map(); - } - - const index = readJson(indexPath); - if (index.schemaVersion !== "rtnn.tauri-updater-index.v1") { - return new Map(); - } - - return new Map( - index.manifests.map((item) => [ - item.shell, - { - file: item.file, - version: item.version, - platforms: item.platforms, - }, - ]), - ); -} - -function sortObject(value) { - if (Array.isArray(value)) { - return value.map(sortObject); - } - - if (!isPlainObject(value)) { - return value; - } - - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, sortObject(value[key])]), - ); -} - -function stableStringify(value) { - return JSON.stringify(sortObject(value)); -} - -function buildDesiredState( - manifests, - mobileReports, - desktopSigningReports, - googlePlayReports, - appStoreConnectReports, - updaterByShell, -) { - const clients = {}; - - for (const manifest of manifests) { - const clientState = clients[manifest.client] ?? {}; - const targetState = { - releaseVersion: manifest.releaseVersion, - shellVersion: manifest.shellVersion, - channel: manifest.channel, - releaseKind: manifest.releaseKind, - sourceSha: manifest.sourceSha, - sourceRef: manifest.sourceRef, - artifactName: manifest.artifactName, - webUrl: manifest.webUrl, - }; - const updater = updaterByShell.get(manifest.shell); - const mobileReport = mobileReports.get(manifest.artifactName); - const desktopSigningReport = desktopSigningReports.get(manifest.artifactName); - const googlePlayReport = googlePlayReports.get(manifest.artifactName); - const appStoreConnectReport = appStoreConnectReports.get(manifest.artifactName); - - if (updater && updater.version === manifest.releaseVersion) { - targetState.updater = updater; - } - - if (desktopSigningReport) { - targetState.desktop = { - status: desktopSigningReport.status, - signingConfigured: Boolean( - desktopSigningReport.signing?.configured, - ), - updaterConfigured: Boolean( - desktopSigningReport.updater?.configured, - ), - updaterEndpoint: - desktopSigningReport.updater?.endpoint || undefined, - blockers: desktopSigningReport.blockers ?? [], - }; - } - - if (mobileReport) { - targetState.mobile = { - status: mobileReport.status, - buildStatus: mobileReport.build?.status, - buildImplemented: Boolean(mobileReport.build?.implemented), - buildArtifactDir: mobileReport.build?.artifactDir || undefined, - artifactType: mobileReport.policy?.artifactType, - storeProvider: mobileReport.policy?.store?.provider, - blockers: mobileReport.policy?.blockers ?? [], - }; - } - - if (googlePlayReport) { - targetState.mobile = { - ...(targetState.mobile ?? {}), - storeRelease: { - provider: "google-play", - status: googlePlayReport.status, - track: googlePlayReport.track, - releaseStatus: googlePlayReport.releaseStatus, - packageName: googlePlayReport.packageName, - releaseFileName: googlePlayReport.releaseFileName, - committedEditId: googlePlayReport.committedEditId || undefined, - reason: googlePlayReport.reason || undefined, - }, - }; - } - - if (appStoreConnectReport) { - targetState.mobile = { - ...(targetState.mobile ?? {}), - storeRelease: { - provider: "app-store-connect", - status: appStoreConnectReport.status, - distribution: appStoreConnectReport.distribution, - bundleId: appStoreConnectReport.bundleId, - ipaFileName: appStoreConnectReport.ipaFileName, - reason: appStoreConnectReport.reason || undefined, - }, - }; - } - - clientState[manifest.target] = targetState; - clients[manifest.client] = clientState; - } - - return clients; +function buildResult(args, changes, extra = {}) { + return { + ok: args.mode === "write" || changes.length === 0, + mode: args.mode, + environment: args.environment, + artifactsDir: args.artifactsDir, + changeCount: changes.length, + changes, + ...extra, + }; } -function diffClientState(currentClients, desiredClients) { - const changes = []; - - for (const [client, targets] of Object.entries(desiredClients)) { - for (const [target, desired] of Object.entries(targets)) { - const current = currentClients?.[client]?.[target] ?? null; - if (stableStringify(current) !== stableStringify(desired)) { - changes.push({ client, target, before: current, after: desired }); - } - } - } - - return changes; +function printJsonResult(result) { + console.log(JSON.stringify(result, null, 2)); } function main() { @@ -316,52 +107,56 @@ function main() { throw new Error("project.role 必须是 business-source"); } + const nextMetadata = readProjectMetadata(rootDir); const artifactsDir = path.resolve(rootDir, args.artifactsDir); - const manifests = readReleaseManifests(artifactsDir); - const mobileReports = readMobileBoundaryReports(artifactsDir); - const desktopSigningReports = readDesktopSigningReports(artifactsDir); - const googlePlayReports = readGooglePlayReports(artifactsDir); - const appStoreConnectReports = readAppStoreConnectReports(artifactsDir); - const updaterByShell = readUpdaterIndex(artifactsDir); - const desiredClients = buildDesiredState( - manifests, - mobileReports, - desktopSigningReports, - googlePlayReports, - appStoreConnectReports, - updaterByShell, + const comparison = compareClientLiveState( + nextMetadata, + artifactsDir, + args.environment, ); - const nextMetadata = readProjectMetadata(rootDir); - const currentEnvironment = - nextMetadata.liveState?.[args.environment] ?? {}; - const currentClients = currentEnvironment.clients ?? {}; - const changes = diffClientState(currentClients, desiredClients); + const changes = comparison.changes; if (changes.length === 0) { - console.log("[client-live-state] .rtnn/project.json 已与客户端 release facts 一致"); + if (args.json) { + printJsonResult(buildResult(args, changes, { written: false })); + } else { + console.log("[client-live-state] .rtnn/project.json 已与客户端 release facts 一致"); + } return; } - for (const change of changes) { - console.log( - `[client-live-state] ${args.environment}.clients.${change.client}.${change.target} 需要更新`, - ); - } - if (args.mode === "check") { + if (args.json) { + printJsonResult(buildResult(args, changes)); + } + if (!args.json) { + for (const change of changes) { + console.log( + `[client-live-state] ${args.environment}.clients.${change.client}.${change.target} 需要更新`, + ); + } + } throw new Error("客户端 liveState 与 release facts 不一致;确认后使用 --write 写回"); } + if (!args.json) { + for (const change of changes) { + console.log( + `[client-live-state] ${args.environment}.clients.${change.client}.${change.target} 需要更新`, + ); + } + } + nextMetadata.liveState = nextMetadata.liveState ?? {}; nextMetadata.liveState[args.environment] = { - ...currentEnvironment, + ...comparison.currentEnvironment, clients: { - ...currentClients, + ...comparison.currentClients, ...Object.fromEntries( - Object.entries(desiredClients).map(([client, targets]) => [ + Object.entries(comparison.desiredClients).map(([client, targets]) => [ client, { - ...(currentClients[client] ?? {}), + ...(comparison.currentClients[client] ?? {}), ...targets, }, ]), @@ -370,7 +165,11 @@ function main() { }; const metadataPath = writeProjectMetadata(rootDir, nextMetadata); - console.log(`[client-live-state] 已更新 ${metadataPath}`); + if (args.json) { + printJsonResult(buildResult(args, changes, { written: true, metadataPath })); + } else { + console.log(`[client-live-state] 已更新 ${metadataPath}`); + } } try { diff --git a/scripts/release/sync-live-state.mjs b/scripts/release/sync-live-state.mjs index 93ba403..a40120e 100644 --- a/scripts/release/sync-live-state.mjs +++ b/scripts/release/sync-live-state.mjs @@ -1,7 +1,12 @@ #!/usr/bin/env node -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import path from "node:path"; +import { + assertRuntimeBindingMatches, + collectRuntimeLiveStateChanges, + readRuntimeFacts, +} from "../lib/release-facts.mjs"; import { readProjectMetadata, validateBusinessProjectMetadata, @@ -69,125 +74,6 @@ function parseArgs(argv) { return args; } -function readRuntimeFacts(factsFile) { - const report = JSON.parse(readFileSync(factsFile, "utf8")); - - if (report.schemaVersion !== "rtnn.deploy.runtime-facts.v1") { - throw new Error("runtime facts schemaVersion 不匹配"); - } - - if (!Array.isArray(report.environments)) { - throw new Error("runtime facts 缺少 environments 数组"); - } - - return report; -} - -function assertBindingMatches(metadata, report) { - const errors = []; - const binding = report.binding ?? {}; - - if (metadata.project.repo !== binding.sourceRepository) { - errors.push( - `project.repo 与 runtime facts sourceRepository 不一致: ${metadata.project.repo} != ${binding.sourceRepository}`, - ); - } - - if (metadata.deployment.application !== binding.application) { - errors.push( - `deployment.application 与 runtime facts 不一致: ${metadata.deployment.application} != ${binding.application}`, - ); - } - - if (metadata.deployment.imageNamePrefix !== binding.imageNamePrefix) { - errors.push( - `deployment.imageNamePrefix 与 runtime facts 不一致: ${metadata.deployment.imageNamePrefix} != ${binding.imageNamePrefix}`, - ); - } - - if (metadata.deployment.dispatchEventType !== binding.dispatchEventType) { - errors.push( - `deployment.dispatchEventType 与 runtime facts 不一致: ${metadata.deployment.dispatchEventType} != ${binding.dispatchEventType}`, - ); - } - - if (errors.length > 0) { - throw new Error(errors.join(";")); - } -} - -function buildDesiredState(environmentFact) { - const release = environmentFact.release ?? {}; - const observedVersion = readObservedVersion(environmentFact); - const deployVersion = - observedVersion.deployVersion || String(release.deployVersion ?? "").trim(); - const sourceSha = - observedVersion.sourceSha || String(release.sourceSha ?? "").trim(); - - if (!environmentFact.source?.exists && !observedVersion.deployVersion) { - throw new Error(`${environmentFact.environment} 缺少可用 runtime source`); - } - - if (!deployVersion) { - throw new Error(`${environmentFact.environment} 缺少 DEPLOY_VERSION`); - } - - return { - activeRelease: deployVersion, - ...(sourceSha ? { sourceSha } : {}), - }; -} - -function readObservedVersion(environmentFact) { - const versionResult = environmentFact.health?.results?.version; - const body = versionResult?.body; - - if (!versionResult?.ok || !body || typeof body !== "object") { - return { - deployVersion: "", - sourceSha: "", - }; - } - - return { - deployVersion: String(body.version ?? "").trim(), - sourceSha: String(body.sourceSha ?? "").trim(), - }; -} - -function diffLiveState(current, desired) { - const changes = []; - - for (const [key, value] of Object.entries(desired)) { - if (current?.[key] !== value) { - changes.push({ key, before: current?.[key] ?? "", after: value }); - } - } - - return changes; -} - -function selectEnvironmentFacts(report, requestedEnvironments) { - const factsByEnvironment = new Map( - report.environments.map((environmentFact) => [ - environmentFact.environment, - environmentFact, - ]), - ); - const environments = - requestedEnvironments.length > 0 - ? requestedEnvironments - : report.environments.map((environmentFact) => environmentFact.environment); - - return environments.map((environment) => { - const environmentFact = factsByEnvironment.get(environment); - if (!environmentFact) { - throw new Error(`runtime facts 缺少环境: ${environment}`); - } - return environmentFact; - }); -} - function main() { const args = parseArgs(process.argv.slice(2)); const rootDir = process.cwd(); @@ -196,26 +82,24 @@ function main() { }); const report = readRuntimeFacts(path.resolve(rootDir, args.factsFile)); - assertBindingMatches(metadata, report); + assertRuntimeBindingMatches(metadata, report); const nextMetadata = readProjectMetadata(rootDir); - const selectedFacts = selectEnvironmentFacts(report, args.environments); - const allChanges = []; - - for (const environmentFact of selectedFacts) { - const desired = buildDesiredState(environmentFact); - const current = nextMetadata.liveState?.[environmentFact.environment] ?? {}; - const changes = diffLiveState(current, desired); - - if (changes.length > 0) { - allChanges.push({ environment: environmentFact.environment, changes }); - } + const environmentChanges = collectRuntimeLiveStateChanges( + nextMetadata, + report, + args.environments, + ); + const allChanges = environmentChanges + .filter((item) => item.changes.length > 0) + .map(({ environment, changes }) => ({ environment, changes })); + for (const item of environmentChanges) { if (args.mode === "write") { nextMetadata.liveState = nextMetadata.liveState ?? {}; - nextMetadata.liveState[environmentFact.environment] = { - ...current, - ...desired, + nextMetadata.liveState[item.environment] = { + ...item.current, + ...item.desired, }; } } diff --git a/scripts/template/check-template-derivation.mjs b/scripts/template/check-template-derivation.mjs index f87d4aa..2e5d06b 100644 --- a/scripts/template/check-template-derivation.mjs +++ b/scripts/template/check-template-derivation.mjs @@ -65,6 +65,11 @@ function main() { ["--check", "scripts/lib/project-profile.mjs"], "校验 project profile 脚本语法", ); + run( + "node", + ["--check", "scripts/lib/release-facts.mjs"], + "校验 release facts 脚本语法", + ); run( "node", ["--check", "scripts/client/check-tauri-clients.mjs"], @@ -170,6 +175,11 @@ function main() { ["--check", "scripts/release/sync-client-release-state.mjs"], "校验 client liveState 同步脚本语法", ); + run( + "node", + ["--check", "scripts/release/check-client-release.mjs"], + "校验 client release 统一检查入口语法", + ); run( "node", ["--check", "scripts/release/check-client-release-github-prereqs.mjs"], @@ -190,6 +200,26 @@ function main() { ["--check", "scripts/release/check-runtime-freshness.mjs"], "校验运行事实 freshness 脚本语法", ); + run( + "node", + ["--check", "scripts/release/check-release-status.mjs"], + "校验 release status 脚本语法", + ); + run( + "node", + ["--check", "scripts/release/prepare-live-state-pr.mjs"], + "校验 liveState PR 准备脚本语法", + ); + run( + "node", + ["--check", "scripts/release/run-release-status-ci.mjs"], + "校验 release status CI 脚本语法", + ); + run( + "node", + ["--check", "scripts/release/run-live-state-pr-ci.mjs"], + "校验 liveState PR CI 脚本语法", + ); run( "node", ["--check", "scripts/release/detect-live-state-only-change.mjs"], @@ -241,6 +271,36 @@ function main() { ["--test", "tests/runtime-freshness.test.mjs"], "运行 runtime freshness 测试", ); + run( + "node", + ["--test", "tests/release-status-contract.test.mjs"], + "运行 release status 契约测试", + ); + run( + "node", + ["--test", "tests/release-status.test.mjs"], + "运行 release status 测试", + ); + run( + "node", + ["--test", "tests/live-state-pr.test.mjs"], + "运行 liveState PR 准备测试", + ); + run( + "node", + ["--test", "tests/release-status-ci.test.mjs"], + "运行 release status CI 测试", + ); + run( + "node", + ["--test", "tests/live-state-pr-ci.test.mjs"], + "运行 liveState PR CI 测试", + ); + run( + "node", + ["--test", "tests/sync-live-state-workflow.test.mjs"], + "运行 liveState 同步 workflow 测试", + ); run( "node", ["--test", "tests/profile-doctor.test.mjs"], diff --git a/tests/client-release-context.test.mjs b/tests/client-release-context.test.mjs index 8c003f7..a151318 100644 --- a/tests/client-release-context.test.mjs +++ b/tests/client-release-context.test.mjs @@ -2468,13 +2468,20 @@ test("client release GitHub dry-run does not sync deploy facts by default", () = assert.match(dryRunScript, /sync_deploy_facts=\$\{args\.syncDeployFacts \? "true" : "false"\}/); }); -test("check:client-release includes the client release surface gate", () => { +test("check:client-release uses the client release orchestrator", () => { const packageJson = JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")); + const orchestrator = readFileSync( + path.join(repoRoot, "scripts/release/check-client-release.mjs"), + "utf8", + ); assert.match( packageJson.scripts["check:client-release"], - /scripts\/release\/check-client-release-surface\.mjs/, + /scripts\/release\/check-client-release\.mjs/, ); + assert.match(orchestrator, /scripts\/release\/check-client-release-surface\.mjs/); + assert.match(orchestrator, /tests\/release-status\.test\.mjs/); + assert.match(orchestrator, /tests\/live-state-pr\.test\.mjs/); }); test("release-clients workflow avoids server-local gh and pnpm cache assumptions", () => { diff --git a/tests/helpers/release-fixtures.mjs b/tests/helpers/release-fixtures.mjs new file mode 100644 index 0000000..e9c234e --- /dev/null +++ b/tests/helpers/release-fixtures.mjs @@ -0,0 +1,232 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +export function writeJson(filePath, value) { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +export function writeTemplateEnv(cwd) { + writeFileSync( + path.join(cwd, ".env"), + [ + "TEMPLATE_PROJECT_ID=acme", + "TEMPLATE_BRAND_NAME=ACME", + "TEMPLATE_COOKIE_PREFIX=acme", + "TEMPLATE_IMAGE_NAME_PREFIX=acme", + "TEMPLATE_DEPLOY_APPLICATION=acme", + "TEMPLATE_DEPLOY_EVENT_TYPE=promote-acme", + "", + ].join("\n"), + ); +} + +export function buildClientTarget() { + return { + releaseVersion: "1.2.3", + shellVersion: "0.2.0", + channel: "testing", + releaseKind: "desktop-unsigned", + sourceSha: "1234567890abcdef", + sourceRef: "refs/tags/v1.2.3", + artifactName: "admin-desktop-macos-1.2.3", + webUrl: "https://admin.acme.test", + updater: { + file: "admin-desktop-latest.json", + version: "1.2.3", + platforms: ["darwin-aarch64"], + }, + desktop: { + status: "ready-for-signed-build", + signingConfigured: true, + updaterConfigured: true, + updaterEndpoint: + "https://github.com/acme/business-source/releases/latest/download/admin-desktop-latest.json", + blockers: [], + }, + }; +} + +export function writeProjectMetadata( + cwd, + { + activeRelease = "main-abc123", + sourceSha = "abc123", + includeClientState = true, + } = {}, +) { + writeJson(path.join(cwd, ".rtnn/project.json"), { + version: "v1", + project: { + repo: "acme/business-source", + role: "business-source", + projectId: "acme", + brandName: "ACME", + cookiePrefix: "acme", + }, + upstreamTemplate: { + repo: "acme/rtnn", + remote: "upstream", + defaultRef: "main", + syncStrategy: "git-merge-from-upstream", + }, + deployment: { + repo: "acme/rtnn-deploy", + application: "acme", + imageNamePrefix: "acme", + dispatchEventType: "promote-acme", + clientReleaseFactsEventType: "sync-acme-client-release-facts", + environments: ["testing", "production"], + }, + domains: { + testing: {}, + production: {}, + }, + server: { + hostModel: "single-host", + }, + releaseExecution: { + defaultMode: "github-hosted", + allowedModes: ["github-hosted", "server-local"], + }, + delivery: { + services: { + backend: { enabled: true }, + admin: { enabled: true }, + app: { enabled: false }, + weapp: { enabled: false }, + }, + clients: { + adminDesktop: { + enabled: true, + targets: ["macos"], + webUrl: "https://admin.acme.test", + channel: "testing", + }, + }, + }, + liveState: { + testing: { + activeRelease, + sourceSha, + clients: includeClientState + ? { + adminDesktop: { + macos: buildClientTarget(), + }, + } + : {}, + }, + production: {}, + }, + }); +} + +export function writeRuntimeFacts(cwd, overrides = {}) { + writeJson(path.join(cwd, "runtime-facts.json"), { + schemaVersion: "rtnn.deploy.runtime-facts.v1", + binding: { + sourceRepository: "acme/business-source", + application: "acme", + imageNamePrefix: "acme", + dispatchEventType: "promote-acme", + ...(overrides.binding ?? {}), + }, + environments: [ + { + environment: "testing", + source: { + exists: true, + }, + release: { + deployVersion: "main-abc123", + sourceSha: "abc123", + }, + health: { + results: { + version: { + ok: true, + body: { + version: "main-abc123", + sourceSha: "abc123", + }, + }, + readyz: { + ok: true, + }, + healthz: { + ok: true, + }, + }, + }, + ...(overrides.environment ?? {}), + }, + ], + ...(overrides.report ?? {}), + }); +} + +export function writeClientArtifacts(cwd) { + const artifactsDir = path.join(cwd, "artifacts/client-release"); + writeJson(path.join(artifactsDir, "admin-desktop-macos-1.2.3.json"), { + schemaVersion: "rtnn.client-release.v1", + client: "adminDesktop", + target: "macos", + shell: "admin-desktop", + packageName: "@rtnn/admin-tauri", + releaseVersion: "1.2.3", + shellVersion: "0.2.0", + channel: "testing", + releaseKind: "desktop-unsigned", + dryRun: false, + webUrl: "https://admin.acme.test", + sourceSha: "1234567890abcdef", + sourceRef: "refs/tags/v1.2.3", + artifactName: "admin-desktop-macos-1.2.3", + generatedAt: "2026-04-29T00:00:00.000Z", + }); + writeJson(path.join(artifactsDir, "updater/index.json"), { + schemaVersion: "rtnn.tauri-updater-index.v1", + manifests: [ + { + shell: "admin-desktop", + file: "admin-desktop-latest.json", + version: "1.2.3", + platforms: ["darwin-aarch64"], + }, + ], + }); + writeJson( + path.join( + artifactsDir, + "desktop-signing/admin-desktop-macos-1.2.3.json", + ), + { + schemaVersion: "rtnn.desktop-signing-boundary.v1", + client: "adminDesktop", + target: "macos", + shell: "admin-desktop", + releaseVersion: "1.2.3", + channel: "testing", + artifactName: "admin-desktop-macos-1.2.3", + status: "ready-for-signed-build", + signing: { + configured: true, + }, + updater: { + configured: true, + endpoint: + "https://github.com/acme/business-source/releases/latest/download/admin-desktop-latest.json", + }, + blockers: [], + }, + ); + return artifactsDir; +} + +export function writeReleaseProject(cwd, options = {}) { + writeTemplateEnv(cwd); + writeProjectMetadata(cwd, options); + writeRuntimeFacts(cwd); + return writeClientArtifacts(cwd); +} diff --git a/tests/live-state-pr-ci.test.mjs b/tests/live-state-pr-ci.test.mjs new file mode 100644 index 0000000..ee484dd --- /dev/null +++ b/tests/live-state-pr-ci.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { writeReleaseProject } from "./helpers/release-fixtures.mjs"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT_PATH = path.join( + ROOT_DIR, + "scripts/release/run-live-state-pr-ci.mjs", +); + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed\n${result.stderr}`); + } + + return result.stdout.trim(); +} + +function setupRepository(options = {}) { + const cwd = mkdtempSync(path.join(tmpdir(), "rtnn-live-state-pr-ci-")); + run("git", ["init"], cwd); + run("git", ["config", "user.email", "agent@example.com"], cwd); + run("git", ["config", "user.name", "Agent"], cwd); + writeReleaseProject(cwd, options); + run("git", ["add", ".env", ".rtnn/project.json"], cwd); + run("git", ["commit", "-m", "initial"], cwd); + return cwd; +} + +test("liveState PR CI creates a liveState-only commit in no-push mode", () => { + const cwd = setupRepository({ + activeRelease: "main-old", + sourceSha: "", + includeClientState: false, + }); + try { + const outputPath = path.join(cwd, "github-output.txt"); + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--client-artifacts-dir", + "artifacts/client-release", + "--branch", + "automation/rtnn-live-state/testing-test", + "--output-dir", + "artifacts/live-state-pr", + "--no-push", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + GITHUB_OUTPUT: outputPath, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.changed, true); + assert.equal(payload.pushed, false); + assert.match(payload.commit, /^[0-9a-f]{40}$/); + assert.match( + run("git", ["log", "-1", "--pretty=%s"], cwd), + /sync RTNN liveState/, + ); + assert.equal( + run("git", ["diff", "HEAD^", "HEAD", "--name-only"], cwd), + ".rtnn/project.json", + ); + + const summary = readFileSync( + path.join(cwd, "artifacts/live-state-pr/live-state-pr.md"), + "utf8", + ); + assert.match(summary, /Sync RTNN liveState/); + assert.match(readFileSync(outputPath, "utf8"), /changed=true/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("liveState PR CI reports no-op when liveState is already fresh", () => { + const cwd = setupRepository(); + try { + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--client-artifacts-dir", + "artifacts/client-release", + "--branch", + "automation/rtnn-live-state/testing-noop", + "--no-push", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.changed, false); + assert.equal(run("git", ["rev-list", "--count", "HEAD"], cwd), "1"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("liveState PR CI rejects dirty workspaces before branch changes", () => { + const cwd = setupRepository(); + try { + writeFileSync(path.join(cwd, "README.md"), "dirty\n"); + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--no-push", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /需要干净工作区/); + assert.equal(run("git", ["branch", "--show-current"], cwd), "main"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/tests/live-state-pr.test.mjs b/tests/live-state-pr.test.mjs new file mode 100644 index 0000000..25e5472 --- /dev/null +++ b/tests/live-state-pr.test.mjs @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { writeReleaseProject } from "./helpers/release-fixtures.mjs"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT_PATH = path.join( + ROOT_DIR, + "scripts/release/prepare-live-state-pr.mjs", +); + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed\n${result.stderr}`); + } + + return result.stdout.trim(); +} + +function setupRepository() { + const cwd = mkdtempSync(path.join(tmpdir(), "rtnn-live-state-pr-")); + run("git", ["init"], cwd); + run("git", ["config", "user.email", "agent@example.com"], cwd); + run("git", ["config", "user.name", "Agent"], cwd); + + writeReleaseProject(cwd, { + activeRelease: "main-old", + sourceSha: "", + includeClientState: false, + }); + + run("git", ["add", ".env", ".rtnn/project.json"], cwd); + run("git", ["commit", "-m", "initial"], cwd); + return cwd; +} + +test("prepare-live-state-pr writes only project liveState and PR summary", () => { + const cwd = setupRepository(); + try { + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--client-artifacts-dir", + "artifacts/client-release", + "--summary-md", + "tmp/live-state-pr.md", + "--json", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.changed, true); + assert.equal(payload.runtimeChanges[0].environment, "testing"); + assert.equal(payload.clientChanges[0].environment, "testing"); + + const changedFiles = run("git", ["diff", "--name-only"], cwd) + .split(/\r?\n/) + .filter(Boolean); + assert.deepEqual(changedFiles, [".rtnn/project.json"]); + + const metadata = JSON.parse( + readFileSync(path.join(cwd, ".rtnn/project.json"), "utf8"), + ); + assert.equal(metadata.liveState.testing.activeRelease, "main-abc123"); + assert.equal(metadata.liveState.testing.sourceSha, "abc123"); + assert.equal( + metadata.liveState.testing.clients.adminDesktop.macos.releaseVersion, + "1.2.3", + ); + assert.match( + readFileSync(path.join(cwd, "tmp/live-state-pr.md"), "utf8"), + /Sync RTNN liveState/, + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("prepare-live-state-pr rejects unrelated dirty files", () => { + const cwd = setupRepository(); + try { + writeFileSync(path.join(cwd, "README.md"), "dirty\n"); + + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /只允许修改 \.rtnn\/project\.json/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("prepare-live-state-pr rejects summary paths outside generated dirs", () => { + const cwd = setupRepository(); + try { + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--summary-md", + "README.md", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /只能使用临时\/产物目录/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("prepare-live-state-pr rejects tracked summary files", () => { + const cwd = setupRepository(); + try { + mkdirSync(path.join(cwd, "tmp"), { recursive: true }); + writeFileSync(path.join(cwd, "tmp/live-state-pr.md"), "tracked\n"); + run("git", ["add", "tmp/live-state-pr.md"], cwd); + run("git", ["commit", "-m", "tracked summary"], cwd); + + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--summary-md", + "tmp/live-state-pr.md", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /不能覆盖 git 已跟踪文件/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/tests/release-status-ci.test.mjs b/tests/release-status-ci.test.mjs new file mode 100644 index 0000000..90dc607 --- /dev/null +++ b/tests/release-status-ci.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { writeReleaseProject } from "./helpers/release-fixtures.mjs"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT_PATH = path.join( + ROOT_DIR, + "scripts/release/run-release-status-ci.mjs", +); + +test("release status CI writes JSON, Markdown and GitHub outputs", () => { + const cwd = mkdtempSync(path.join(tmpdir(), "rtnn-release-status-ci-")); + try { + writeReleaseProject(cwd); + const outputPath = path.join(cwd, "github-output.txt"); + const summaryPath = path.join(cwd, "github-summary.md"); + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--client-artifacts-dir", + "artifacts/client-release", + "--output-dir", + "artifacts/release-status", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + GITHUB_OUTPUT: outputPath, + GITHUB_STEP_SUMMARY: summaryPath, + }, + }, + ); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "fresh"); + assert.equal(payload.code, "OK"); + + const output = readFileSync(outputPath, "utf8"); + assert.match(output, /ok=true/); + assert.match(output, /status=fresh/); + assert.match(output, /code=OK/); + + const markdown = readFileSync( + path.join(cwd, "artifacts/release-status/release-status.md"), + "utf8", + ); + assert.match(markdown, /RTNN Release Status/); + assert.match(markdown, /Conclusion:\*\* fresh/); + assert.match(readFileSync(summaryPath, "utf8"), /RTNN Release Status/); + + const json = JSON.parse( + readFileSync( + path.join(cwd, "artifacts/release-status/release-status.json"), + "utf8", + ), + ); + assert.equal(json.status, "fresh"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("release status CI preserves stale result artifacts and outputs", () => { + const cwd = mkdtempSync(path.join(tmpdir(), "rtnn-release-status-ci-")); + try { + writeReleaseProject(cwd, { + activeRelease: "main-old", + sourceSha: "old", + }); + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--output-dir", + "artifacts/release-status", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + GITHUB_OUTPUT: path.join(cwd, "github-output.txt"), + }, + }, + ); + + assert.notEqual(result.status, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, "stale"); + assert.equal(payload.code, "RUNTIME_FACTS_STALE"); + const markdown = readFileSync( + path.join(cwd, "artifacts/release-status/release-status.md"), + "utf8", + ); + assert.match(markdown, /RUNTIME_FACTS_STALE/); + const output = readFileSync(path.join(cwd, "github-output.txt"), "utf8"); + assert.match(output, /ok=false/); + assert.match(output, /status=stale/); + assert.match(output, /code=RUNTIME_FACTS_STALE/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/tests/release-status-contract.test.mjs b/tests/release-status-contract.test.mjs new file mode 100644 index 0000000..8622558 --- /dev/null +++ b/tests/release-status-contract.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { + RELEASE_STATUS_CODE_DETAILS, + RELEASE_STATUS_CODES, + RELEASE_STATUS_VALUES, +} from "../scripts/lib/release-status-contract.mjs"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("release status contract documents every stable code", () => { + const documentedCodes = new Set( + RELEASE_STATUS_CODE_DETAILS.map((item) => item.code), + ); + + for (const code of Object.values(RELEASE_STATUS_CODES)) { + assert.equal(documentedCodes.has(code), true, `${code} is undocumented`); + } +}); + +test("release status code documentation includes every stable code", () => { + const content = readFileSync( + path.join(ROOT_DIR, "docs/operations/release-status-codes.md"), + "utf8", + ); + + for (const code of Object.values(RELEASE_STATUS_CODES)) { + assert.match(content, new RegExp(`\\\`${code}\\\``), `${code} missing in docs`); + } +}); + +test("release status contract uses known statuses and next actions", () => { + const statuses = new Set(Object.values(RELEASE_STATUS_VALUES)); + + for (const item of RELEASE_STATUS_CODE_DETAILS) { + assert.equal(statuses.has(item.status), true, `${item.code} has invalid status`); + assert.equal(Boolean(item.meaning), true, `${item.code} missing meaning`); + assert.equal(Boolean(item.nextAction), true, `${item.code} missing next action`); + } +}); diff --git a/tests/release-status.test.mjs b/tests/release-status.test.mjs new file mode 100644 index 0000000..edf9ebe --- /dev/null +++ b/tests/release-status.test.mjs @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { + writeReleaseProject, + writeRuntimeFacts, + writeTemplateEnv, +} from "./helpers/release-fixtures.mjs"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SCRIPT_PATH = path.join( + ROOT_DIR, + "scripts/release/check-release-status.mjs", +); + +function setupProject({ + activeRelease = "main-abc123", + sourceSha = "abc123", + includeClientState = true, + includeMetadata = true, +} = {}) { + const cwd = mkdtempSync(path.join(tmpdir(), "rtnn-release-status-")); + + if (includeMetadata) { + writeReleaseProject(cwd, { + activeRelease, + sourceSha, + includeClientState, + }); + } else { + writeTemplateEnv(cwd); + writeRuntimeFacts(cwd); + } + + return cwd; +} + +function run(cwd, args = []) { + return spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--json", + ...args, + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); +} + +test("release status passes when runtime and client liveState are fresh", () => { + const cwd = setupProject(); + try { + const result = run(cwd, [ + "--client-artifacts-dir", + "artifacts/client-release", + ]); + + assert.equal(result.status, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, true); + assert.equal(payload.status, "fresh"); + assert.equal(payload.code, "OK"); + assert.equal(payload.checks.profile.ok, true); + assert.equal(payload.checks.runtime.ok, true); + assert.equal(payload.checks.clientLiveState.ok, true); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("release status fails when runtime liveState is stale", () => { + const cwd = setupProject({ + activeRelease: "main-old", + sourceSha: "old", + }); + try { + const result = run(cwd); + + assert.notEqual(result.status, 0); + + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, false); + assert.equal(payload.status, "stale"); + assert.equal(payload.checks.runtime.ok, false); + assert.equal(payload.checks.runtime.code, "RUNTIME_FACTS_STALE"); + assert.equal( + payload.checks.runtime.environments[0].mismatches[0].field, + "activeRelease", + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("release status fails when client liveState differs from artifacts", () => { + const cwd = setupProject({ + includeClientState: false, + }); + try { + const result = run(cwd, [ + "--client-artifacts-dir", + "artifacts/client-release", + ]); + + assert.notEqual(result.status, 0); + + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, false); + assert.equal(payload.status, "stale"); + assert.equal(payload.checks.runtime.ok, true); + assert.equal(payload.checks.clientLiveState.ok, false); + assert.equal(payload.checks.clientLiveState.code, "CLIENT_LIVE_STATE_STALE"); + assert.equal( + payload.checks.clientLiveState.environments[0].changeCount, + 1, + ); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("release status reports missing project metadata clearly", () => { + const cwd = setupProject({ + includeMetadata: false, + }); + try { + const result = run(cwd); + + assert.notEqual(result.status, 0); + + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, false); + assert.equal(payload.checks.profile.ok, true); + assert.equal(payload.checks.runtime.ok, false); + assert.equal(payload.checks.runtime.code, "MISSING_PROJECT_METADATA"); + assert.match(payload.checks.runtime.error, /缺少项目事实文件/); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("release status writes JSON output and markdown summary", () => { + const cwd = setupProject(); + try { + const result = spawnSync( + process.execPath, + [ + SCRIPT_PATH, + "--facts-file", + "runtime-facts.json", + "--environment", + "testing", + "--client-artifacts-dir", + "artifacts/client-release", + "--summary-md", + "--output", + "status/release-status.json", + ], + { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /RTNN Release Status/); + assert.match(result.stdout, /Conclusion:\*\* fresh/); + + const output = JSON.parse( + readFileSync(path.join(cwd, "status/release-status.json"), "utf8"), + ); + assert.equal(output.status, "fresh"); + assert.equal(output.code, "OK"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/tests/sync-live-state-workflow.test.mjs b/tests/sync-live-state-workflow.test.mjs new file mode 100644 index 0000000..4ca7e2d --- /dev/null +++ b/tests/sync-live-state-workflow.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const WORKFLOW_PATH = path.join( + ROOT_DIR, + ".github/workflows/sync-live-state.yml", +); + +test("sync-live-state workflow wires runtime facts status and liveState PR", () => { + const content = readFileSync(WORKFLOW_PATH, "utf8"); + + for (const expected of [ + "repository_dispatch:", + "sync-rtnn-live-state", + "source_repository", + "source_run_id", + "runtime_facts_artifact", + "runtime_facts_file", + "mode:", + "prepare-pr", + "actions/download-artifact@v4", + "repository: ${{ steps.input.outputs.source_repository }}", + "DEPLOY_REPOSITORY_READ_TOKEN", + "actions/upload-artifact@v4", + "node scripts/release/run-release-status-ci.mjs", + "continue-on-error:", + "status == 'blocked'", + "refusing to prepare liveState PR", + "node scripts/release/run-live-state-pr-ci.mjs", + "contents: write", + "pull-requests: write", + "rtnn-release-status", + "rtnn-live-state-pr", + ]) { + assert.match(content, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + + assert.doesNotMatch(content, /pnpm run release:sync-live-state -- --write/); +}); From 7e99232536422cbcabfdad440027d0fe3f506a70 Mon Sep 17 00:00:00 2001 From: Hxgh <3088816598@qq.com> Date: Wed, 3 Jun 2026 13:06:14 +0800 Subject: [PATCH 2/7] chore: fix backend message lint --- apps/backend/src/common/i18n/backend-messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/common/i18n/backend-messages.ts b/apps/backend/src/common/i18n/backend-messages.ts index 325577b..f469631 100644 --- a/apps/backend/src/common/i18n/backend-messages.ts +++ b/apps/backend/src/common/i18n/backend-messages.ts @@ -199,7 +199,7 @@ export function getBackendMessageFromCode( code: string, locale: SupportedLocale, ): string | undefined { - return codeMessages[locale][code as ApiErrorCode]; + return codeMessages[locale][code]; } export function getBackendMessageCode(message: string): string | undefined { From 70a4511516883db3abb58809ce6841c060a3d76e Mon Sep 17 00:00:00 2001 From: Hxgh <3088816598@qq.com> Date: Wed, 3 Jun 2026 13:16:48 +0800 Subject: [PATCH 3/7] test: stabilize live state pr ci branch fixture --- tests/live-state-pr-ci.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/live-state-pr-ci.test.mjs b/tests/live-state-pr-ci.test.mjs index ee484dd..dd3652d 100644 --- a/tests/live-state-pr-ci.test.mjs +++ b/tests/live-state-pr-ci.test.mjs @@ -34,7 +34,7 @@ function run(command, args, cwd) { function setupRepository(options = {}) { const cwd = mkdtempSync(path.join(tmpdir(), "rtnn-live-state-pr-ci-")); - run("git", ["init"], cwd); + run("git", ["init", "--initial-branch=main"], cwd); run("git", ["config", "user.email", "agent@example.com"], cwd); run("git", ["config", "user.name", "Agent"], cwd); writeReleaseProject(cwd, options); From 0febafa2462099301a78d09bc3f42d1124d204b7 Mon Sep 17 00:00:00 2001 From: Hxgh <3088816598@qq.com> Date: Wed, 3 Jun 2026 13:37:04 +0800 Subject: [PATCH 4/7] test: isolate playwright smoke wrapper env --- tests/playwright-ui-smoke-wrapper.test.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/playwright-ui-smoke-wrapper.test.mjs b/tests/playwright-ui-smoke-wrapper.test.mjs index f4ce47c..f41b4cb 100644 --- a/tests/playwright-ui-smoke-wrapper.test.mjs +++ b/tests/playwright-ui-smoke-wrapper.test.mjs @@ -26,6 +26,7 @@ function run(extraEnv = {}) { test("local Playwright UI smoke wrapper skips when browser is missing", () => { const result = run({ CI: "", + RTNN_RUN_UI_SMOKE: "", RTNN_REQUIRE_PLAYWRIGHT_UI: "", RTNN_ALLOW_LOCAL_PLAYWRIGHT_UI: "", }); From 4f46ee57c66402111780683810d149cde3ca04ab Mon Sep 17 00:00:00 2001 From: Hxgh <3088816598@qq.com> Date: Wed, 3 Jun 2026 13:48:41 +0800 Subject: [PATCH 5/7] fix: detect playwright chromium via cli path --- scripts/runtime/run-playwright-ui-smoke.mjs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/runtime/run-playwright-ui-smoke.mjs b/scripts/runtime/run-playwright-ui-smoke.mjs index 1ffaddd..20bae17 100644 --- a/scripts/runtime/run-playwright-ui-smoke.mjs +++ b/scripts/runtime/run-playwright-ui-smoke.mjs @@ -36,12 +36,24 @@ async function hasBundledChromium() { return true; } - try { - const { chromium } = await import("playwright"); - return existsSync(chromium.executablePath()); - } catch { + const result = spawnSync( + "pnpm", + ["exec", "playwright", "install", "--dry-run", "chromium"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + if (result.status !== 0) { return false; } + + return result.stdout + .split(/\r?\n/) + .filter((line) => line.includes("Install location:")) + .map((line) => line.replace(/^.*Install location:\s*/, "").trim()) + .some((installPath) => installPath.length > 0 && existsSync(installPath)); } function printMissingBrowserMessage() { From edf412f16d7f70358b00032486b07dd24a2cfdf3 Mon Sep 17 00:00:00 2001 From: Hxgh <3088816598@qq.com> Date: Wed, 3 Jun 2026 13:59:03 +0800 Subject: [PATCH 6/7] test: align admin audit smoke with visible action label --- tests/acceptance/admin-acceptance.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/acceptance/admin-acceptance.spec.ts b/tests/acceptance/admin-acceptance.spec.ts index 1dc850c..beb2235 100644 --- a/tests/acceptance/admin-acceptance.spec.ts +++ b/tests/acceptance/admin-acceptance.spec.ts @@ -273,7 +273,7 @@ test("admin 首发边界界面验收", async ({ page }, testInfo) => { "管理员", ); await page.getByRole("button", { name: "搜索" }).click(); - await expect(page.locator("tbody tr").first()).toContainText("admin.customer.password.reset"); + await expect(page.locator("tbody tr").first()).toContainText("重置客户密码"); await expect(page.locator("tbody tr").first()).toContainText("管理员"); await page.getByRole("button", { name: new RegExp(adminDisplayName) }).click(); From 25d22b7f3cc7f2871d3ea20ea848120b75397a34 Mon Sep 17 00:00:00 2001 From: Hxgh <3088816598@qq.com> Date: Wed, 3 Jun 2026 14:08:55 +0800 Subject: [PATCH 7/7] test: relax app device service link text smoke --- tests/acceptance/app-acceptance.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/acceptance/app-acceptance.spec.ts b/tests/acceptance/app-acceptance.spec.ts index 9d35e7f..f22f9a5 100644 --- a/tests/acceptance/app-acceptance.spec.ts +++ b/tests/acceptance/app-acceptance.spec.ts @@ -7,6 +7,8 @@ import { const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const either = (...values: string[]) => new RegExp(`^(?:${values.map(escapeRegExp).join("|")})$`); +const containsEither = (...values: string[]) => + new RegExp(`(?:${values.map(escapeRegExp).join("|")})`); const templateEnv = resolveTemplateEnv(process.cwd()); const cookieKeys = getTemplateCookieKeys(templateEnv); @@ -36,11 +38,11 @@ const confirmPasswordLabel = either("确认新密码", "Confirm new password"); const logoutButton = either("退出登录", "Sign out"); const forbiddenTitle = either("无权限访问", "Access denied"); const nativeCapabilitiesTitle = either("设备服务", "Device Services"); -const nativeScannerTitle = either("扫码", "Scan"); -const nativeMapTitle = either("地图导航", "Map navigation"); -const nativeMediaTitle = either("相机相册", "Camera and photos"); -const nativeNotificationTitle = either("通知", "Notifications"); -const nativeSafeAreaTitle = either("键盘与安全区", "Keyboard and safe area"); +const nativeScannerTitle = containsEither("扫码", "Scan"); +const nativeMapTitle = containsEither("地图导航", "Map navigation"); +const nativeMediaTitle = containsEither("相机相册", "Camera and photos"); +const nativeNotificationTitle = containsEither("通知", "Notifications"); +const nativeSafeAreaTitle = containsEither("键盘与安全区", "Keyboard and safe area"); const refreshTokenCookieName = cookieKeys.customerRefreshToken;