From 2653c587ec6129a9308ebc3fd1917f889a9953c3 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 30 Oct 2026 17:09:00 +0800 Subject: [PATCH 01/36] feat(desktop): recover website publication and artifact archival --- .github/workflows/desktop-publish.yml | 95 ++++++++++++ .github/workflows/macos-intel-package.yml | 45 +++++- .github/workflows/macos-package.yml | 45 +++++- .github/workflows/windows-package.yml | 50 +++++- apps/desktop/publishing/ci/README.i18n.yaml | 8 +- apps/desktop/publishing/ci/README.md | 12 +- apps/desktop/publishing/ci/README.zh.md | 12 +- apps/desktop/scripts/archive-desktop-ci.ts | 24 +++ .../scripts/desktop-publication-failure.ts | 5 +- apps/desktop/scripts/desktop-publish-route.ts | 2 +- .../desktop/scripts/desktop-purge-recovery.ts | 146 ++++++++++++++++++ apps/desktop/scripts/desktop-release-store.ts | 19 ++- .../scripts/desktop-website-installer.ts | 109 +++++++++++++ apps/desktop/scripts/publish-desktop-ci.ts | 7 +- apps/desktop/scripts/publish-website-ci.ts | 129 ++++++++++++++++ .../scripts/resolve-desktop-publish-ci.ts | 27 +++- apps/desktop/scripts/retain-desktop-ci.ts | 24 ++- .../tests/desktop-package-selection.spec.ts | 25 ++- .../tests/desktop-purge-recovery.spec.ts | 101 ++++++++++++ .../tests/desktop-website-installer.spec.ts | 88 +++++++++++ .../tests/expected/website-purge-unknown.md | 6 + apps/desktop/tests/publish-website-ci.spec.ts | 50 ++++++ apps/desktop/tests/retain-desktop-ci.spec.ts | 19 ++- scripts/desktop-package-source.spec.ts | 4 + scripts/desktop-publish-workflow.spec.ts | 15 ++ scripts/macos-package-workflow.spec.ts | 15 +- scripts/windows-package-workflow.spec.ts | 8 +- 27 files changed, 1051 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/scripts/archive-desktop-ci.ts create mode 100644 apps/desktop/scripts/desktop-purge-recovery.ts create mode 100644 apps/desktop/scripts/desktop-website-installer.ts create mode 100644 apps/desktop/scripts/publish-website-ci.ts create mode 100644 apps/desktop/tests/desktop-purge-recovery.spec.ts create mode 100644 apps/desktop/tests/desktop-website-installer.spec.ts create mode 100644 apps/desktop/tests/expected/website-purge-unknown.md create mode 100644 apps/desktop/tests/publish-website-ci.spec.ts diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 3e5c006d7b..ff403221be 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -9,6 +9,11 @@ on: description: '打包运行 ID:填写成功的 Package Desktop run ID;发布环境和 release ID 沿用原包' type: string required: true + upload_website: + description: '同时上传官网安装包并刷新缓存(仅 production);取消后仅执行版本与自动更新发布' + type: boolean + default: true + required: true package_run_attempt: description: '打包尝试次数:留空选择最新一次,且必须成功;失败时不会回退到旧包' type: string @@ -56,10 +61,15 @@ jobs: outputs: route: ${{ steps.local.outputs.route }} preset: ${{ steps.local.outputs.preset }} + target: ${{ fromJSON(steps.local.outputs.route).target }} approval: ${{ steps.check.outputs.approval }} + website-approval: ${{ steps.check.outputs.website-approval }} + website-destination: ${{ steps.check.outputs.website-destination }} label: ${{ steps.check.outputs.label }} env: DESKTOP_PACKAGE_SELECTION: ${{ needs.route.outputs.selection }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} DSH_TELEMETRY_DISABLED: '1' steps: - uses: actions/checkout@v7.0.1 @@ -151,6 +161,7 @@ jobs: DESKTOP_TAG_TOKEN: ${{ steps.app.outputs.token }} run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --publish - name: Retain publication audit + continue-on-error: true if: always() && steps.publish.outputs.record != '' uses: actions/upload-artifact@v7.0.1 with: @@ -158,3 +169,87 @@ jobs: path: ${{ steps.publish.outputs.record }}/ if-no-files-found: error retention-days: 30 + + upload-website-installer: + needs: [route, prepare, publish] + if: inputs.upload_website && needs.prepare.outputs.preset == 'production' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: desktop-production + timeout-minutes: 60 + concurrency: + group: desktop-website-upload-${{ needs.prepare.outputs.website-destination }} + cancel-in-progress: false + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: website-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: website-publisher/package.json + dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: website-publisher + - name: Upload website installer + id: website + working-directory: website-publisher + run: node --import tsx apps/desktop/scripts/publish-website-ci.ts upload + - name: Archive website publication diagnostics + if: always() && steps.website.outputs.record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-upload-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.record }}/ + if-no-files-found: warn + retention-days: 30 + + purge-website-installer: + needs: [route, prepare, upload-website-installer] + if: inputs.upload_website && needs.prepare.outputs.preset == 'production' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: desktop-production + timeout-minutes: 60 + concurrency: + group: desktop-website-purge-${{ needs.prepare.outputs.target }} + cancel-in-progress: false + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: website-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: website-publisher/package.json + dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: website-publisher + - name: Recover and observe website cache purge + id: website + working-directory: website-publisher + run: node --import tsx apps/desktop/scripts/publish-website-ci.ts purge + - name: Archive website publication diagnostics + if: always() && steps.website.outputs.record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-purge-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.record }}/ + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml index cc835b8933..7f5efd92f0 100644 --- a/.github/workflows/macos-intel-package.yml +++ b/.github/workflows/macos-intel-package.yml @@ -57,6 +57,13 @@ concurrency: jobs: package: + outputs: + directory: ${{ steps.retained.outputs.directory }} + runner: ${{ steps.retained.outputs.runner }} + runner-name: ${{ steps.retained.outputs.runner-name }} + attempt: ${{ steps.retained.outputs.attempt }} + version: ${{ steps.settings.outputs.version }} + mode: ${{ steps.settings.outputs.mode }} runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] timeout-minutes: 180 defaults: @@ -98,6 +105,7 @@ jobs: if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); } if(!fs.readFileSync('apps/desktop/scripts/prepare-macos-ci.mjs','utf8').includes('resolveDesktopBuildTarget')) throw Error('Source lacks Intel macOS CI support; select a newer source'); + if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source'); if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: @@ -115,6 +123,10 @@ jobs: run: arch -x86_64 /usr/bin/true - name: Install locked dependencies run: pnpm install --frozen-lockfile + - name: Check original machine archival route + env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner - name: Validate preset and resolve build version id: settings env: @@ -137,10 +149,12 @@ jobs: - name: Retain complete package outside the checkout id: retained env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics + continue-on-error: true if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 with: @@ -149,13 +163,40 @@ jobs: path: apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 + + upload-artifact: + needs: package + runs-on: [self-hosted, '${{ needs.package.outputs.runner }}'] + timeout-minutes: 30 + env: + DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} + DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + path: artifact-archiver + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: artifact-archiver/package.json + dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: artifact-archiver + - name: Verify original retained build before GitHub archival + working-directory: artifact-archiver + run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - name: Retain installers for the selected mode if: success() uses: actions/upload-artifact@v7.0.1 with: - name: macos-x64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: macos-x64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} include-hidden-files: true - path: ${{ steps.retained.outputs.directory }}/* + path: ${{ needs.package.outputs.directory }}/* compression-level: 0 if-no-files-found: error retention-days: 14 + overwrite: true diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 073f1e9541..5da47a1c94 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -57,6 +57,13 @@ concurrency: jobs: package: + outputs: + directory: ${{ steps.retained.outputs.directory }} + runner: ${{ steps.retained.outputs.runner }} + runner-name: ${{ steps.retained.outputs.runner-name }} + attempt: ${{ steps.retained.outputs.attempt }} + version: ${{ steps.settings.outputs.version }} + mode: ${{ steps.settings.outputs.mode }} runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] timeout-minutes: 180 defaults: @@ -97,6 +104,7 @@ jobs: const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); } + if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source'); if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: @@ -112,6 +120,10 @@ jobs: echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" - name: Install locked dependencies run: pnpm install --frozen-lockfile + - name: Check original machine archival route + env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner - name: Validate preset and resolve build version id: settings env: @@ -134,10 +146,12 @@ jobs: - name: Retain complete package outside the checkout id: retained env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics + continue-on-error: true if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 with: @@ -146,13 +160,40 @@ jobs: path: apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 + + upload-artifact: + needs: package + runs-on: [self-hosted, '${{ needs.package.outputs.runner }}'] + timeout-minutes: 30 + env: + DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} + DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + path: artifact-archiver + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: artifact-archiver/package.json + dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: artifact-archiver + - name: Verify original retained build before GitHub archival + working-directory: artifact-archiver + run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - name: Retain installers for the selected mode if: success() uses: actions/upload-artifact@v7.0.1 with: - name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: macos-arm64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} include-hidden-files: true - path: ${{ steps.retained.outputs.directory }}/* + path: ${{ needs.package.outputs.directory }}/* compression-level: 0 if-no-files-found: error retention-days: 14 + overwrite: true diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 29229605de..03657ceeb9 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -63,6 +63,13 @@ concurrency: jobs: package: + outputs: + directory: ${{ steps.retained.outputs.directory }} + runner: ${{ steps.retained.outputs.runner }} + runner-name: ${{ steps.retained.outputs.runner-name }} + attempt: ${{ steps.retained.outputs.attempt }} + version: ${{ steps.settings.outputs.version }} + mode: ${{ steps.settings.outputs.mode }} runs-on: dsh-win-package-trial timeout-minutes: 120 defaults: @@ -127,6 +134,7 @@ jobs: const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); } + if(!fs.readFileSync('apps/desktop/scripts/retain-desktop-ci.ts','utf8').includes('--check-runner')) throw Error('Source lacks independent Desktop artifact archival support; select a newer source'); if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" - uses: pnpm/action-setup@v5.0.0 with: @@ -158,6 +166,10 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm install --frozen-lockfile exit $LASTEXITCODE + - name: Check original machine archival route + env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts --check-runner - name: Validate preset and resolve build version id: settings env: @@ -183,10 +195,12 @@ jobs: - name: Retain complete package outside the checkout id: retained env: + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} PACKAGE_VERSION: ${{ steps.settings.outputs.version }} run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts - name: Retain packaging diagnostics + continue-on-error: true if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 with: @@ -195,23 +209,51 @@ jobs: path: ${{ github.workspace }}/apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 + + upload-artifact: + needs: package + runs-on: [self-hosted, '${{ needs.package.outputs.runner }}'] + timeout-minutes: 30 + env: + DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} + DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} + DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + path: artifact-archiver + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: artifact-archiver/package.json + dest: ${{ runner.temp }}/archive-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: artifact-archiver + - name: Verify original retained build before GitHub archival + working-directory: artifact-archiver + run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts - name: Retain verified unsigned installer if: success() && inputs.signing == 'unsigned' uses: actions/upload-artifact@v7.0.1 with: - name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: windows-x64-unsigned-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} compression-level: 0 include-hidden-files: true - path: ${{ steps.retained.outputs.directory }}/* + path: ${{ needs.package.outputs.directory }}/* if-no-files-found: error retention-days: 14 + overwrite: true - name: Retain verified signed installer if: success() && inputs.signing == 'signed' uses: actions/upload-artifact@v7.0.1 with: - name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: windows-x64-signed-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }} compression-level: 0 include-hidden-files: true - path: ${{ steps.retained.outputs.directory }}/* + path: ${{ needs.package.outputs.directory }}/* if-no-files-found: error retention-days: 14 + overwrite: true diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 14ef478ed7..4316440977 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -24,11 +24,11 @@ en: 0f43f2e193e95043 zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: - en: f7c12d343891e547 - zh: da596ee1366c2046 + en: be3cf37739f7f9b3 + zh: 33b7c8ca3b547c5e /desktop-ci-publication/recovery-and-records: - en: b62a7484929f9941 - zh: c9dd06c77098e6ef + en: 0f4ff50cd6137d00 + zh: bb5be9da7e6d29f1 /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 5009bf87e7..ee23311d04 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -72,7 +72,7 @@ For publication to either test or production, choose Windows `signing=signed` or Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. -Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. +Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt and its successful package job. An archival-only retry retains the original build attempt; a failed newer package job never falls back to an older success. An explicit attempt selects that build even when its separate GitHub archival failed. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. Before installing dependencies on the original runner, preparation and the approved publish job check that the selected `publishing/.json` is readable JSON. Missing configuration reports its path; restore it and retry publication with the same package. Full configuration and signature validation still run afterward. Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. @@ -80,6 +80,16 @@ Before installing dependencies on the original runner, preparation and the appro ## Recovery and records +The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). Both use `DESKTOP_PUBLISH_RUNNERS` to keep retained files on the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. + +Production COS publication defaults to **同时上传官网安装包并刷新缓存** (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. It does not require a stable version merely because the fixed filename contains latest. The existing version/update publication retains its own checks. + +Set the repository variable `DOWNLOAD_PROD_EO_ZONE_ID` to the production EdgeOne zone. The production COS credential pair also needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks`. Website uploads serialize by bucket/target in Actions and by bucket/object on the original machine. Cache observation runs separately. Production website jobs use the existing deployment approval Environment. + +Website state lives in `upload-records/website--/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. A completed upload is not repeated on a job rerun; ambiguous uploads reconcile remote bytes first. Rerun only the purge job to inspect its saved task. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. + +Purge observation waits up to five minutes at ten-second intervals, with one additional query after an unknown result or observation deadline. Confirmed failed, timeout or canceled tasks fail the job. An unresolved result exits successfully with an Actions warning and a prominent summary; its durable state remains unknown, not completed. Diagnostic artifact archival is best effort; local records remain the recovery source. Simulated-provider tests cover these paths; production COS/EdgeOne permissions and actual CDN behavior require release-operator qualification. + After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 151032eed9..22ecb5606f 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -72,7 +72,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 -打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 +打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试及其成功的 package job;归档重跑继续使用原始构建 attempt,新的构建失败时不回退到较早成功构建。显式填写则选择对应构建,独立的 GitHub 归档失败不取消其资格。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 在原 runner 安装依赖前,准备阶段和审批后的发布 job 都会检查所选 `publishing/.json` 是否为可读取的 JSON。配置缺失时会报告路径;补齐后可使用同一个包重试发布。之后仍会执行完整配置与签名校验。上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 @@ -80,6 +80,16 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复与记录 +打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。两者使用 `DESKTOP_PUBLISH_RUNNERS` 将保留文件定位到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。 + +生产 COS 发布默认勾选“同时上传官网安装包并刷新缓存”(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。固定文件名包含 latest 不意味着要求稳定版本。原有版本与自动更新发布保留自身校验。 + +将仓库变量 `DOWNLOAD_PROD_EO_ZONE_ID` 配置为生产 EdgeOne 站点。生产 COS 凭据还需要 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。官网上传在 Actions 中按 bucket/平台互斥,在原机器上按 bucket/对象互斥;缓存观察单独运行。生产官网 job 沿用现有部署审批 Environment。 + +官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。job 重跑不重复已确认完成的上传;结果未知的上传先核对远端字节。仅重跑刷新 job 即可查询已保存任务;人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。 + +刷新观察每十秒查询一次,最长五分钟;结果未知或观察超时后额外查询一次。明确的 failed、timeout、canceled 结果使 job 失败。仍无法确认时成功退出,并输出 Actions warning 和醒目摘要;持久化状态保持 unknown,不标为完成。诊断 artifact 尽力归档,本地记录仍是恢复依据。模拟服务测试覆盖这些路径;生产 COS/EdgeOne 权限和实际 CDN 行为需要发布操作者验证。 + 解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 diff --git a/apps/desktop/scripts/archive-desktop-ci.ts b/apps/desktop/scripts/archive-desktop-ci.ts new file mode 100644 index 0000000000..c6acad8de2 --- /dev/null +++ b/apps/desktop/scripts/archive-desktop-ci.ts @@ -0,0 +1,24 @@ +/** Validate the original machine and immutable inventory before GitHub artifact archival. */ +import { resolve } from 'node:path' +import { desktopReleasesRoot } from './retain-desktop-ci.ts' +import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts' + +/** + * Verify an original build without interpreting the archive job's attempt as a new build. + * @param env Original build outputs and current Actions machine identity. + * @returns Resolves after the retained inventory passes all digest checks. + */ +export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise { + if (!env.RUNNER_NAME || env.RUNNER_NAME !== env.DESKTOP_PACKAGE_RUNNER) throw new Error('desktop archival: wrong original machine') + const root = desktopReleasesRoot(env) + const runId = env.GITHUB_RUN_ID || '' + const attempt = env.DESKTOP_PACKAGE_ATTEMPT || '' + const expected = retainedDesktopPath(root, runId, attempt) + if (env.DESKTOP_RETAINED_DIRECTORY !== expected) throw new Error('desktop archival: retained directory differs from original build') + const manifest = await verifyRetainedDesktopRelease(root, runId, attempt) + if (manifest.source.repository !== env.GITHUB_REPOSITORY || manifest.source.runnerName !== env.RUNNER_NAME) { + throw new Error('desktop archival: retained source does not match repository or machine') + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) await verifyDesktopArchive(process.env) diff --git a/apps/desktop/scripts/desktop-publication-failure.ts b/apps/desktop/scripts/desktop-publication-failure.ts index 930c23da2d..dde9b1c58c 100644 --- a/apps/desktop/scripts/desktop-publication-failure.ts +++ b/apps/desktop/scripts/desktop-publication-failure.ts @@ -1,5 +1,6 @@ /** Safe failure details shared by publication diagnostics and durable journals. */ import { ZodError } from 'zod' +import { DesktopPurgeExecutionError } from './desktop-purge-recovery.ts' import { DesktopPublishError } from './desktop-publish.ts' const publicCodes = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch', @@ -39,12 +40,14 @@ export function desktopPublicationFailure(error: unknown, phase: string): { ...(typeof requestId === 'string' && /^[\w+/=.-]{1,256}$/u.test(requestId) ? { requestId } : {}), ...(typeof status === 'number' && Number.isInteger(status) && status >= 100 && status <= 599 ? { httpStatus: status } : {}) } } + if (error instanceof DesktopPurgeExecutionError) return { phase, code: `purge-${error.status}`, message: error.message } if (error instanceof DesktopPublishError) return { phase, code: error.code, message: error.message } if (error instanceof ZodError) return { phase, code: 'invalid-data', message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` } if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' } const ownedMessage = /^(desktop (?:publish|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u - if (error instanceof Error && ownedMessage.test(error.message)) { + const websiteMessage = /^(website (?:publication|installer|cache purge)|desktop archival):/u + if (error instanceof Error && (ownedMessage.test(error.message) || websiteMessage.test(error.message))) { return { phase, code: 'validation', message: error.message } } const record = typeof error === 'object' && error !== null ? error : {} diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 9b6fedfa42..31b691e22b 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -6,7 +6,7 @@ import { desktopPublishQualificationError } from './desktop-publish-qualificatio const positive = z.number().int().positive() const runSchema = z.object({ id: positive, run_attempt: positive, head_sha: z.string().regex(/^[a-f0-9]{40}$/u), - path: z.string(), conclusion: z.literal('success'), event: z.literal('workflow_dispatch'), + path: z.string(), conclusion: z.string(), event: z.literal('workflow_dispatch'), repository: z.object({ full_name: z.string() }), }) const requestSchema = z.object({ source: z.object({ diff --git a/apps/desktop/scripts/desktop-purge-recovery.ts b/apps/desktop/scripts/desktop-purge-recovery.ts new file mode 100644 index 0000000000..f970f16b32 --- /dev/null +++ b/apps/desktop/scripts/desktop-purge-recovery.ts @@ -0,0 +1,146 @@ +/** Resume a fixed-URL purge without repeating ambiguous submissions. */ +import type { teo } from 'tencentcloud-sdk-nodejs-teo' +import type { DesktopPurgeConfig } from './desktop-edgeone.ts' + +/** Non-secret evidence saved before submission and after every observation. */ +export interface DesktopPurgeRecord { + readonly zoneId: string + readonly publicUrl: string + readonly submittedAt: string + readonly jobId?: string | undefined + readonly status: 'pending' | 'processing' | 'success' | 'failed' | 'timeout' | 'canceled' | 'unknown' + readonly observedAt?: string | undefined + readonly diagnostic?: string | undefined +} + +/** SDK requests used by the recovery owner. */ +export type DesktopPurgeRecoveryClient = Pick, 'CreatePurgeTask' | 'DescribePurgeTasks'> + +/** Explicit bounded observation policy; writes are never automatically retried. */ +export interface DesktopPurgeRecoveryOptions { + readonly intervalMs: number + readonly waitMs: number + readonly allowResubmit: boolean + readonly now: () => number + readonly sleep: (milliseconds: number) => Promise + readonly save: (record: DesktopPurgeRecord) => Promise +} + +/** A confirmed remote failure, distinct from an unconfirmed observation. */ +export class DesktopPurgeExecutionError extends Error { + constructor(readonly status: 'failed' | 'timeout' | 'canceled') { + super(`website cache purge: ${status}; inspect the task before manually rerunning`) + } +} + +function failed(status: DesktopPurgeRecord['status']): status is 'failed' | 'timeout' | 'canceled' { + return status === 'failed' || status === 'timeout' || status === 'canceled' +} + +async function observe(record: DesktopPurgeRecord, client: DesktopPurgeRecoveryClient, + now: number, config: DesktopPurgeConfig): Promise { + const observedAt = new Date(now).toISOString() + const unknown = (diagnostic: string): DesktopPurgeRecord => ({ ...record, status: 'unknown', observedAt, diagnostic }) + const matches = [] + try { + let offset = 0 + do { + const response = await client.DescribePurgeTasks({ + ZoneId: record.zoneId, Limit: 1000, Offset: offset, + ...(record.jobId ? { Filters: [{ Name: 'job-id', Values: [record.jobId] }] } : { + StartTime: record.submittedAt, + EndTime: new Date(Math.min(now, Date.parse(record.submittedAt) + 120_000)).toISOString(), + }), + }) + if (!Array.isArray(response.Tasks) || typeof response.TotalCount !== 'number') return unknown('invalid query response') + for (const task of response.Tasks) { + if (task.Target !== record.publicUrl || task.Type !== 'purge_url') continue + if (record.jobId ? task.JobId !== record.jobId + : !task.CreateTime || !Number.isFinite(Date.parse(task.CreateTime)) + || Date.parse(task.CreateTime) < Date.parse(record.submittedAt) + || Date.parse(task.CreateTime) > Date.parse(record.submittedAt) + 120_000) continue + matches.push(task) + } + offset += response.Tasks.length + if (offset >= response.TotalCount) break + if (response.Tasks.length === 0 || offset >= 10_000) return unknown('task history could not be fully inspected') + } while (true) + } catch (error) { return unknown('task query failed; check EdgeOne history and DescribePurgeTasks permission') } + if (matches.length !== 1) return unknown('task history has no unique matching submission') + const task = matches[0]! + if (!task.JobId || !/^[\w.-]{1,128}$/u.test(task.JobId) + || task.JobId.includes(config.secretId) || task.JobId.includes(config.secretKey)) return unknown('query returned no usable task ID') + const status = task.Status + if (status !== 'processing' && status !== 'success' && status !== 'failed' && status !== 'timeout' && status !== 'canceled') { + return unknown('query returned an unknown task status') + } + return { zoneId: record.zoneId, publicUrl: record.publicUrl, submittedAt: record.submittedAt, + jobId: task.JobId, status, observedAt } +} + +/** + * Submit at most once, or reconcile an earlier submission, then observe bounded completion. + * Unknown results receive one additional query and return unknown for warning-only Actions completion. + * @param config Validated destination and credentials; credentials never enter records. + * @param previous Durable record, or undefined for a new publication. + * @param client SDK client with bounded requests and no submission retry policy. + * @param options Clock, persistence, timing and explicit manual-resubmission authorization. + * @returns Successful or unconfirmed result; confirmed failures throw after durable recording. + */ +export async function recoverDesktopPurge(config: DesktopPurgeConfig, previous: DesktopPurgeRecord | undefined, + client: DesktopPurgeRecoveryClient, options: DesktopPurgeRecoveryOptions): Promise { + if (options.intervalMs <= 0 || options.waitMs < 0) throw new Error('website cache purge: invalid observation budget') + if (previous && (previous.zoneId !== config.zoneId || previous.publicUrl !== config.publicUrl)) { + throw new Error('website cache purge: saved destination differs from selected publication') + } + let record = previous + let observedPrevious = false + if (record) { + record = await observe(record, client, options.now(), config) + observedPrevious = true + await options.save(record) + if (record.status === 'success') return record + if (failed(record.status)) { + if (!options.allowResubmit) throw new DesktopPurgeExecutionError(record.status) + record = undefined + observedPrevious = false + } + } + if (!record) { + record = { zoneId: config.zoneId, publicUrl: config.publicUrl, + submittedAt: new Date(Math.floor(options.now() / 1000) * 1000).toISOString(), status: 'pending' } + await options.save(record) + let response: Awaited> | undefined + try { response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) } + catch (error) { /* Submission may have reached EdgeOne; reconcile history without repeating the request. */ } + const jobId = response?.JobId + if (response?.FailedList?.length) { + record = { ...record, status: 'failed' } + } else if (typeof jobId === 'string' && /^[\w.-]{1,128}$/u.test(jobId) + && !jobId.includes(config.secretId) && !jobId.includes(config.secretKey)) { + record = { ...record, status: 'processing', jobId } + } else { + record = { ...record, status: 'unknown', diagnostic: 'submission acceptance unconfirmed; checking task history' } + } + await options.save(record) + if (failed(record.status)) throw new DesktopPurgeExecutionError(record.status) + } + let current: DesktopPurgeRecord = record + const deadline = options.now() + options.waitMs + let extraQuery = false + while (true) { + const observed: DesktopPurgeRecord = observedPrevious ? current : await observe(current, client, options.now(), config) + observedPrevious = false + current = observed + await options.save(current) + if (current.status === 'success') return current + if (failed(current.status)) throw new DesktopPurgeExecutionError(current.status) + if (extraQuery) { + current = { ...current, status: 'unknown', diagnostic: current.diagnostic ?? 'observation deadline exceeded' } + await options.save(current) + return current + } + if (current.status === 'unknown' || options.now() >= deadline) extraQuery = true + await options.sleep(options.intervalMs) + } +} diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index c080272e1d..33a0c7c4d2 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -121,13 +121,13 @@ export async function retainDesktopRelease(root: string, source: DesktopRetained } /** - * Read an attempt and rehash every retained file before a consumer uses it. + * Read and validate manifest identity without requiring unrelated retained files. * @param root Persistent releases root. * @param runId Expected source run ID. * @param attempt Expected source attempt. - * @returns Parsed inventory; missing, linked, changed or mismatched files reject. + * @returns Parsed inventory; consumers must verify the files they use against their recorded digests. */ -export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise { +export async function readRetainedDesktopManifest(root: string, runId: string, attempt: string): Promise { const path = retainedDesktopPath(root, runId, attempt) await directory(root) await directory(join(root, runId)) @@ -137,6 +137,19 @@ export async function verifyRetainedDesktopRelease(root: string, runId: string, const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8'))) if (manifest.source.runId !== runId || manifest.source.attempt !== attempt) throw new Error('desktop release store: attempt identity mismatch') if (new Set(manifest.files.map(file => file.name)).size !== manifest.files.length) throw new Error('desktop release store: duplicate file inventory') + return manifest +} + +/** + * Verify every retained file against its immutable build manifest. + * @param root Persistent releases directory. + * @param runId Original packaging run. + * @param attempt Original packaging attempt, unchanged by archival reruns. + * @returns Manifest after all retained files pass size and hash verification. + */ +export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise { + const manifest = await readRetainedDesktopManifest(root, runId, attempt) + const path = retainedDesktopPath(root, runId, attempt) for (const file of manifest.files) { const current = await fingerprint(join(path, file.name)) if (current.size !== file.size || current.sha512 !== file.sha512) throw new Error(`desktop release store: changed file ${file.name}`) diff --git a/apps/desktop/scripts/desktop-website-installer.ts b/apps/desktop/scripts/desktop-website-installer.ts new file mode 100644 index 0000000000..a81fe9702a --- /dev/null +++ b/apps/desktop/scripts/desktop-website-installer.ts @@ -0,0 +1,109 @@ +/** Validate one production installer and hold its file handle through COS publication. */ +import { createHash } from 'node:crypto' +import { open, lstat, type FileHandle } from 'node:fs/promises' +import { join } from 'node:path' +import type { DesktopRetainedManifest } from './desktop-release-store.ts' +import type { DesktopPublishRoute } from './desktop-publish-route.ts' +import { desktopLatestInstallerFilename } from './desktop-auto-update-environment.mjs' +import { createDesktopCos, DESKTOP_COS_REGION, type DesktopCosCredentials } from './desktop-cos.ts' +import { cosOperation } from './cos-operation.ts' +import type { DesktopObjectDigest, DesktopPublishTransport } from './desktop-publish.ts' +import { DesktopCosOperationError } from './desktop-publication-failure.ts' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' + +/** Verified file and destination; the caller closes the handle after publication. */ +export interface DesktopWebsiteInstaller { + readonly plan: DesktopUploadPlan + readonly digest: DesktopObjectDigest + readonly md5: string + readonly handle: FileHandle +} + +/** + * Check production build identity and hash only the selected installer once. + * @param directory Retained build directory on the original runner. + * @param manifest Trusted retained build inventory matched against GitHub evidence. + * @param route Pinned build identity from the credential-free routing job. + * @param repository Repository expected by the workflow. + * @param bucket Approved production COS bucket. + * @returns An open verified installer; callers must close its handle in finally. + */ +export async function prepareWebsiteInstaller(directory: string, manifest: DesktopRetainedManifest, + route: DesktopPublishRoute, repository: string, bucket: string): Promise { + const source = manifest.source + if (route.preset !== 'production' || source.preset !== 'production' + || source.repository !== repository || source.runId !== route.runId || source.attempt !== route.attempt + || source.commit !== route.commit || source.runnerName !== route.runnerName || source.target !== route.target + || source.sourceRef !== route.sourceRef || source.channel !== route.channel + || source.qualification !== (route.target === 'win-x64' ? 'signed' : 'notarized') + || !/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef)) { + throw new Error('website installer: build identity or production qualification mismatch') + } + const windows = route.target === 'win-x64' + const filename = `deepseek-harness-${source.version}-${route.target}.${windows ? 'exe' : 'dmg'}` + const file = manifest.files.find(item => item.name === filename) + if (!file) throw new Error('website installer: selected installer is absent from build manifest') + const path = join(directory, file.name) + const info = await lstat(path) + if (!info.isFile() || info.isSymbolicLink()) throw new Error('website installer: expected a regular installer file') + const handle = await open(path, 'r') + try { + const sha512 = createHash('sha512') + const md5 = createHash('md5') + let size = 0 + for await (const bytes of handle.createReadStream({ autoClose: false, start: 0 })) { + size += bytes.length + sha512.update(bytes) + md5.update(bytes) + } + const digest = { size, sha512: sha512.digest('base64') } + if (size !== file.size || digest.sha512 !== file.sha512) throw new Error('website installer: SHA-512 or size differs from build manifest') + const key = `desktop/${desktopLatestInstallerFilename(route.target)}` + return { handle, digest, md5: md5.digest('base64'), plan: { + environment: 'production', target: route.target, version: source.version, commit: source.commit, dirty: false, + bucket, publicUrl: `https://download.deepseek.com/${key}`, + secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', + artifacts: [{ path, filename: file.name, key, channelMetadata: false, + contentType: windows ? 'application/vnd.microsoft.portable-executable' : 'application/x-apple-diskimage' }], + } } + } catch (error) { + await handle.close() + throw error + } +} + +/** + * Reconcile an earlier upload or replace the fixed object once using the already verified handle. + * @param installer Open installer verified against the selected build. + * @param credentials Production upload credentials, never logged. + * @param transport Read-only reconciliation adapter using the same destination. + * @param record Durable event writer; intent must persist before the remote write. + * @param createClient Optional caller-owned COS factory; absent uses the production client. + * @returns Whether bytes were uploaded or an existing identical object was reused. + */ +export async function uploadWebsiteInstaller(installer: DesktopWebsiteInstaller, credentials: DesktopCosCredentials, + transport: Pick, + record: (event: 'upload-intent' | 'uploaded') => Promise, + createClient: typeof createDesktopCos = createDesktopCos, +): Promise<'uploaded' | 'present'> { + const artifact = installer.plan.artifacts[0]! + const matches = (remote: DesktopObjectDigest | null) => + remote?.size === installer.digest.size && remote.sha512 === installer.digest.sha512 + if (matches(await transport.read(artifact.key, false))) { + await record('uploaded') + return 'present' + } + await record('upload-intent') + const body = installer.handle.createReadStream({ autoClose: false, start: 0 }) + const cos = createClient(credentials) + try { + await cosOperation(cos, 900_000, () => cos.putObject({ Bucket: installer.plan.bucket, Region: DESKTOP_COS_REGION, + Key: artifact.key, Body: body, ContentLength: installer.digest.size, ContentType: artifact.contentType, + Headers: { 'Content-MD5': installer.md5 }, + })) + } catch (error) { throw new DesktopCosOperationError('PutObject', error) } + finally { body.destroy() } + if (!matches(await transport.read(artifact.key, false))) throw new Error('website installer: remote upload result unconfirmed; reconcile before retrying') + await record('uploaded') + return 'uploaded' +} diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index f9348ec2eb..a670e50ec3 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -14,6 +14,7 @@ import { publishDesktopRelease } from './desktop-publish.ts' import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { desktopPublicationSummary } from './desktop-publication-summary.ts' import { publishDesktopTag } from './desktop-publish-tag.ts' +import { websiteApproval } from './publish-website-ci.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), @@ -63,8 +64,12 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const env = process.env const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env) if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required') + if (env.DESKTOP_UPLOAD_WEBSITE === 'true' && route.preset === 'production' + && !/^zone-[a-z0-9]+$/u.test(env.DOWNLOAD_PROD_EO_ZONE_ID || '')) { + throw new Error('desktop publish: configure DOWNLOAD_PROD_EO_ZONE_ID before approving website upload') + } if (mode === '--check') { - if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `website-destination=${createHash('sha256').update(`${config.bucket}/${plan.target}`).digest('hex')}\nwebsite-approval=${websiteApproval(manifest, config.bucket)}\napproval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) if (env.GITHUB_STEP_SUMMARY && !env.DESKTOP_APPROVED_PACKAGE) await appendFile(env.GITHUB_STEP_SUMMARY, await desktopPublicationSummary(manifest.source, plan, approval)) if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT, diff --git a/apps/desktop/scripts/publish-website-ci.ts b/apps/desktop/scripts/publish-website-ci.ts new file mode 100644 index 0000000000..953eff9ef6 --- /dev/null +++ b/apps/desktop/scripts/publish-website-ci.ts @@ -0,0 +1,129 @@ +/** Actions entrypoint for independently recoverable website upload and cache purge jobs. */ +import { createHash, randomUUID } from 'node:crypto' +import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { z } from 'zod' +import { teo } from 'tencentcloud-sdk-nodejs-teo' +import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts' +import { desktopReleasesRoot } from './retain-desktop-ci.ts' +import { readRetainedDesktopManifest, retainedDesktopPath } from './desktop-release-store.ts' +import { prepareWebsiteInstaller, uploadWebsiteInstaller } from './desktop-website-installer.ts' +import { desktopPublishCos } from './desktop-publish-cos.ts' +import { withDesktopChannelLock } from './desktop-publish-record.ts' +import { recoverDesktopPurge, type DesktopPurgeRecord } from './desktop-purge-recovery.ts' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' + +const routeSchema = z.object({ runId: z.string().regex(/^[1-9]\d*$/u), attempt: z.string().regex(/^[1-9]\d*$/u), + commit: z.string().regex(/^[a-f0-9]{40}$/u), runnerName: z.string(), runnerLabel: z.string(), sourceRef: z.string(), + channel: z.enum(['nightly', 'latest']), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.literal('production') }) +const purgeSchema = z.object({ zoneId: z.string(), publicUrl: z.url(), submittedAt: z.iso.datetime(), + jobId: z.string().regex(/^[\w.-]{1,128}$/u).optional(), + status: z.enum(['pending', 'processing', 'success', 'failed', 'timeout', 'canceled', 'unknown']), + observedAt: z.iso.datetime().optional(), diagnostic: z.string().optional() }) +const stateSchema = z.object({ approval: z.string(), upload: z.enum(['pending', 'upload-intent', 'uploaded']), + purge: purgeSchema.optional(), purgeAttempt: z.string().optional() }) + +/** + * Bind website approval to the retained manifest and exact production bucket. + * @param manifest Retained build metadata already matched to GitHub evidence. + * @param bucket Production COS destination. + * @returns Non-secret digest passed through the production approval job. + */ +export function websiteApproval(manifest: unknown, bucket: string): string { + return createHash('sha256').update(JSON.stringify({ manifest, bucket })).digest('hex') +} + +/** + * Upload a fixed installer or recover its cache purge, using a publication-run-owned journal. + * @param mode Explicit website operation; purge never opens or uploads installer bytes. + * @param env Actions identity, approved build and machine configuration. + * @returns Resolves on success or a prominently reported unknown purge result. + */ +export async function publishWebsiteCi(mode: 'upload' | 'purge', env: NodeJS.ProcessEnv): Promise { + const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}')) + if (env.RUNNER_NAME !== route.runnerName || !/^[1-9]\d*$/u.test(env.GITHUB_RUN_ID || '') + || !/^[1-9]\d*$/u.test(env.GITHUB_RUN_ATTEMPT || '')) throw new Error('website publication: invalid Actions identity or original machine') + const root = desktopReleasesRoot(env) + const manifest = await readRetainedDesktopManifest(root, route.runId, route.attempt) + const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, 'production') + const approval = websiteApproval(manifest, config.bucket) + if (!env.DESKTOP_WEBSITE_APPROVAL || env.DESKTOP_WEBSITE_APPROVAL !== approval) throw new Error('website publication: approved build or destination changed') + const zoneId = env.DOWNLOAD_PROD_EO_ZONE_ID + if (!zoneId || !/^zone-[a-z0-9]+$/u.test(zoneId)) throw new Error('website publication: configure DOWNLOAD_PROD_EO_ZONE_ID before uploading') + const directory = join(dirname(root), 'upload-records', `website-${env.GITHUB_RUN_ID}-${route.target}`) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const stateFile = join(directory, 'state.json') + let state: z.infer = { approval, upload: 'pending' } + try { state = stateSchema.parse(JSON.parse(await readFile(stateFile, 'utf8'))) } + catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error } + if (state.approval !== approval) throw new Error('website publication: journal belongs to another approved build') + const save = async () => { + const temporary = join(directory, `.state-${randomUUID()}.json`) + await writeFile(temporary, `${JSON.stringify(state)}\n`, { flag: 'wx', mode: 0o600, flush: true }) + await rename(temporary, stateFile) + } + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${directory}\n`) + const credentials = await readDesktopUploadCredentials(config) + if (mode === 'upload') { + const installer = await prepareWebsiteInstaller(retainedDesktopPath(root, route.runId, route.attempt), manifest, route, + env.GITHUB_REPOSITORY || '', config.bucket) + try { + await withDesktopChannelLock(join(dirname(root), 'publish-locks'), `${config.bucket}/${installer.plan.artifacts[0]!.key}`, async () => { + if (state.upload === 'uploaded') { + process.stdout.write('website installer: this publication already uploaded; skipping retransmission\n') + return + } + await uploadWebsiteInstaller(installer, credentials, desktopPublishCos(installer.plan, credentials), async (event) => { + state = { ...state, upload: event } + await save() + }) + }) + const text = `Website installer uploaded: ${installer.plan.publicUrl}; version ${installer.plan.version}\n` + process.stdout.write(text) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, text) + } finally { await installer.handle.close() } + return + } + if (state.upload !== 'uploaded') throw new Error('website publication: upload completion is not confirmed; recover the upload job first') + const { desktopLatestInstallerFilename } = await import('./desktop-auto-update-environment.mjs') + const publicUrl = `https://download.deepseek.com/desktop/${desktopLatestInstallerFilename(route.target)}` + const client = new teo.v20220901.Client({ credential: credentials, region: '', + profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } } }) + const previousAttempt = state.purgeAttempt + const result = await recoverDesktopPurge({ ...credentials, zoneId, publicUrl }, state.purge, client, { + intervalMs: 10_000, waitMs: 300_000, + allowResubmit: previousAttempt !== undefined && previousAttempt !== env.GITHUB_RUN_ATTEMPT, + now: Date.now, sleep, + save: async (purge: DesktopPurgeRecord) => { + const accepted = purge.jobId && purge.jobId !== state.purge?.jobId + state = { ...state, purge, purgeAttempt: env.GITHUB_RUN_ATTEMPT } + await save() + if (accepted) process.stdout.write(`Website cache purge accepted: ${publicUrl}; task ${purge.jobId}\n`) + }, + }) + const details = `${publicUrl}\nTask: ${result.jobId ?? 'unknown'}\nObserved: ${result.observedAt}\n${result.diagnostic ?? ''}\n` + if (result.status === 'unknown') { + process.stdout.write(`::warning title=官网缓存刷新结果未确认::官网资源已上传,缓存刷新结果未确认;请检查 EdgeOne 任务记录。\n${details}`) + if (env.GITHUB_STEP_SUMMARY) { + let existing = '' + try { existing = await readFile(env.GITHUB_STEP_SUMMARY, 'utf8') } + catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error } + await writeFile(env.GITHUB_STEP_SUMMARY, `# ⚠ 官网资源已上传,缓存刷新结果未确认\n\n${details}${existing ? `\n${existing}` : ''}`) + } + } else { + process.stdout.write(`Website cache purge completed\n${details}`) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, `Website cache purge completed\n\n${details}`) + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + try { + const mode = process.argv[2] + if (process.argv.length !== 3 || (mode !== 'upload' && mode !== 'purge')) throw new Error('Usage: publish-website-ci.ts upload|purge') + await publishWebsiteCi(mode, process.env) + } catch (error) { + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'website publication'))}\n`) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index 152369c49b..646f2b499b 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -8,7 +8,7 @@ import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop- const positiveId = z.string().regex(/^[1-9]\d*$/u) const inputsSchema = z.object({ package_run_id: positiveId, package_run_attempt: z.union([positiveId, z.literal('')]).optional() }) const runSchema = z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive(), - head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.literal('success'), + head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.string(), event: z.literal('workflow_dispatch'), path: z.enum(Object.values(desktopPackageWorkflows)), repository: z.object({ full_name: z.string() }) }) const jobsSchema = z.object({ jobs: z.array(z.object({ @@ -29,12 +29,31 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r const inputs = inputsSchema.parse(inputsValue) const base = `repos/${repository}/actions/runs/${inputs.package_run_id}` const attempt = inputs.package_run_attempt || String(z.object({ run_attempt: z.number().int().positive() }).parse(api(base)).run_attempt) - const run = runSchema.parse(api(`${base}/attempts/${attempt}`)) + let run = runSchema.parse(api(`${base}/attempts/${attempt}`)) if (String(run.id) !== inputs.package_run_id || String(run.run_attempt) !== attempt || run.repository.full_name !== repository) { throw new Error('desktop publish: selected run or attempt does not match GitHub evidence') } - const jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs) - const packages = jobs.filter(job => job.name === 'package' && job.conclusion === 'success' && job.runner_name) + let jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs) + let packages = jobs.filter(job => job.name === 'package') + if (packages.length === 0) { + // An archival-only attempt owns no new build; select the latest actual package job, never a prior success over a failed build. + const historySchema = z.array(z.object({ jobs: z.array(z.object({ name: z.string(), run_attempt: z.number().int().positive(), + runner_name: z.string().nullable(), conclusion: z.string().nullable() })) })) + const history = historySchema.parse(api(`${base}/jobs?filter=all&per_page=100`, true)).flatMap(page => page.jobs) + .filter(job => job.name === 'package' && job.run_attempt <= Number(attempt)) + .sort((left, right) => right.run_attempt - left.run_attempt) + const original = history[0] + if (!original || original.conclusion !== 'success') throw new Error('desktop publish: latest original package job was not successful') + const selected = run + run = runSchema.parse(api(`${base}/attempts/${original.run_attempt}`)) + if (String(run.id) !== inputs.package_run_id || run.run_attempt !== original.run_attempt || run.repository.full_name !== repository + || run.head_sha !== selected.head_sha || run.path !== selected.path) { + throw new Error('desktop publish: original build identity differs from GitHub evidence') + } + jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${original.run_attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs) + packages = jobs.filter(job => job.name === 'package') + } + packages = packages.filter(job => job.conclusion === 'success' && job.runner_name) if (packages.length !== 1) throw new Error('desktop publish: expected one successful package job') const runnerName = packages[0]!.runner_name! const routes = desktopPublishRunnersSchema.parse(routesValue) diff --git a/apps/desktop/scripts/retain-desktop-ci.ts b/apps/desktop/scripts/retain-desktop-ci.ts index 69bade9712..e672274992 100644 --- a/apps/desktop/scripts/retain-desktop-ci.ts +++ b/apps/desktop/scripts/retain-desktop-ci.ts @@ -6,6 +6,7 @@ import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDeskt import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts' import { validateDesktopBuildVersion } from './desktop-build-version.mjs' +import { desktopPublishRunnersSchema } from './desktop-publish-targets.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' function required(env: NodeJS.ProcessEnv, name: string): string { @@ -26,6 +27,22 @@ export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string { return root } +/** + * Resolve the original machine's unique label before expensive packaging work. + * @param env Machine identity, target and administrator-owned runner mapping. + * @returns The exact label used by the later GitHub archival job. + */ +export function resolveDesktopRetentionRunner(env: NodeJS.ProcessEnv): string { + const target = resolveDesktopBuildTarget(env) + const routes = desktopPublishRunnersSchema.parse(JSON.parse(required(env, 'DESKTOP_PUBLISH_RUNNERS'))) + const route = routes[required(env, 'RUNNER_NAME')] + if (!route || !route.target.includes(target) + || new Set(Object.values(routes).map(item => item.label)).size !== Object.keys(routes).length) { + throw new Error('desktop retention: original machine requires a unique artifact routing label') + } + return route.label +} + /** * Save the complete installer inventory for a successful package job, without reading signing secrets. * @param env Actions identity, resolved package version and machine preset selection. @@ -34,6 +51,7 @@ export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string { */ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(import.meta.dirname, '..')): Promise { const target = resolveDesktopBuildTarget(env) + const runnerLabel = resolveDesktopRetentionRunner(env) const windows = target === 'win-x64' const platform = windows ? 'win32' : 'darwin' const settings = loadDesktopPackageEnvironment(platform, env, appRoot, { unsigned: true }) @@ -66,7 +84,7 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform, source.channel)) } const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name))) - if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\nrunner=${runnerLabel}\nrunner-name=${source.runnerName}\nattempt=${source.attempt}\n`) const nextStep = desktopPublishQualificationError(qualification) ?? `Publish separately with package_run_id=${env.GITHUB_RUN_ID} and package_run_attempt=${env.GITHUB_RUN_ATTEMPT}` if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, @@ -75,5 +93,7 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { - await retainDesktopCi(process.env) + if (process.argv[2] === '--check-runner' && process.argv.length === 3) resolveDesktopRetentionRunner(process.env) + else if (process.argv.length === 2) await retainDesktopCi(process.env) + else throw new Error('Usage: retain-desktop-ci.ts [--check-runner]') } diff --git a/apps/desktop/tests/desktop-package-selection.spec.ts b/apps/desktop/tests/desktop-package-selection.spec.ts index 1da8dde6e8..4f4172984e 100644 --- a/apps/desktop/tests/desktop-package-selection.spec.ts +++ b/apps/desktop/tests/desktop-package-selection.spec.ts @@ -47,8 +47,8 @@ it('selects an explicit old attempt without consulting the latest attempt', () = it.each(['failed', 'cancelled', 'running', 'workflow', 'repository', 'attempt', 'run', 'job', 'ambiguous', 'route', 'label'])( 'rejects %s evidence without falling back to a different attempt', (kind) => { const f = fixture() - if (kind === 'failed') f.run.conclusion = 'failure' - if (kind === 'cancelled') f.run.conclusion = 'cancelled' + if (kind === 'failed') { f.run.conclusion = 'failure'; f.jobs[0]!.conclusion = 'failure' } + if (kind === 'cancelled') { f.run.conclusion = 'cancelled'; f.jobs[0]!.conclusion = 'cancelled' } if (kind === 'running') f.run.status = 'in_progress' if (kind === 'workflow') f.run.path = '.github/workflows/ci.yml' if (kind === 'repository') f.run.repository.full_name = 'other/repo' @@ -135,3 +135,24 @@ it('routes both Mac architectures to one machine without authorizing an unlisted expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, { mini: { label: 'dsh-mini', target } }, f.api)).toThrow() } }) + +it('keeps the original successful build after an archival-only retry', () => { + const f = fixture() + const selected = selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, (path) => { + if (path.endsWith('/42')) return { run_attempt: 3 } + if (path.endsWith('/attempts/3')) return { ...f.run, run_attempt: 3 } + if (path.endsWith('/attempts/3/jobs?per_page=100')) return [{ jobs: [{ name: 'upload-artifact', runner_name: 'mini', conclusion: 'success' }] }] + if (path.includes('filter=all')) return [{ jobs: [{ ...f.jobs[0], run_attempt: 2 }] }] + if (path.endsWith('/attempts/2')) return { ...f.run, conclusion: 'failure' } + return [{ jobs: f.jobs }] + }) + expect(selected.run.run_attempt).toBe(2) + expect(selected.runnerName).toBe('mini') +}) + +it('accepts a successful package when only archival failed', () => { + const f = fixture() + f.run.conclusion = 'failure' + f.jobs.push({ name: 'upload-artifact', runner_name: 'mini', conclusion: 'failure' }) + expect(selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, f.api).run.run_attempt).toBe(2) +}) diff --git a/apps/desktop/tests/desktop-purge-recovery.spec.ts b/apps/desktop/tests/desktop-purge-recovery.spec.ts new file mode 100644 index 0000000000..8c631005d8 --- /dev/null +++ b/apps/desktop/tests/desktop-purge-recovery.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest' +import { desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts' +import { recoverDesktopPurge, type DesktopPurgeRecord, type DesktopPurgeRecoveryOptions } from '../scripts/desktop-purge-recovery.ts' + +const config = { zoneId: 'zone-test', publicUrl: 'https://download.deepseek.com/desktop/test.dmg', secretId: 'secret-id', secretKey: 'secret-key' } +const submittedAt = '2026-10-30T00:00:00.000Z' +function fixture() { + let clock = Date.parse(submittedAt) + const records: DesktopPurgeRecord[] = [] + const options: DesktopPurgeRecoveryOptions = { + intervalMs: 10, waitMs: 20, allowResubmit: false, now: () => clock, + sleep: async (ms) => { clock += ms }, save: async (record) => { records.push(record) }, + } + const task = (status: string, jobId = 'job-1') => ({ TotalCount: 1, Tasks: [{ JobId: jobId, Status: status, + Target: config.publicUrl, Type: 'purge_url', CreateTime: submittedAt }] }) + const client = { CreatePurgeTask: vi.fn(async () => ({ JobId: 'job-1' })), DescribePurgeTasks: vi.fn(async () => task('success')) } + return { options, records, task, client } +} + +describe('website purge recovery', () => { + it('saves intent before submitting once and confirms completion', async () => { + const f = fixture() + f.client.CreatePurgeTask.mockImplementation(async () => { + expect(f.records.at(-1)?.status).toBe('pending') + return { JobId: 'job-1' } + }) + expect((await recoverDesktopPurge(config, undefined, f.client, f.options)).status).toBe('success') + expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce() + expect(f.records.map(r => r.status)).toEqual(['pending', 'processing', 'success']) + }) + + it('queries an existing successful task without submitting again', async () => { + const f = fixture() + const previous: DesktopPurgeRecord = { ...config, submittedAt, jobId: 'job-1', status: 'unknown' } + expect((await recoverDesktopPurge(config, previous, f.client, f.options)).status).toBe('success') + expect(f.client.CreatePurgeTask).not.toHaveBeenCalled() + }) + + it('reconciles a lost submission response using exact history', async () => { + const f = fixture() + f.client.CreatePurgeTask.mockRejectedValue(new Error(config.secretKey)) + expect((await recoverDesktopPurge(config, undefined, f.client, f.options)).jobId).toBe('job-1') + expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce() + expect(f.client.DescribePurgeTasks.mock.calls[0]).toBeDefined() + expect(JSON.stringify(f.records)).not.toContain(config.secretKey) + }) + + it('queries once more after uncertainty and preserves an unknown result', async () => { + const f = fixture() + f.client.DescribePurgeTasks.mockRejectedValue(new Error(config.secretKey)) + const result = await recoverDesktopPurge(config, undefined, f.client, f.options) + expect(result.status).toBe('unknown') + expect(f.client.DescribePurgeTasks).toHaveBeenCalledTimes(2) + expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce() + expect(JSON.stringify(f.records)).not.toContain(config.secretKey) + }) + + it('bounds processing observations and makes one final query', async () => { + const f = fixture() + f.client.DescribePurgeTasks.mockResolvedValue(f.task('processing')) + expect((await recoverDesktopPurge(config, undefined, f.client, f.options)).status).toBe('unknown') + expect(f.client.DescribePurgeTasks).toHaveBeenCalledTimes(4) + }) + + it.each(['failed', 'timeout', 'canceled'])('fails for confirmed %s without retrying submission', async (status) => { + const f = fixture() + f.client.DescribePurgeTasks.mockResolvedValue(f.task(status)) + await expect(recoverDesktopPurge(config, undefined, f.client, f.options)).rejects.toThrow(status) + expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce() + expect(f.records.at(-1)?.status).toBe(status) + const saved = f.records.at(-1)! + await recoverDesktopPurge(config, saved, f.client, f.options).catch((error: unknown) => { + expect(desktopPublicationFailure(error, 'purge')).toMatchObject({ code: `purge-${status}` }) + }) + }) + + it('allows one replacement only after a manual rerun confirms previous failure', async () => { + const f = fixture() + f.client.DescribePurgeTasks.mockResolvedValueOnce(f.task('failed')).mockResolvedValue(f.task('success')) + const previous: DesktopPurgeRecord = { zoneId: config.zoneId, publicUrl: config.publicUrl, submittedAt, jobId: 'old-job', status: 'failed' } + f.client.DescribePurgeTasks.mockReset().mockResolvedValueOnce(f.task('failed', 'old-job')).mockResolvedValue(f.task('success')) + expect((await recoverDesktopPurge(config, previous, f.client, { ...f.options, allowResubmit: true })).status).toBe('success') + expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce() + }) + + it('does not submit when intent persistence fails', async () => { + const f = fixture() + await expect(recoverDesktopPurge(config, undefined, f.client, { ...f.options, + save: async () => { throw new Error('disk full') }, + })).rejects.toThrow('disk full') + expect(f.client.CreatePurgeTask).not.toHaveBeenCalled() + }) + + it('does not mistake multiple matching tasks for confirmed completion', async () => { + const f = fixture() + f.client.CreatePurgeTask.mockRejectedValue(new Error('lost response')) + f.client.DescribePurgeTasks.mockResolvedValue({ TotalCount: 2, Tasks: [...f.task('success').Tasks, ...f.task('success', 'job-2').Tasks] }) + expect((await recoverDesktopPurge(config, undefined, f.client, f.options)).status).toBe('unknown') + expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/tests/desktop-website-installer.spec.ts b/apps/desktop/tests/desktop-website-installer.spec.ts new file mode 100644 index 0000000000..4894a7aa7c --- /dev/null +++ b/apps/desktop/tests/desktop-website-installer.spec.ts @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { createDesktopCos } from '../scripts/desktop-cos.ts' +import { desktopPublishCos } from '../scripts/desktop-publish-cos.ts' +import { answer, cosError, startCosLoopback } from './cos-loopback.ts' +import { prepareWebsiteInstaller, uploadWebsiteInstaller } from '../scripts/desktop-website-installer.ts' +import type { DesktopRetainedManifest } from '../scripts/desktop-release-store.ts' +import type { DesktopPublishRoute } from '../scripts/desktop-publish-route.ts' + +const roots: string[] = [] +afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) +async function fixture(target: DesktopPublishRoute['target'] = 'mac-arm64') { + const directory = await mkdtemp(join(tmpdir(), 'website-installer-')) + roots.push(directory) + const bytes = Buffer.from('final installer bytes') + const route: DesktopPublishRoute = { runId: '12', attempt: '1', commit: 'a'.repeat(40), sourceRef: 'master', + channel: 'nightly', runnerName: 'mini', runnerLabel: 'dsh-mini', target, preset: 'production' } + const filename = `deepseek-harness-0.2.1-rc.5-${target}.${target === 'win-x64' ? 'exe' : 'dmg'}` + await writeFile(join(directory, filename), bytes) + const manifest: DesktopRetainedManifest = { schemaVersion: 1, source: { + ...route, repository: 'owner/repo', workflowCommit: route.commit, productVersion: '0.2.1', version: '0.2.1-rc.5', + testReleaseId: null, qualification: target === 'win-x64' ? 'signed' : 'notarized', + }, files: [{ name: filename, size: bytes.length, sha512: createHash('sha512').update(bytes).digest('base64') }] } + return { directory, bytes, route, manifest } +} + +it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('accepts only the final %s installer without any update files or current checkout version', async (target) => { + const f = await fixture(target) + const installer = await prepareWebsiteInstaller(f.directory, f.manifest, f.route, 'owner/repo', 'production-123') + try { + expect(installer.plan.version).toBe('0.2.1-rc.5') + expect(installer.plan.artifacts).toHaveLength(1) + expect(installer.digest).toEqual({ size: f.bytes.length, sha512: f.manifest.files[0]!.sha512 }) + } finally { await installer.handle.close() } +}) + +it('rejects changed installer bytes', async () => { + const f = await fixture() + await writeFile(join(f.directory, f.manifest.files[0]!.name), 'wrong bytes') + await expect(prepareWebsiteInstaller(f.directory, f.manifest, f.route, 'owner/repo', 'production-123')).rejects.toThrow('differs from build manifest') +}) + +it.each(['preset', 'qualification', 'attempt', 'commit', 'target', 'repository'] as const)('rejects mismatched %s even when file bytes match', async (field) => { + const f = await fixture() + const source = { ...f.manifest.source } + if (field === 'preset') source.preset = 'test' + if (field === 'qualification') source.qualification = 'unnotarized' + if (field === 'attempt') source.attempt = '2' + if (field === 'commit') source.commit = 'b'.repeat(40) + if (field === 'target') source.target = 'mac-x64' + if (field === 'repository') source.repository = 'other/repo' + await expect(prepareWebsiteInstaller(f.directory, { ...f.manifest, source }, f.route, 'owner/repo', 'production-123')).rejects.toThrow('identity or production qualification mismatch') +}) + +it('reconciles stored bytes after audit failure without repeating the fixed-object PUT', async () => { + const f = await fixture() + const installer = await prepareWebsiteInstaller(f.directory, f.manifest, f.route, 'owner/repo', 'production-123') + let stored: Buffer | undefined + const server = await startCosLoopback((request, response) => { + if (request.method === 'PUT') { + expect(request.headers['x-cos-forbid-overwrite']).toBeUndefined() + expect(request.headers['content-md5']).toBe(createHash('md5').update(f.bytes).digest('base64')) + stored = request.body + answer(response) + } else if (stored) answer(response, 200, stored) + else answer(response, 404, cosError('NoSuchKey')) + }) + try { + const credentials = { secretId: 'fixture-id', secretKey: 'fixture-key' } + const createClient: typeof createDesktopCos = (credentials) => { + const client = createDesktopCos(credentials) + server.redirect(client) + return client + } + const transport = desktopPublishCos(installer.plan, credentials, createClient) + await expect(uploadWebsiteInstaller(installer, credentials, transport, async (event) => { + if (event === 'uploaded') throw new Error('audit unavailable') + }, createClient)).rejects.toThrow('audit unavailable') + const events: string[] = [] + expect(await uploadWebsiteInstaller(installer, credentials, transport, async (event) => { events.push(event) }, createClient)).toBe('present') + expect(events).toEqual(['uploaded']) + expect(stored).toEqual(f.bytes) + expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1) + } finally { await installer.handle.close(); await server.close() } +}) diff --git a/apps/desktop/tests/expected/website-purge-unknown.md b/apps/desktop/tests/expected/website-purge-unknown.md new file mode 100644 index 0000000000..c91a01223c --- /dev/null +++ b/apps/desktop/tests/expected/website-purge-unknown.md @@ -0,0 +1,6 @@ +# ⚠ 官网资源已上传,缓存刷新结果未确认 + +https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg +Task: job-1 +Observed: 2026-10-30T00:00:00.000Z +task query failed; check EdgeOne history and DescribePurgeTasks permission diff --git a/apps/desktop/tests/publish-website-ci.spec.ts b/apps/desktop/tests/publish-website-ci.spec.ts new file mode 100644 index 0000000000..8eb9755301 --- /dev/null +++ b/apps/desktop/tests/publish-website-ci.spec.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { teo } from 'tencentcloud-sdk-nodejs-teo' +import { publishWebsiteCi, websiteApproval } from '../scripts/publish-website-ci.ts' + +vi.mock('node:timers/promises', () => ({ setTimeout: async () => {} })) +afterEach(() => vi.restoreAllMocks()) + +it('returns successfully with a visible warning and durable unknown state after two failed queries', async () => { + const root = await mkdtemp(join(tmpdir(), 'website-ci-')) + try { + const config = join(root, 'packaging') + const records = join(root, 'upload-records', 'website-99-mac-arm64') + await mkdir(join(config, 'publishing'), { recursive: true }) + await mkdir(join(root, 'releases', '12', '1'), { recursive: true }) + await mkdir(records, { recursive: true }) + const credentialsFile = join(root, 'credentials.json') + await writeFile(credentialsFile, JSON.stringify({ secretId: 'fixture-id', secretKey: 'fixture-key' })) + await writeFile(join(config, 'publishing', 'production.json'), JSON.stringify({ + bucket: 'production-123', origin: 'https://download.deepseek.com', credentialsFile, + githubApp: { clientId: 'app', privateKeyFile: join(root, 'app.pem') }, + })) + const route = { runId: '12', attempt: '1', commit: 'a'.repeat(40), sourceRef: 'master', channel: 'nightly', + runnerName: 'mini', runnerLabel: 'dsh-mini', target: 'mac-arm64', preset: 'production' } + const manifest = { schemaVersion: 1, source: { repository: 'owner/repo', runId: '12', attempt: '1', commit: route.commit, + workflowCommit: route.commit, sourceRef: 'master', channel: 'nightly', runnerName: 'mini', target: 'mac-arm64', + preset: 'production', productVersion: '1.0.0', version: '1.0.0', testReleaseId: null, qualification: 'notarized' }, + files: [{ name: 'installer.dmg', size: 1, sha512: createHash('sha512').update('x').digest('base64') }] } + await writeFile(join(root, 'releases', '12', '1', 'manifest.json'), JSON.stringify(manifest)) + const approval = websiteApproval(manifest, 'production-123') + await writeFile(join(records, 'state.json'), JSON.stringify({ approval, upload: 'uploaded' })) + const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' }) + const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('fixture-key')) + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-30T00:00:00.000Z')) + const summary = join(root, 'summary.md') + await publishWebsiteCi('purge', { RUNNER_NAME: 'mini', GITHUB_REPOSITORY: 'owner/repo', GITHUB_RUN_ID: '99', + GITHUB_RUN_ATTEMPT: '1', DSH_PACKAGING_CONFIG_DIR: config, DESKTOP_PUBLISH_ROUTE: JSON.stringify(route), + DESKTOP_WEBSITE_APPROVAL: approval, DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', GITHUB_STEP_SUMMARY: summary }) + expect(submit).toHaveBeenCalledOnce() + expect(query).toHaveBeenCalledTimes(2) + expect(output.mock.calls.map(call => String(call[0])).join('')).toContain('::warning title=官网缓存刷新结果未确认::') + expect(JSON.parse(await readFile(join(records, 'state.json'), 'utf8'))).toMatchObject({ upload: 'uploaded', purge: { status: 'unknown' } }) + await expect(await readFile(summary, 'utf8')).toMatchFileSnapshot('./expected/website-purge-unknown.md') + expect(await readFile(summary, 'utf8')).not.toContain('fixture-key') + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/apps/desktop/tests/retain-desktop-ci.spec.ts b/apps/desktop/tests/retain-desktop-ci.spec.ts index f83592fa4a..cc26e3a2d6 100644 --- a/apps/desktop/tests/retain-desktop-ci.spec.ts +++ b/apps/desktop/tests/retain-desktop-ci.spec.ts @@ -2,8 +2,9 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { desktopReleasesRoot, retainDesktopCi } from '../scripts/retain-desktop-ci.ts' +import { desktopReleasesRoot, retainDesktopCi, resolveDesktopRetentionRunner } from '../scripts/retain-desktop-ci.ts' import { verifyRetainedDesktopRelease } from '../scripts/desktop-release-store.ts' +import { verifyDesktopArchive } from '../scripts/archive-desktop-ci.ts' import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts' const roots: string[] = [] @@ -32,9 +33,15 @@ it.each([ DSH_DESKTOP_TARGET_ARCH: target === 'mac-arm64' ? 'arm64' : 'x64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_SIGNING: qualification === 'unsigned' ? 'unsigned' : 'signed', PACKAGE_SKIP_NOTARIZATION: qualification === 'unnotarized' ? 'true' : 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', GITHUB_STEP_SUMMARY: join(root, 'summary.md'), + DESKTOP_PUBLISH_RUNNERS: JSON.stringify({ dedicated: { label: 'dsh-dedicated', target: [target] } }), GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated', } - await retainDesktopCi(env, appRoot) + const directory = await retainDesktopCi(env, appRoot) + const archiveEnv = { ...env, GITHUB_RUN_ATTEMPT: '3', DESKTOP_RETAINED_DIRECTORY: directory, + DESKTOP_PACKAGE_ATTEMPT: '2', DESKTOP_PACKAGE_RUNNER: env.RUNNER_NAME } + await verifyDesktopArchive(archiveEnv) + await expect(verifyDesktopArchive({ ...archiveEnv, RUNNER_NAME: 'other' })).rejects.toThrow('wrong original machine') + await expect(verifyDesktopArchive({ ...archiveEnv, DESKTOP_PACKAGE_ATTEMPT: '3' })).rejects.toThrow('original build') const manifest = await verifyRetainedDesktopRelease(desktopReleasesRoot(env), '123', '2') expect(manifest.files.map(file => file.name)).toEqual(names) expect(manifest.source).toMatchObject({ target, qualification }) @@ -49,3 +56,11 @@ it.each([ await expect(summary).toMatchFileSnapshot(`./expected/retained-${qualification}.md`) } else expect(summary).toContain('Publish separately with package_run_id=123 and package_run_attempt=2') }) + +it('rejects missing or ambiguous archival routing before packaging starts', () => { + const env = { RUNNER_NAME: 'dedicated', DSH_DESKTOP_TARGET_PLATFORM: 'darwin', DSH_DESKTOP_TARGET_ARCH: 'arm64' } + expect(() => resolveDesktopRetentionRunner(env)).toThrow('DESKTOP_PUBLISH_RUNNERS is required') + expect(() => resolveDesktopRetentionRunner({ ...env, DESKTOP_PUBLISH_RUNNERS: JSON.stringify({ + dedicated: { label: 'dsh-shared', target: 'mac-arm64' }, other: { label: 'dsh-shared', target: 'mac-arm64' }, + }) })).toThrow('unique artifact routing label') +}) diff --git a/scripts/desktop-package-source.spec.ts b/scripts/desktop-package-source.spec.ts index cbf7b053b1..ef28aee7e8 100644 --- a/scripts/desktop-package-source.spec.ts +++ b/scripts/desktop-package-source.spec.ts @@ -23,6 +23,7 @@ it.each(['macos', 'windows'])('%s packaging accepts test branches but preserves mkdirSync(join(root, 'apps/desktop/scripts'), { recursive: true }) writeFileSync(join(root, 'package.json'), '{"version":"1.0.0"}') writeFileSync(join(root, 'apps/desktop/scripts/desktop-ci-version.mjs'), 'PACKAGE_VERSION_MODE') + writeFileSync(join(root, 'apps/desktop/scripts/retain-desktop-ci.ts'), '--check-runner') git('add', '.') git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture') const sha = git('rev-parse', 'HEAD') @@ -51,6 +52,9 @@ it.each(['macos', 'windows'])('%s packaging accepts test branches but preserves } for (const preset of ['test', 'production']) expect(run(preset, 'dsh-v1.0.0').status).toBe(0) expect(run('test', 'dsh-v2.0.0').status).not.toBe(0) + writeFileSync(join(root, 'apps/desktop/scripts/retain-desktop-ci.ts'), 'old archival adapter') + expect(run('test', 'dsh-v1.0.0').stderr).toContain('independent Desktop artifact archival support') + writeFileSync(join(root, 'apps/desktop/scripts/retain-desktop-ci.ts'), '--check-runner') writeFileSync(join(root, 'apps/desktop/scripts/desktop-ci-version.mjs'), 'old adapter') expect(run('test', 'dsh-v1.0.0').status).not.toBe(0) } finally { rmSync(root, { recursive: true, force: true, maxRetries: 3 }) } diff --git a/scripts/desktop-publish-workflow.spec.ts b/scripts/desktop-publish-workflow.spec.ts index fc44ac267a..85dfd56489 100644 --- a/scripts/desktop-publish-workflow.spec.ts +++ b/scripts/desktop-publish-workflow.spec.ts @@ -78,3 +78,18 @@ it('rejects non-master dispatch before scheduling a self-hosted job', () => { if (result.status !== 0) expect(result.stderr).toContain('must be dispatched from master') } }) + +it('defaults to production website upload and separates cache-only recovery', () => { + expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { upload_website: { type: 'boolean', default: true } } }) + const upload = workflow.jobs['upload-website-installer']! + const purge = workflow.jobs['purge-website-installer']! + expect(upload.if).toContain("inputs.upload_website && needs.prepare.outputs.preset == 'production'") + expect(purge.needs).toEqual(['route', 'prepare', 'upload-website-installer']) + for (const job of [upload, purge]) { + expect(job.environment).toBe('desktop-production') + expect(job.env?.DESKTOP_WEBSITE_APPROVAL).toBe('${{ needs.prepare.outputs.website-approval }}') + } + expect(upload.steps.find(step => step.id === 'website')?.run).toBe('node --import tsx apps/desktop/scripts/publish-website-ci.ts upload') + expect(purge.steps.find(step => step.id === 'website')?.run).toBe('node --import tsx apps/desktop/scripts/publish-website-ci.ts purge') + expect(purge.steps.some(step => step.run?.includes('publish-desktop-ci.ts'))).toBe(false) +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index a0bb2cb027..72bc6b769b 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -36,11 +36,14 @@ describe.each(['arm64', 'x64'])('macOS %s packaging', (arch) => { expect(steps.find(step => step.id === 'checkout')?.with).toMatchObject({ 'persist-credentials': false, clean: true }) for (const step of steps) expect(step.run ?? '').not.toContain('${{ inputs.') const uploads = steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) - expect(uploads).toHaveLength(2) + expect(uploads).toHaveLength(1) + const archive = workflow.jobs['upload-artifact']! + expect(archive['runs-on']).toEqual(['self-hosted', '${{ needs.package.outputs.runner }}']) + const installer = archive.steps.find(step => step.uses?.startsWith('actions/upload-artifact@'))! expect(uploads[0]?.if).toBe("always() && steps.checkout.outcome == 'success'") - expect(uploads[1]?.if).toBe('success()') - expect(uploads[1]?.with?.path).toBe('${{ steps.retained.outputs.directory }}/*') - expect(uploads[1]?.with?.['compression-level']).toBe(0) + expect(installer.if).toBe('success()') + expect(installer.with?.path).toBe('${{ needs.package.outputs.directory }}/*') + expect(installer.with?.['compression-level']).toBe(0) }) it('defaults to notarization and labels the explicit debugging option', () => { @@ -50,7 +53,7 @@ describe.each(['arm64', 'x64'])('macOS %s packaging', (arch) => { } } }) const steps = workflow.jobs.package!.steps expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2) - expect(steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ steps.settings.outputs.mode }}') + expect(workflow.jobs['upload-artifact']!.steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ needs.package.outputs.mode }}') }) it('retains build inputs outside checkout and run-specific pnpm installations', () => { @@ -78,7 +81,7 @@ describe.each(['arm64', 'x64'])('macOS %s packaging', (arch) => { const builds = job.steps.filter(step => step.run?.includes('pnpm run package:desktop:mac:')) expect(builds).toHaveLength(2) for (const step of builds) expect(step.run).toContain(`package:desktop:mac:${arch}`) - expect(job.steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain(`macos-${arch}-`) + expect(workflow.jobs['upload-artifact']!.steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain(`macos-${arch}-`) if (arch === 'x64') { const rosetta = job.steps.findIndex(step => step.run === 'arch -x86_64 /usr/bin/true') expect(rosetta).toBeGreaterThan(-1) diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 6338bbf991..5d35d4525f 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -105,15 +105,17 @@ it('validates before building and passes the same mode and version to checks and it('retains failure diagnostics and uploads only the selected successful installer', () => { const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) - expect(uploads).toHaveLength(3) + expect(uploads).toHaveLength(1) + const archival = workflow.jobs['upload-artifact']!.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) + expect(archival).toHaveLength(2) expect(uploads[0]!.if).toBe("always() && steps.checkout.outcome == 'success'") expect(uploads[0]!.with).toMatchObject({ path: '${{ github.workspace }}/apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true, }) for (const mode of ['unsigned', 'signed']) { - const upload = uploads.find(step => step.if === `success() && inputs.signing == '${mode}'`)! - expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error', 'compression-level': 0 }) + const upload = archival.find(step => step.if === `success() && inputs.signing == '${mode}'`)! + expect(upload.with).toMatchObject({ path: '${{ needs.package.outputs.directory }}/*', 'if-no-files-found': 'error', 'compression-level': 0 }) } }) From 7502558cc5a3613d3b88be952edf1913730d9787 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 30 Oct 2026 17:52:00 +0800 Subject: [PATCH 02/36] fix(desktop): address publication recovery review --- .github/AGENTS.md | 2 +- .github/workflows/desktop-publish.yml | 2 +- .github/workflows/macos-intel-package.yml | 6 ++++ .github/workflows/macos-package.yml | 6 ++++ .github/workflows/windows-package.yml | 19 ++++++++++ apps/desktop/publishing/ci/README.i18n.yaml | 4 +-- apps/desktop/publishing/ci/README.md | 10 +++--- apps/desktop/publishing/ci/README.zh.md | 10 +++--- apps/desktop/scripts/archive-desktop-ci.ts | 9 ++++- .../desktop/scripts/desktop-purge-recovery.ts | 5 ++- apps/desktop/scripts/publish-website-ci.ts | 10 +++--- .../scripts/resolve-desktop-publish-ci.ts | 6 ++-- .../tests/desktop-package-selection.spec.ts | 9 ++--- .../tests/desktop-publication-failure.spec.ts | 12 +++++++ .../desktop-publication-preflight.spec.ts | 1 + .../tests/desktop-purge-recovery.spec.ts | 12 +++++++ ...e-unknown.md => website-purge-accepted.md} | 2 ++ .../expected/website-purge-unconfirmed.md | 8 +++++ apps/desktop/tests/publish-website-ci.spec.ts | 35 +++++++++++++++++-- scripts/desktop-publish-workflow.spec.ts | 3 ++ scripts/windows-package-workflow.spec.ts | 8 +++++ 21 files changed, 149 insertions(+), 30 deletions(-) rename apps/desktop/tests/expected/{website-purge-unknown.md => website-purge-accepted.md} (85%) create mode 100644 apps/desktop/tests/expected/website-purge-unconfirmed.md diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 57830f1af0..71a8491022 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -6,4 +6,4 @@ The manual [Windows packaging workflow](workflows/windows-package.yml) uses the The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. -The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. +The manual Desktop publication workflow selects the original successful `package` job attempt independently of `upload-artifact` archival status, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index ff403221be..d35778c640 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -219,7 +219,7 @@ jobs: environment: desktop-production timeout-minutes: 60 concurrency: - group: desktop-website-purge-${{ needs.prepare.outputs.target }} + group: desktop-website-purge-${{ needs.prepare.outputs.website-destination }} cancel-in-progress: false env: DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml index 7f5efd92f0..55e3f2ee62 100644 --- a/.github/workflows/macos-intel-package.yml +++ b/.github/workflows/macos-intel-package.yml @@ -184,6 +184,12 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Configure persistent package caches + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV" + echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile working-directory: artifact-archiver - name: Verify original retained build before GitHub archival diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 5da47a1c94..1fcd8005a7 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -181,6 +181,12 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Configure persistent package caches + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV" + echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" - run: pnpm install --frozen-lockfile working-directory: artifact-archiver - name: Verify original retained build before GitHub archival diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 03657ceeb9..4235afc46e 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -211,10 +211,14 @@ jobs: retention-days: 14 upload-artifact: + defaults: + run: + shell: pwsh needs: package runs-on: [self-hosted, '${{ needs.package.outputs.runner }}'] timeout-minutes: 30 env: + PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }} DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }} DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }} DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }} @@ -230,6 +234,21 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Configure persistent pnpm store + # PNPM_HOME belongs to this run; keep downloaded packages outside its temporary directory. + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + $storeRoot = Join-Path ([IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)) '.pnpm-store' + $namespace = $env:PACKAGE_CACHE_NAMESPACE + if ($namespace) { + if ($namespace -cnotmatch '^[a-z0-9][a-z0-9-]{0,63}\z') { throw 'Cache namespace must contain 1-64 lowercase letters, digits or hyphens' } + if ($namespace -match '^(con|prn|aux|nul|com[1-9]|lpt[1-9])$') { throw 'Cache namespace cannot be a Windows device name' } + $storeRoot = Join-Path $storeRoot $namespace + $env:DSH_DESKTOP_PACKAGE_CACHE_DIR = Join-Path $env:DSH_DESKTOP_PACKAGE_CACHE_DIR $namespace + } + "DSH_DESKTOP_PACKAGE_CACHE_DIR=$env:DSH_DESKTOP_PACKAGE_CACHE_DIR" >> $env:GITHUB_ENV + "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV - run: pnpm install --frozen-lockfile working-directory: artifact-archiver - name: Verify original retained build before GitHub archival diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index 4316440977..b8858beeaf 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -27,8 +27,8 @@ en: be3cf37739f7f9b3 zh: 33b7c8ca3b547c5e /desktop-ci-publication/recovery-and-records: - en: 0f4ff50cd6137d00 - zh: bb5be9da7e6d29f1 + en: ec8cf99e4f5ec1bc + zh: 20547260bfadad4c /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index ee23311d04..2db7533adf 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -80,15 +80,15 @@ Before installing dependencies on the original runner, preparation and the appro ## Recovery and records -The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). Both use `DESKTOP_PUBLISH_RUNNERS` to keep retained files on the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. +The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). All packaging modes, including test and unsigned builds, require `DESKTOP_PUBLISH_RUNNERS` before packaging; it routes archival to the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. -Production COS publication defaults to **同时上传官网安装包并刷新缓存** (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. It does not require a stable version merely because the fixed filename contains latest. The existing version/update publication retains its own checks. +Production COS publication defaults to **同时上传官网安装包并刷新缓存** (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. Both production nightly and latest packages can replace the fixed website installer. Operators select the intended version; the fixed object has no downgrade protection, so rerunning an older publication can replace a newer installer. The existing version/update publication retains its own checks. -Set the repository variable `DOWNLOAD_PROD_EO_ZONE_ID` to the production EdgeOne zone. The production COS credential pair also needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks`. Website uploads serialize by bucket/target in Actions and by bucket/object on the original machine. Cache observation runs separately. Production website jobs use the existing deployment approval Environment. +Set the repository variable `DOWNLOAD_PROD_EO_ZONE_ID` to the production EdgeOne zone. The production COS credential pair also needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks`. Website uploads serialize by bucket/target in Actions and by bucket/object on the original machine. Cache observation runs separately. Version publication, website upload and cache refresh each use the production approval Environment; later jobs and reruns may require another approval. GitHub concurrency retains at most one pending job per group, so a newer pending job can cancel an older pending job; inspect canceled jobs before rerunning them. -Website state lives in `upload-records/website--/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. A completed upload is not repeated on a job rerun; ambiguous uploads reconcile remote bytes first. Rerun only the purge job to inspect its saved task. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. +Website state lives in `upload-records/website--/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. A completed upload is not repeated on a job rerun; ambiguous uploads reconcile remote bytes first. Rerun only the purge job to inspect its saved task. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. An unknown result with a task ID means submission was confirmed; without an ID it may never have been submitted. Inspect EdgeOne history before manual recovery, and use a new publication run if a fresh submission is needed. -Purge observation waits up to five minutes at ten-second intervals, with one additional query after an unknown result or observation deadline. Confirmed failed, timeout or canceled tasks fail the job. An unresolved result exits successfully with an Actions warning and a prominent summary; its durable state remains unknown, not completed. Diagnostic artifact archival is best effort; local records remain the recovery source. Simulated-provider tests cover these paths; production COS/EdgeOne permissions and actual CDN behavior require release-operator qualification. +Purge observation waits up to five minutes at ten-second intervals, with one additional query after an unknown result or observation deadline. Confirmed failed, timeout or canceled tasks fail the job. An unresolved result exits successfully with an Actions warning and a prominent summary; its durable state remains unknown, not completed. Diagnostic artifact archival is best effort; local records remain the recovery source. Simulated-provider tests cover these paths; release operators must verify production COS/EdgeOne permissions, job-id filtering, time-range queries, task type and timestamp formats, cross-attempt Actions recovery, and actual CDN behavior before relying on recovery in production. After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 22ecb5606f..ec30826514 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -80,15 +80,15 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复与记录 -打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。两者使用 `DESKTOP_PUBLISH_RUNNERS` 将保留文件定位到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。 +打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。所有打包模式(包括 test 和 unsigned)都要求在打包前配置 `DESKTOP_PUBLISH_RUNNERS`,用于将归档路由到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。 -生产 COS 发布默认勾选“同时上传官网安装包并刷新缓存”(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。固定文件名包含 latest 不意味着要求稳定版本。原有版本与自动更新发布保留自身校验。 +生产 COS 发布默认勾选“同时上传官网安装包并刷新缓存”(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。production nightly 和 latest 包都可以替换官网固定安装包。操作者负责选择预期版本;固定对象没有防降级保护,重跑旧发布可能覆盖新安装包。原有版本与自动更新发布保留自身校验。 -将仓库变量 `DOWNLOAD_PROD_EO_ZONE_ID` 配置为生产 EdgeOne 站点。生产 COS 凭据还需要 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。官网上传在 Actions 中按 bucket/平台互斥,在原机器上按 bucket/对象互斥;缓存观察单独运行。生产官网 job 沿用现有部署审批 Environment。 +将仓库变量 `DOWNLOAD_PROD_EO_ZONE_ID` 配置为生产 EdgeOne 站点。生产 COS 凭据还需要 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。官网上传在 Actions 中按 bucket/平台互斥,在原机器上按 bucket/对象互斥;缓存观察单独运行。版本发布、官网上传和缓存刷新分别使用生产审批 Environment,后续 job 和重跑可能需要再次审批。GitHub 每个并发组最多保留一个排队 job,新排队 job 可能取消旧排队 job;重跑前应检查取消记录。 -官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。job 重跑不重复已确认完成的上传;结果未知的上传先核对远端字节。仅重跑刷新 job 即可查询已保存任务;人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。 +官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。job 重跑不重复已确认完成的上传;结果未知的上传先核对远端字节。仅重跑刷新 job 即可查询已保存任务;人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。unknown 带任务 ID 表示提交已确认;没有 ID 则可能从未提交。人工恢复前应检查 EdgeOne 历史,需要重新提交时发起新的发布运行。 -刷新观察每十秒查询一次,最长五分钟;结果未知或观察超时后额外查询一次。明确的 failed、timeout、canceled 结果使 job 失败。仍无法确认时成功退出,并输出 Actions warning 和醒目摘要;持久化状态保持 unknown,不标为完成。诊断 artifact 尽力归档,本地记录仍是恢复依据。模拟服务测试覆盖这些路径;生产 COS/EdgeOne 权限和实际 CDN 行为需要发布操作者验证。 +刷新观察每十秒查询一次,最长五分钟;结果未知或观察超时后额外查询一次。明确的 failed、timeout、canceled 结果使 job 失败。仍无法确认时成功退出,并输出 Actions warning 和醒目摘要;持久化状态保持 unknown,不标为完成。诊断 artifact 尽力归档,本地记录仍是恢复依据。模拟服务测试覆盖这些路径;发布操作者必须在生产依赖恢复能力前验证 COS/EdgeOne 权限、job-id 筛选、时间范围查询、任务类型和时间戳格式、Actions 跨 attempt 恢复及实际 CDN 行为。 解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 diff --git a/apps/desktop/scripts/archive-desktop-ci.ts b/apps/desktop/scripts/archive-desktop-ci.ts index c6acad8de2..ec96e2635f 100644 --- a/apps/desktop/scripts/archive-desktop-ci.ts +++ b/apps/desktop/scripts/archive-desktop-ci.ts @@ -1,4 +1,5 @@ /** Validate the original machine and immutable inventory before GitHub artifact archival. */ +import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { resolve } from 'node:path' import { desktopReleasesRoot } from './retain-desktop-ci.ts' import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts' @@ -21,4 +22,10 @@ export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise= 10_000) return unknown('task history could not be fully inspected') } while (true) } catch (error) { return unknown('task query failed; check EdgeOne history and DescribePurgeTasks permission') } - if (matches.length !== 1) return unknown('task history has no unique matching submission') + if (matches.length === 0) return unknown(record.jobId + ? 'accepted task not found in query history; execution result unconfirmed' + : 'no matching submission found; purge may not have been submitted; inspect EdgeOne history before manual recovery') + if (matches.length !== 1) return unknown('multiple matching submissions found; cannot identify this submission') const task = matches[0]! if (!task.JobId || !/^[\w.-]{1,128}$/u.test(task.JobId) || task.JobId.includes(config.secretId) || task.JobId.includes(config.secretKey)) return unknown('query returned no usable task ID') diff --git a/apps/desktop/scripts/publish-website-ci.ts b/apps/desktop/scripts/publish-website-ci.ts index 953eff9ef6..97a634965e 100644 --- a/apps/desktop/scripts/publish-website-ci.ts +++ b/apps/desktop/scripts/publish-website-ci.ts @@ -4,7 +4,6 @@ import { appendFile, mkdir, readFile, rename, writeFile } from 'node:fs/promises import { dirname, join, resolve } from 'node:path' import { setTimeout as sleep } from 'node:timers/promises' import { z } from 'zod' -import { teo } from 'tencentcloud-sdk-nodejs-teo' import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts' import { desktopReleasesRoot } from './retain-desktop-ci.ts' import { readRetainedDesktopManifest, retainedDesktopPath } from './desktop-release-store.ts' @@ -87,7 +86,8 @@ export async function publishWebsiteCi(mode: 'upload' | 'purge', env: NodeJS.Pro } if (state.upload !== 'uploaded') throw new Error('website publication: upload completion is not confirmed; recover the upload job first') const { desktopLatestInstallerFilename } = await import('./desktop-auto-update-environment.mjs') - const publicUrl = `https://download.deepseek.com/desktop/${desktopLatestInstallerFilename(route.target)}` + const publicUrl = `${config.origin}/desktop/${desktopLatestInstallerFilename(route.target)}` + const { teo } = await import('tencentcloud-sdk-nodejs-teo') const client = new teo.v20220901.Client({ credential: credentials, region: '', profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } } }) const previousAttempt = state.purgeAttempt @@ -104,12 +104,14 @@ export async function publishWebsiteCi(mode: 'upload' | 'purge', env: NodeJS.Pro }) const details = `${publicUrl}\nTask: ${result.jobId ?? 'unknown'}\nObserved: ${result.observedAt}\n${result.diagnostic ?? ''}\n` if (result.status === 'unknown') { - process.stdout.write(`::warning title=官网缓存刷新结果未确认::官网资源已上传,缓存刷新结果未确认;请检查 EdgeOne 任务记录。\n${details}`) + const acceptance = result.jobId ? '刷新已提交,执行结果未确认' : '刷新提交未确认,可能尚未提交;请核对 EdgeOne 历史后人工恢复' + + process.stdout.write(`::warning title=官网缓存刷新结果未确认::官网资源已上传;${acceptance}。请检查 EdgeOne 任务记录。\n${details}`) if (env.GITHUB_STEP_SUMMARY) { let existing = '' try { existing = await readFile(env.GITHUB_STEP_SUMMARY, 'utf8') } catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error } - await writeFile(env.GITHUB_STEP_SUMMARY, `# ⚠ 官网资源已上传,缓存刷新结果未确认\n\n${details}${existing ? `\n${existing}` : ''}`) + await writeFile(env.GITHUB_STEP_SUMMARY, `# ⚠ 官网资源已上传,缓存刷新结果未确认\n\n${acceptance}\n\n${details}${existing ? `\n${existing}` : ''}`) } } else { process.stdout.write(`Website cache purge completed\n${details}`) diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index 646f2b499b..75dcbb9087 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -12,7 +12,7 @@ const runSchema = z.object({ id: z.number().int().positive(), run_attempt: z.num event: z.literal('workflow_dispatch'), path: z.enum(Object.values(desktopPackageWorkflows)), repository: z.object({ full_name: z.string() }) }) const jobsSchema = z.object({ jobs: z.array(z.object({ - name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable(), + name: z.string(), run_attempt: z.number().int().positive(), runner_name: z.string().nullable(), conclusion: z.string().nullable(), })) }) /** @@ -35,7 +35,7 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r } let jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs) let packages = jobs.filter(job => job.name === 'package') - if (packages.length === 0) { + if (packages.length === 0 || packages.every(job => job.run_attempt < Number(attempt))) { // An archival-only attempt owns no new build; select the latest actual package job, never a prior success over a failed build. const historySchema = z.array(z.object({ jobs: z.array(z.object({ name: z.string(), run_attempt: z.number().int().positive(), runner_name: z.string().nullable(), conclusion: z.string().nullable() })) })) @@ -53,7 +53,7 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r jobs = z.array(jobsSchema).parse(api(`${base}/attempts/${original.run_attempt}/jobs?per_page=100`, true)).flatMap(page => page.jobs) packages = jobs.filter(job => job.name === 'package') } - packages = packages.filter(job => job.conclusion === 'success' && job.runner_name) + packages = packages.filter(job => job.run_attempt === run.run_attempt && job.conclusion === 'success' && job.runner_name) if (packages.length !== 1) throw new Error('desktop publish: expected one successful package job') const runnerName = packages[0]!.runner_name! const routes = desktopPublishRunnersSchema.parse(routesValue) diff --git a/apps/desktop/tests/desktop-package-selection.spec.ts b/apps/desktop/tests/desktop-package-selection.spec.ts index 4f4172984e..54d2aaec13 100644 --- a/apps/desktop/tests/desktop-package-selection.spec.ts +++ b/apps/desktop/tests/desktop-package-selection.spec.ts @@ -9,7 +9,7 @@ import { retainDesktopRelease, verifyRetainedDesktopRelease } from '../scripts/d function fixture() { const run = { id: 42, run_attempt: 2, head_sha: 'a'.repeat(40), status: 'completed', conclusion: 'success', event: 'workflow_dispatch', path: '.github/workflows/macos-package.yml', repository: { full_name: 'owner/repo' } } - const jobs = [{ name: 'package', runner_name: 'mini', conclusion: 'success' }] + const jobs = [{ name: 'package', run_attempt: 2, runner_name: 'mini', conclusion: 'success' }] const routes = { mini: { label: 'dsh-mini', target: 'mac-arm64' } } const calls: string[] = [] const api = (path: string, paginate?: boolean) => { @@ -39,6 +39,7 @@ it('routes a Windows package through its own configured machine target', () => { it('selects an explicit old attempt without consulting the latest attempt', () => { const f = fixture() f.run.run_attempt = 1 + f.jobs[0]!.run_attempt = 1 selectDesktopPackage('owner/repo', { package_run_id: '42', package_run_attempt: '1' }, f.routes, f.api) expect(f.calls).not.toContain('repos/owner/repo/actions/runs/42') expect(f.calls).toContain('repos/owner/repo/actions/runs/42/attempts/1') @@ -136,12 +137,12 @@ it('routes both Mac architectures to one machine without authorizing an unlisted } }) -it('keeps the original successful build after an archival-only retry', () => { +it.each([false, true])('keeps original build after archival retry with reused job listed: %s', (includesReused) => { const f = fixture() const selected = selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, (path) => { if (path.endsWith('/42')) return { run_attempt: 3 } if (path.endsWith('/attempts/3')) return { ...f.run, run_attempt: 3 } - if (path.endsWith('/attempts/3/jobs?per_page=100')) return [{ jobs: [{ name: 'upload-artifact', runner_name: 'mini', conclusion: 'success' }] }] + if (path.endsWith('/attempts/3/jobs?per_page=100')) return [{ jobs: [{ name: 'upload-artifact', run_attempt: 3, runner_name: 'mini', conclusion: 'success' }, ...(includesReused ? f.jobs : [])] }] if (path.includes('filter=all')) return [{ jobs: [{ ...f.jobs[0], run_attempt: 2 }] }] if (path.endsWith('/attempts/2')) return { ...f.run, conclusion: 'failure' } return [{ jobs: f.jobs }] @@ -153,6 +154,6 @@ it('keeps the original successful build after an archival-only retry', () => { it('accepts a successful package when only archival failed', () => { const f = fixture() f.run.conclusion = 'failure' - f.jobs.push({ name: 'upload-artifact', runner_name: 'mini', conclusion: 'failure' }) + f.jobs.push({ name: 'upload-artifact', run_attempt: 3, runner_name: 'mini', conclusion: 'failure' }) expect(selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, f.api).run.run_attempt).toBe(2) }) diff --git a/apps/desktop/tests/desktop-publication-failure.spec.ts b/apps/desktop/tests/desktop-publication-failure.spec.ts index 6f9e8d05e6..80daeab539 100644 --- a/apps/desktop/tests/desktop-publication-failure.spec.ts +++ b/apps/desktop/tests/desktop-publication-failure.spec.ts @@ -44,3 +44,15 @@ it('omits malformed provider receipt fields and never copies raw errors into COS expect(failure).not.toHaveProperty('httpStatus') expect(JSON.stringify(failure)).not.toContain('fixture-secret') }) + +it('prints structured archival failure through the real CLI', () => { + const result = spawnSync(process.execPath, ['--import', 'tsx/esm', + fileURLToPath(new URL('../scripts/archive-desktop-ci.ts', import.meta.url))], { + encoding: 'utf8', timeout: 30_000, env: { ...process.env, RUNNER_NAME: '', DESKTOP_PACKAGE_RUNNER: '' }, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status).toBe(1) + expect(JSON.parse(result.stderr)).toEqual({ phase: 'artifact archival', code: 'validation', + message: 'desktop archival: wrong original machine' }) +}) diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index 39e717a827..6f5d2caf8b 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -39,6 +39,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu })) const env = { DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_PACKAGING_PRESET: 'test', + DESKTOP_PUBLISH_RUNNERS: JSON.stringify({ 'fixture-runner': { label: 'dsh-fixture', target: [target] } }), DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '10', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: commit, RUNNER_NAME: 'fixture-runner', diff --git a/apps/desktop/tests/desktop-purge-recovery.spec.ts b/apps/desktop/tests/desktop-purge-recovery.spec.ts index 8c631005d8..83ce0e5992 100644 --- a/apps/desktop/tests/desktop-purge-recovery.spec.ts +++ b/apps/desktop/tests/desktop-purge-recovery.spec.ts @@ -91,6 +91,18 @@ describe('website purge recovery', () => { expect(f.client.CreatePurgeTask).not.toHaveBeenCalled() }) + it('reports a possibly absent submission without resubmitting it', async () => { + const f = fixture() + f.client.CreatePurgeTask.mockRejectedValue(new Error('lost response')) + f.client.DescribePurgeTasks.mockResolvedValue({ TotalCount: 0, Tasks: [] }) + const result = await recoverDesktopPurge(config, undefined, f.client, f.options) + expect(result.status).toBe('unknown') + expect(result.diagnostic).toContain('may not have been submitted') + expect(result.jobId).toBeUndefined() + expect(f.client.CreatePurgeTask).toHaveBeenCalledOnce() + expect(f.client.DescribePurgeTasks).toHaveBeenCalledTimes(2) + }) + it('does not mistake multiple matching tasks for confirmed completion', async () => { const f = fixture() f.client.CreatePurgeTask.mockRejectedValue(new Error('lost response')) diff --git a/apps/desktop/tests/expected/website-purge-unknown.md b/apps/desktop/tests/expected/website-purge-accepted.md similarity index 85% rename from apps/desktop/tests/expected/website-purge-unknown.md rename to apps/desktop/tests/expected/website-purge-accepted.md index c91a01223c..c0f73b7785 100644 --- a/apps/desktop/tests/expected/website-purge-unknown.md +++ b/apps/desktop/tests/expected/website-purge-accepted.md @@ -1,5 +1,7 @@ # ⚠ 官网资源已上传,缓存刷新结果未确认 +刷新已提交,执行结果未确认 + https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg Task: job-1 Observed: 2026-10-30T00:00:00.000Z diff --git a/apps/desktop/tests/expected/website-purge-unconfirmed.md b/apps/desktop/tests/expected/website-purge-unconfirmed.md new file mode 100644 index 0000000000..5ec864ab9a --- /dev/null +++ b/apps/desktop/tests/expected/website-purge-unconfirmed.md @@ -0,0 +1,8 @@ +# ⚠ 官网资源已上传,缓存刷新结果未确认 + +刷新提交未确认,可能尚未提交;请核对 EdgeOne 历史后人工恢复 + +https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg +Task: unknown +Observed: 2026-10-30T00:00:00.000Z +task query failed; check EdgeOne history and DescribePurgeTasks permission diff --git a/apps/desktop/tests/publish-website-ci.spec.ts b/apps/desktop/tests/publish-website-ci.spec.ts index 8eb9755301..6905c3aade 100644 --- a/apps/desktop/tests/publish-website-ci.spec.ts +++ b/apps/desktop/tests/publish-website-ci.spec.ts @@ -3,13 +3,15 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' +import * as desktopCos from '../scripts/desktop-cos.ts' +import { answer, startCosLoopback } from './cos-loopback.ts' import { teo } from 'tencentcloud-sdk-nodejs-teo' import { publishWebsiteCi, websiteApproval } from '../scripts/publish-website-ci.ts' vi.mock('node:timers/promises', () => ({ setTimeout: async () => {} })) afterEach(() => vi.restoreAllMocks()) -it('returns successfully with a visible warning and durable unknown state after two failed queries', async () => { +it.each(['accepted', 'unconfirmed', 'upload', 'uploaded', 'approval'] as const)('preserves publication recovery for %s', async (scenario) => { const root = await mkdtemp(join(tmpdir(), 'website-ci-')) try { const config = join(root, 'packaging') @@ -28,12 +30,39 @@ it('returns successfully with a visible warning and durable unknown state after const manifest = { schemaVersion: 1, source: { repository: 'owner/repo', runId: '12', attempt: '1', commit: route.commit, workflowCommit: route.commit, sourceRef: 'master', channel: 'nightly', runnerName: 'mini', target: 'mac-arm64', preset: 'production', productVersion: '1.0.0', version: '1.0.0', testReleaseId: null, qualification: 'notarized' }, - files: [{ name: 'installer.dmg', size: 1, sha512: createHash('sha512').update('x').digest('base64') }] } + files: [{ name: 'deepseek-harness-1.0.0-mac-arm64.dmg', size: 1, sha512: createHash('sha512').update('x').digest('base64') }] } await writeFile(join(root, 'releases', '12', '1', 'manifest.json'), JSON.stringify(manifest)) const approval = websiteApproval(manifest, 'production-123') await writeFile(join(records, 'state.json'), JSON.stringify({ approval, upload: 'uploaded' })) + const env = { RUNNER_NAME: 'mini', GITHUB_REPOSITORY: 'owner/repo', GITHUB_RUN_ID: '99', + GITHUB_RUN_ATTEMPT: '1', DSH_PACKAGING_CONFIG_DIR: config, DESKTOP_PUBLISH_ROUTE: JSON.stringify(route), + DESKTOP_WEBSITE_APPROVAL: approval, DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', GITHUB_OUTPUT: join(root, 'output') } + if (scenario === 'upload' || scenario === 'uploaded' || scenario === 'approval') { + await writeFile(join(root, 'releases', '12', '1', manifest.files[0]!.name), 'x') + if (scenario === 'upload') await rm(join(records, 'state.json')) + const server = await startCosLoopback((_request, response) => { answer(response, 200, 'x') }) + const original = desktopCos.createDesktopCos + vi.spyOn(desktopCos, 'createDesktopCos').mockImplementation((credentials) => { + const client = original(credentials) + server.redirect(client) + return client + }) + try { + if (scenario === 'approval') { + await expect(publishWebsiteCi('upload', { ...env, DESKTOP_WEBSITE_APPROVAL: 'changed' })).rejects.toThrow('approved build or destination changed') + expect(server.requests).toHaveLength(0) + } else { + await publishWebsiteCi('upload', env) + expect(server.requests.map(request => request.method)).toEqual(scenario === 'upload' ? ['GET'] : []) + expect(JSON.parse(await readFile(join(records, 'state.json'), 'utf8'))).toMatchObject({ approval, upload: 'uploaded' }) + expect(await readFile(env.GITHUB_OUTPUT, 'utf8')).toContain(`record=${records}`) + } + } finally { await server.close() } + return + } const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' }) const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('fixture-key')) + if (scenario === 'unconfirmed') submit.mockRejectedValue(new Error('lost response')) const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-30T00:00:00.000Z')) const summary = join(root, 'summary.md') @@ -44,7 +73,7 @@ it('returns successfully with a visible warning and durable unknown state after expect(query).toHaveBeenCalledTimes(2) expect(output.mock.calls.map(call => String(call[0])).join('')).toContain('::warning title=官网缓存刷新结果未确认::') expect(JSON.parse(await readFile(join(records, 'state.json'), 'utf8'))).toMatchObject({ upload: 'uploaded', purge: { status: 'unknown' } }) - await expect(await readFile(summary, 'utf8')).toMatchFileSnapshot('./expected/website-purge-unknown.md') + await expect(await readFile(summary, 'utf8')).toMatchFileSnapshot(`./expected/website-purge-${scenario}.md`) expect(await readFile(summary, 'utf8')).not.toContain('fixture-key') } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/scripts/desktop-publish-workflow.spec.ts b/scripts/desktop-publish-workflow.spec.ts index 85dfd56489..96119c56ee 100644 --- a/scripts/desktop-publish-workflow.spec.ts +++ b/scripts/desktop-publish-workflow.spec.ts @@ -9,6 +9,7 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github permissions: Record jobs: Record if?: string @@ -85,6 +86,8 @@ it('defaults to production website upload and separates cache-only recovery', () const purge = workflow.jobs['purge-website-installer']! expect(upload.if).toContain("inputs.upload_website && needs.prepare.outputs.preset == 'production'") expect(purge.needs).toEqual(['route', 'prepare', 'upload-website-installer']) + expect(upload.concurrency).toEqual({ group: 'desktop-website-upload-${{ needs.prepare.outputs.website-destination }}', 'cancel-in-progress': false }) + expect(purge.concurrency).toEqual({ group: 'desktop-website-purge-${{ needs.prepare.outputs.website-destination }}', 'cancel-in-progress': false }) for (const job of [upload, purge]) { expect(job.environment).toBe('desktop-production') expect(job.env?.DESKTOP_WEBSITE_APPROVAL).toBe('${{ needs.prepare.outputs.website-approval }}') diff --git a/scripts/windows-package-workflow.spec.ts b/scripts/windows-package-workflow.spec.ts index 5d35d4525f..c96b844f7f 100644 --- a/scripts/windows-package-workflow.spec.ts +++ b/scripts/windows-package-workflow.spec.ts @@ -11,6 +11,7 @@ const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows permissions: Record concurrency: { group: string; 'cancel-in-progress': boolean } jobs: Record steps: Array<{ @@ -213,3 +214,10 @@ it.skipIf(process.platform !== 'win32')('stages helpers that remain usable when rmSync(directory, { recursive: true, force: true, maxRetries: 3 }) } }) + +it('archives with native PowerShell and the persistent packaging store', () => { + const archival = workflow.jobs['upload-artifact']! + expect(archival.defaults?.run.shell).toBe('pwsh') + expect(archival.steps.find(step => step.name === 'Configure persistent pnpm store')?.run) + .toBe(job.steps.find(step => step.name === 'Configure persistent pnpm store')?.run) +}) From 8caf3d7838a867402079005472a7d187125a121e Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 30 Oct 2026 18:20:41 +0800 Subject: [PATCH 03/36] fix(desktop): clarify publication approval and recovery diagnostics --- .github/workflows/desktop-publish.yml | 7 +++- apps/desktop/publishing/ci/README.i18n.yaml | 4 +- apps/desktop/publishing/ci/README.md | 6 +-- apps/desktop/publishing/ci/README.zh.md | 6 +-- apps/desktop/scripts/archive-desktop-ci.ts | 2 +- .../scripts/desktop-publication-failure.ts | 5 ++- .../scripts/desktop-publication-summary.ts | 6 ++- .../desktop/scripts/desktop-purge-recovery.ts | 2 +- apps/desktop/scripts/publish-desktop-ci.ts | 2 +- apps/desktop/scripts/publish-website-ci.ts | 16 +++---- .../scripts/resolve-desktop-publish-ci.ts | 4 +- .../desktop-publication-preflight.spec.ts | 2 +- .../tests/desktop-publication-summary.spec.ts | 16 +++++-- .../tests/desktop-purge-recovery.spec.ts | 10 +++++ .../publication-production-website.md | 42 +++++++++++++++++++ .../expected/website-purge-unconfirmed.md | 2 +- apps/desktop/tests/publish-website-ci.spec.ts | 4 ++ scripts/desktop-publish-workflow.spec.ts | 3 ++ scripts/macos-package-workflow.spec.ts | 6 +++ 19 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/tests/expected/publication-production-website.md diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index d35778c640..296ee125d0 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -61,7 +61,6 @@ jobs: outputs: route: ${{ steps.local.outputs.route }} preset: ${{ steps.local.outputs.preset }} - target: ${{ fromJSON(steps.local.outputs.route).target }} approval: ${{ steps.check.outputs.approval }} website-approval: ${{ steps.check.outputs.website-approval }} website-destination: ${{ steps.check.outputs.website-destination }} @@ -196,6 +195,9 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Check local publication configuration + working-directory: website-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs - run: pnpm install --frozen-lockfile working-directory: website-publisher - name: Upload website installer @@ -238,6 +240,9 @@ jobs: - uses: actions/setup-node@v7.0.0 with: node-version: '24' + - name: Check local publication configuration + working-directory: website-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs - run: pnpm install --frozen-lockfile working-directory: website-publisher - name: Recover and observe website cache purge diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index b8858beeaf..3ecb74d720 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -27,8 +27,8 @@ en: be3cf37739f7f9b3 zh: 33b7c8ca3b547c5e /desktop-ci-publication/recovery-and-records: - en: ec8cf99e4f5ec1bc - zh: 20547260bfadad4c + en: 32ba51ae83843910 + zh: a956ef14d7bd22f0 /desktop-ci-publication/restore-a-channel: en: d82e2ccf70cce397 zh: d93dcb77cc4b463e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 2db7533adf..a90ed18ae8 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -80,13 +80,13 @@ Before installing dependencies on the original runner, preparation and the appro ## Recovery and records -The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). All packaging modes, including test and unsigned builds, require `DESKTOP_PUBLISH_RUNNERS` before packaging; it routes archival to the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. +The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). All packaging modes, including test and unsigned builds, require `DESKTOP_PUBLISH_RUNNERS` before packaging; it routes archival to the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. If an archival retry stays queued without a runner, inspect the resolved runner label and upstream package outputs before treating the runner as offline. Missing outputs require operator recovery of the original retained directory and manifest; rerunning the whole workflow rebuilds the package and is not archival-only recovery. Verify output reuse in the Actions acceptance test. -Production COS publication defaults to **同时上传官网安装包并刷新缓存** (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. Both production nightly and latest packages can replace the fixed website installer. Operators select the intended version; the fixed object has no downgrade protection, so rerunning an older publication can replace a newer installer. The existing version/update publication retains its own checks. +Production COS publication defaults to **同时上传官网安装包并刷新缓存** (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. Both production nightly and latest packages can replace the fixed website installer. Operators select the intended version; the fixed object has no downgrade protection, so rerunning an older publication can replace a newer installer. The pre-approval summary names the fixed website URL, selected version and lack of downgrade protection when website upload is selected. The existing version/update publication retains its own checks. Set the repository variable `DOWNLOAD_PROD_EO_ZONE_ID` to the production EdgeOne zone. The production COS credential pair also needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks`. Website uploads serialize by bucket/target in Actions and by bucket/object on the original machine. Cache observation runs separately. Version publication, website upload and cache refresh each use the production approval Environment; later jobs and reruns may require another approval. GitHub concurrency retains at most one pending job per group, so a newer pending job can cancel an older pending job; inspect canceled jobs before rerunning them. -Website state lives in `upload-records/website--/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. A completed upload is not repeated on a job rerun; ambiguous uploads reconcile remote bytes first. Rerun only the purge job to inspect its saved task. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. An unknown result with a task ID means submission was confirmed; without an ID it may never have been submitted. Inspect EdgeOne history before manual recovery, and use a new publication run if a fresh submission is needed. +Website state lives in `upload-records/website--/state.json`, beside the retained releases root. macOS and Windows use the same record format on separate local disks. A completed upload is not repeated on a job rerun; ambiguous uploads reconcile remote bytes first. Rerun only the purge job to inspect its saved task. A manually rerun purge may submit once after confirming the previous task failed. A lost submission response queries matching history instead of resubmitting. An unknown result with a task ID means submission was confirmed; without an ID it may never have been submitted. Inspect EdgeOne history before manual recovery, and a fresh publication run is one recovery option. Operators with the target production dotenv and credentials may instead run `node --import tsx apps/desktop/scripts/upload-target.ts --latest --purge-only` on the configured machine. This submits only a fixed-URL purge: it does not observe completion or update this Actions run’s journal. Inspect the returned task in EdgeOne before considering recovery complete. Purge observation waits up to five minutes at ten-second intervals, with one additional query after an unknown result or observation deadline. Confirmed failed, timeout or canceled tasks fail the job. An unresolved result exits successfully with an Actions warning and a prominent summary; its durable state remains unknown, not completed. Diagnostic artifact archival is best effort; local records remain the recovery source. Simulated-provider tests cover these paths; release operators must verify production COS/EdgeOne permissions, job-id filtering, time-range queries, task type and timestamp formats, cross-attempt Actions recovery, and actual CDN behavior before relying on recovery in production. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index ec30826514..f54e2bca34 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -80,13 +80,13 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复与记录 -打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。所有打包模式(包括 test 和 unsigned)都要求在打包前配置 `DESKTOP_PUBLISH_RUNNERS`,用于将归档路由到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。 +打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。所有打包模式(包括 test 和 unsigned)都要求在打包前配置 `DESKTOP_PUBLISH_RUNNERS`,用于将归档路由到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。归档重跑若一直排队且未分配 runner,应先检查解析出的 runner 标签和上游 package 输出,不能直接认定机器离线。输出缺失时需由操作者从原留存目录和 manifest 恢复文件;重跑整个 workflow 会重新构建,不属于仅归档恢复。Actions 验收须验证输出复用。 -生产 COS 发布默认勾选“同时上传官网安装包并刷新缓存”(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。production nightly 和 latest 包都可以替换官网固定安装包。操作者负责选择预期版本;固定对象没有防降级保护,重跑旧发布可能覆盖新安装包。原有版本与自动更新发布保留自身校验。 +生产 COS 发布默认勾选“同时上传官网安装包并刷新缓存”(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。production nightly 和 latest 包都可以替换官网固定安装包。操作者负责选择预期版本;固定对象没有防降级保护,重跑旧发布可能覆盖新安装包。勾选官网上传时,审批前摘要展示固定官网 URL、选定版本和无防降级保护的说明。原有版本与自动更新发布保留自身校验。 将仓库变量 `DOWNLOAD_PROD_EO_ZONE_ID` 配置为生产 EdgeOne 站点。生产 COS 凭据还需要 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。官网上传在 Actions 中按 bucket/平台互斥,在原机器上按 bucket/对象互斥;缓存观察单独运行。版本发布、官网上传和缓存刷新分别使用生产审批 Environment,后续 job 和重跑可能需要再次审批。GitHub 每个并发组最多保留一个排队 job,新排队 job 可能取消旧排队 job;重跑前应检查取消记录。 -官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。job 重跑不重复已确认完成的上传;结果未知的上传先核对远端字节。仅重跑刷新 job 即可查询已保存任务;人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。unknown 带任务 ID 表示提交已确认;没有 ID 则可能从未提交。人工恢复前应检查 EdgeOne 历史,需要重新提交时发起新的发布运行。 +官网状态保存在 releases 根目录同级的 `upload-records/website-<发布 run ID>-/state.json`。macOS 和 Windows 在各自本地磁盘使用相同记录格式。job 重跑不重复已确认完成的上传;结果未知的上传先核对远端字节。仅重跑刷新 job 即可查询已保存任务;人工重跑确认旧任务失败后可提交一次。提交响应丢失时查询匹配的历史记录,不重新提交。unknown 带任务 ID 表示提交已确认;没有 ID 则可能从未提交。人工恢复前应检查 EdgeOne 历史,重新发起发布运行是恢复方式之一。已配置目标 production dotenv 和凭据的操作者也可在配置机器上执行 `node --import tsx apps/desktop/scripts/upload-target.ts --latest --purge-only`,只提交固定 URL 刷新,不观察完成状态,也不更新本次 Actions 运行的 journal。确认恢复完成前应在 EdgeOne 检查返回的任务。 刷新观察每十秒查询一次,最长五分钟;结果未知或观察超时后额外查询一次。明确的 failed、timeout、canceled 结果使 job 失败。仍无法确认时成功退出,并输出 Actions warning 和醒目摘要;持久化状态保持 unknown,不标为完成。诊断 artifact 尽力归档,本地记录仍是恢复依据。模拟服务测试覆盖这些路径;发布操作者必须在生产依赖恢复能力前验证 COS/EdgeOne 权限、job-id 筛选、时间范围查询、任务类型和时间戳格式、Actions 跨 attempt 恢复及实际 CDN 行为。 diff --git a/apps/desktop/scripts/archive-desktop-ci.ts b/apps/desktop/scripts/archive-desktop-ci.ts index ec96e2635f..849433b698 100644 --- a/apps/desktop/scripts/archive-desktop-ci.ts +++ b/apps/desktop/scripts/archive-desktop-ci.ts @@ -1,6 +1,6 @@ /** Validate the original machine and immutable inventory before GitHub artifact archival. */ -import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { resolve } from 'node:path' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { desktopReleasesRoot } from './retain-desktop-ci.ts' import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts' diff --git a/apps/desktop/scripts/desktop-publication-failure.ts b/apps/desktop/scripts/desktop-publication-failure.ts index dde9b1c58c..3772fb0b67 100644 --- a/apps/desktop/scripts/desktop-publication-failure.ts +++ b/apps/desktop/scripts/desktop-publication-failure.ts @@ -45,8 +45,9 @@ export function desktopPublicationFailure(error: unknown, phase: string): { if (error instanceof ZodError) return { phase, code: 'invalid-data', message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` } if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' } - const ownedMessage = /^(desktop (?:publish|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u - const websiteMessage = /^(website (?:publication|installer|cache purge)|desktop archival):/u + const ownedMessage = + /^(desktop (?:publish|archival|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u + const websiteMessage = /^website (?:publication|installer|cache purge):/u if (error instanceof Error && (ownedMessage.test(error.message) || websiteMessage.test(error.message))) { return { phase, code: 'validation', message: error.message } } diff --git a/apps/desktop/scripts/desktop-publication-summary.ts b/apps/desktop/scripts/desktop-publication-summary.ts index 4d2d437dd3..9cc48fcde7 100644 --- a/apps/desktop/scripts/desktop-publication-summary.ts +++ b/apps/desktop/scripts/desktop-publication-summary.ts @@ -1,6 +1,7 @@ /** Render the verified publication plan for GitHub's native deployment reviewers. */ import { createHash } from 'node:crypto' import { createReadStream } from 'node:fs' +import { desktopLatestInstallerFilename } from './desktop-auto-update-environment.mjs' import type { DesktopRetainedSource } from './desktop-release-store.ts' import type { DesktopUploadPlan } from './desktop-upload-plan.ts' @@ -18,10 +19,11 @@ function link(url: string, label = url): string { return ` { const origin = new URL(plan.publicUrl).origin const url = (key: string) => `${origin}/${key.split('/').map(encodeURIComponent).join('/')}` @@ -52,6 +54,8 @@ export async function desktopPublicationSummary( row('升级 YAML', `${feeds.map(file => link(url(file.key))).join('
')}
本次发布将更新此文件;审批前可能不存在或仍指向旧版本。`), row('打包来源', `${link(runUrl, `查看打包运行 #${source.runId} · 第 ${source.attempt} 次尝试`)}
源码:${code(`${source.sourceRef} @ ${source.commit}`)}
打包机器:${code(source.runnerName)}`), row('签名与公证', plan.target === 'win-x64' ? 'EXE 签名校验通过' : 'DMG 校验通过
ZIP 内应用校验通过'), + ...(uploadWebsite && plan.environment === 'production' ? [row('官网固定安装包', + `${link(`https://download.deepseek.com/desktop/${desktopLatestInstallerFilename(plan.target)}`)}
替换为版本 ${code(plan.version)};已有安装包会被替换,无防降级保护;随后刷新缓存。`)] : []), row('Git tag', tag), '', '### 批准后将执行', '', '1. 重新校验产物与发布目标;若与审批摘要不一致则停止。', '2. 上传缺少的文件,跳过内容一致的已有文件;发现内容冲突则停止。', diff --git a/apps/desktop/scripts/desktop-purge-recovery.ts b/apps/desktop/scripts/desktop-purge-recovery.ts index 8df363d7db..890ea003ba 100644 --- a/apps/desktop/scripts/desktop-purge-recovery.ts +++ b/apps/desktop/scripts/desktop-purge-recovery.ts @@ -139,7 +139,7 @@ export async function recoverDesktopPurge(config: DesktopPurgeConfig, previous: if (current.status === 'success') return current if (failed(current.status)) throw new DesktopPurgeExecutionError(current.status) if (extraQuery) { - current = { ...current, status: 'unknown', diagnostic: current.diagnostic ?? 'observation deadline exceeded' } + current = { ...current, status: 'unknown', diagnostic: current.diagnostic ?? (options.now() >= deadline ? 'observation deadline exceeded' : 'task still processing after an additional query') } await options.save(current) return current } diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index a670e50ec3..2fcc3d6fcb 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -71,7 +71,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi if (mode === '--check') { if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `website-destination=${createHash('sha256').update(`${config.bucket}/${plan.target}`).digest('hex')}\nwebsite-approval=${websiteApproval(manifest, config.bucket)}\napproval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) if (env.GITHUB_STEP_SUMMARY && !env.DESKTOP_APPROVED_PACKAGE) await appendFile(env.GITHUB_STEP_SUMMARY, - await desktopPublicationSummary(manifest.source, plan, approval)) + await desktopPublicationSummary(manifest.source, plan, approval, env.DESKTOP_UPLOAD_WEBSITE === 'true')) if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT, `app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`) process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`) diff --git a/apps/desktop/scripts/publish-website-ci.ts b/apps/desktop/scripts/publish-website-ci.ts index 97a634965e..4a4ba6c271 100644 --- a/apps/desktop/scripts/publish-website-ci.ts +++ b/apps/desktop/scripts/publish-website-ci.ts @@ -68,17 +68,18 @@ export async function publishWebsiteCi(mode: 'upload' | 'purge', env: NodeJS.Pro const installer = await prepareWebsiteInstaller(retainedDesktopPath(root, route.runId, route.attempt), manifest, route, env.GITHUB_REPOSITORY || '', config.bucket) try { - await withDesktopChannelLock(join(dirname(root), 'publish-locks'), `${config.bucket}/${installer.plan.artifacts[0]!.key}`, async () => { + const outcome = await withDesktopChannelLock(join(dirname(root), 'publish-locks'), `${config.bucket}/${installer.plan.artifacts[0]!.key}`, async () => { if (state.upload === 'uploaded') { - process.stdout.write('website installer: this publication already uploaded; skipping retransmission\n') - return + return 'recorded' as const } - await uploadWebsiteInstaller(installer, credentials, desktopPublishCos(installer.plan, credentials), async (event) => { + return uploadWebsiteInstaller(installer, credentials, desktopPublishCos(installer.plan, credentials), async (event) => { state = { ...state, upload: event } await save() }) }) - const text = `Website installer uploaded: ${installer.plan.publicUrl}; version ${installer.plan.version}\n` + const disposition = outcome === 'recorded' ? 'previous upload confirmed by journal; skipped' + : outcome === 'present' ? 'identical remote bytes confirmed; skipped' : 'uploaded' + const text = `Website installer ${disposition}: ${installer.plan.publicUrl}; version ${installer.plan.version}\n` process.stdout.write(text) if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, text) } finally { await installer.handle.close() } @@ -104,9 +105,8 @@ export async function publishWebsiteCi(mode: 'upload' | 'purge', env: NodeJS.Pro }) const details = `${publicUrl}\nTask: ${result.jobId ?? 'unknown'}\nObserved: ${result.observedAt}\n${result.diagnostic ?? ''}\n` if (result.status === 'unknown') { - const acceptance = result.jobId ? '刷新已提交,执行结果未确认' : '刷新提交未确认,可能尚未提交;请核对 EdgeOne 历史后人工恢复' - - process.stdout.write(`::warning title=官网缓存刷新结果未确认::官网资源已上传;${acceptance}。请检查 EdgeOne 任务记录。\n${details}`) + const acceptance = result.jobId ? '刷新已提交,执行结果未确认' : '刷新提交未确认,可能尚未提交' + process.stdout.write(`::warning title=官网缓存刷新结果未确认::官网资源已上传;${acceptance}。请核对 EdgeOne 任务记录后人工恢复。\n${details}`) if (env.GITHUB_STEP_SUMMARY) { let existing = '' try { existing = await readFile(env.GITHUB_STEP_SUMMARY, 'utf8') } diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index 75dcbb9087..d1ad2264d4 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -37,9 +37,7 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r let packages = jobs.filter(job => job.name === 'package') if (packages.length === 0 || packages.every(job => job.run_attempt < Number(attempt))) { // An archival-only attempt owns no new build; select the latest actual package job, never a prior success over a failed build. - const historySchema = z.array(z.object({ jobs: z.array(z.object({ name: z.string(), run_attempt: z.number().int().positive(), - runner_name: z.string().nullable(), conclusion: z.string().nullable() })) })) - const history = historySchema.parse(api(`${base}/jobs?filter=all&per_page=100`, true)).flatMap(page => page.jobs) + const history = z.array(jobsSchema).parse(api(`${base}/jobs?filter=all&per_page=100`, true)).flatMap(page => page.jobs) .filter(job => job.name === 'package' && job.run_attempt <= Number(attempt)) .sort((left, right) => right.run_attempt - left.run_attempt) const original = history[0] diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index 6f5d2caf8b..04402925a5 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -53,7 +53,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu expect(prepared.plan.version).toBe('1.0.0') expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true) expect(signatureChecks).toBe(1) - const summary = await desktopPublicationSummary(prepared.manifest.source, prepared.plan, 'c'.repeat(64)) + const summary = await desktopPublicationSummary(prepared.manifest.source, prepared.plan, 'c'.repeat(64), false) await expect(summary).toMatchFileSnapshot(`./expected/publication-${target}.md`) const feed = prepared.plan.artifacts.find(file => file.channelMetadata)! expect(summary).toContain(createHash('sha256').update(feed.contents!).digest('hex')) diff --git a/apps/desktop/tests/desktop-publication-summary.spec.ts b/apps/desktop/tests/desktop-publication-summary.spec.ts index 5267d29034..95634c5b47 100644 --- a/apps/desktop/tests/desktop-publication-summary.spec.ts +++ b/apps/desktop/tests/desktop-publication-summary.spec.ts @@ -21,7 +21,7 @@ const plan: DesktopUploadPlan = { } it('shows production destinations and tag after approval with upload-byte fingerprints', async () => { - const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64)) + const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64), false) await expect(summary).toMatchFileSnapshot('./expected/publication-production.md') expect(summary).toContain('/latest/deepseek-harness-1.0.0-win-x64.exe') expect(summary).toContain('https://download.example.com/dsh-desk/feeds/win-x64/latest.yml') @@ -32,7 +32,7 @@ it('shows production destinations and tag after approval with upload-byte finger }) it('escapes runner display text without adding Markdown rows or HTML elements', async () => { - const summary = await desktopPublicationSummary({ ...source, runnerName: 'runner|\n