From c30cd298ce0c6c6f08a2e0b65815a75251b72a53 Mon Sep 17 00:00:00 2001 From: Wang Date: Thu, 27 Aug 2026 18:19:24 +0800 Subject: [PATCH 1/7] fix(release): harden desktop auto-updates Generated-by: OpenAI Codex --- .asf.yaml | 11 + .github/RELEASE_CHECKLIST.md | 23 +- .github/workflows/release-cli-finalize.yml | 63 +++- .github/workflows/release-cli-stage.yml | 2 +- .github/workflows/release.yml | 14 + .gitignore | 1 + .../main/__tests__/app-update-service.test.ts | 31 ++ apps/desktop/src/main/app-update-service.ts | 32 +- .../src/main/app-update-test-context.ts | 106 ++++++ apps/desktop/src/main/main.ts | 10 + docs/cli-npm-release.md | 24 +- docs/cli-npm-release.zh-CN.md | 18 +- package.json | 5 +- scripts/ci-test-plan.mjs | 6 + scripts/ci-test-plan.test.mjs | 6 + scripts/desktop-update-contract.mjs | 215 ++++++++++++ scripts/desktop-update-contract.test.mjs | 111 ++++++ scripts/experiment-macos-autoupdate.mjs | 196 +++++++++++ scripts/package-macos-autoupdate-next.mjs | 94 +++++ scripts/package-windows-autoupdate-next.mjs | 48 +-- scripts/product-release-artifacts.mjs | 51 ++- scripts/product-release-artifacts.test.mjs | 59 ++++ scripts/product-release-authority.mjs | 167 ++++++++- scripts/product-release-authority.test.mjs | 97 ++++- scripts/product-release.test.mjs | 7 +- scripts/release-cli-workflow-policy.test.mjs | 13 +- scripts/verify-macos-arm64-dmg.mjs | 2 + scripts/verify-macos-autoupdate.mjs | 330 ++++++++++++++++++ scripts/verify-windows-autoupdate.mjs | 137 +------- scripts/verify-windows-harness.test.mjs | 2 +- scripts/verify-windows-x64.mjs | 7 +- 31 files changed, 1649 insertions(+), 239 deletions(-) create mode 100644 apps/desktop/src/main/app-update-test-context.ts create mode 100644 scripts/desktop-update-contract.mjs create mode 100644 scripts/desktop-update-contract.test.mjs create mode 100644 scripts/experiment-macos-autoupdate.mjs create mode 100644 scripts/package-macos-autoupdate-next.mjs create mode 100644 scripts/verify-macos-autoupdate.mjs diff --git a/.asf.yaml b/.asf.yaml index afff5f4c74..bc0a69bf36 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -110,6 +110,17 @@ github: policies: - name: "v*" type: tag + product-release: + required_reviewers: + - id: M4n5ter + type: User + wait_timer: 0 + prevent_self_review: true + deployment_branch_policy: + protected_branches: false + policies: + - name: "v*" + type: tag notifications: commits: commits@maka.apache.org diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 5a1c32ab00..321a2bd382 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -60,7 +60,7 @@ must never be exposed to fork or ordinary pull-request jobs. Before the first product release, confirm the checked-in `.asf.yaml` has reconciled the live repository: - the `Immutable release tags` ruleset blocks updates, force-pushes, and deletions of `v*` tags; -- the `release` and `npm-release` Environments accept only their declared tag patterns and require a reviewer other than the triggering user; +- the `release`, `npm-release`, and `product-release` Environments accept only their declared tag patterns and require a reviewer other than the triggering user; - enable immutable releases so assets and the associated tag cannot change after publication. These controls close the check-to-upload and check-to-stage windows. Keep the Release in Draft while assets and acceptance are incomplete; publishing early must make subsequent mutation fail closed. @@ -108,15 +108,16 @@ Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact p 1. Run **Stage CLI npm release** from `v` and record its successful run ID and attempt. 2. Inspect the staged tarball and provenance, then approve that exact stage with npm 2FA. -3. Run **Finalize CLI npm channel** from `main` and confirm it verifies the public package bytes, - provenance, signature, and release dist-tag. +3. Run **Finalize product release** from `v`. Its first job verifies the public package + bytes, provenance, signature, and release dist-tag. 4. Install the exact public version on each release platform and complete the npm acceptance steps. -Keep the GitHub Release in Draft throughout this sequence. A failed or rejected npm candidate -requires a new product version; never publish the Draft to work around npm state. - -When every npm and cross-machine acceptance check has passed, publish the Draft. Mark a stable -release as Latest at that final publication boundary; prereleases must remain non-Latest. +Keep the GitHub Release in Draft throughout this sequence. The final workflow job waits at the +`product-release` Environment. Approve it only after every npm and cross-machine acceptance check +has passed. It redownloads and verifies every Draft byte, then publishes the release and makes a +stable release Latest in the same GitHub operation; prereleases remain non-Latest. Do not publish or +change the Latest designation manually. A failed or rejected npm candidate requires a new product +version; never publish the Draft to work around npm state. ## Acceptance on another Apple Silicon Mac @@ -155,8 +156,8 @@ Download the installer, Windows Desktop ZIP, and both checksum files through a b 7. Add a clean remote Runtime Host from the packaged Desktop app. Confirm setup installs the exact public `maka-agent@` package and the remote session completes one model turn. -Immediately before publication, reverify that the approved ASF candidate tag and convenience -`v` tag still resolve to the same recorded commit. Publish only after npm Finalize and both -independent-machine acceptance passes. If any required artifact, npm step, or +Immediately before approving the `product-release` Environment, reverify that the approved ASF +candidate tag and convenience `v` tag still resolve to the same recorded commit. Approve +only after npm verification and both independent-machine acceptance passes. If any required artifact, npm step, or acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product version, and run the full workflow again. Never replace an existing release identity. diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 7909ce03ca..1160a1206a 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -name: Finalize CLI npm channel +name: Finalize product release on: workflow_dispatch: @@ -38,7 +38,7 @@ permissions: contents: read concurrency: - group: cli-npm-finalize + group: product-release-finalize cancel-in-progress: false jobs: @@ -46,13 +46,17 @@ jobs: name: Verify the public npm channel runs-on: ubuntu-24.04 timeout-minutes: 20 + outputs: + product_tag: ${{ steps.release.outputs.product_tag }} + source_commit: ${{ steps.release.outputs.source_commit }} steps: - - name: Require main + - name: Require the exact product tag env: RELEASE_REF: ${{ github.ref }} + PRODUCT_TAG: v${{ inputs.version }} run: | - if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "CLI npm finalization must be dispatched from main; found $RELEASE_REF" >&2 + if [[ "$RELEASE_REF" != "refs/tags/$PRODUCT_TAG" ]]; then + echo "Product finalization must be dispatched from $PRODUCT_TAG; found $RELEASE_REF" >&2 exit 1 fi @@ -141,3 +145,52 @@ jobs: if-no-files-found: error compression-level: 0 retention-days: 30 + + publish: + name: Publish the immutable product release + needs: inspect + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: + name: product-release + url: https://github.com/apache/maka/releases/tag/${{ needs.inspect.outputs.product_tag }} + permissions: + contents: write + steps: + - name: Check out the exact product source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.inspect.outputs.source_commit }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + - name: Install the release verifier dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Download the complete Draft release + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} + run: gh release download "$PRODUCT_TAG" --dir "$RUNNER_TEMP/product-release" + + - name: Revalidate every product artifact + env: + GITHUB_SHA: ${{ needs.inspect.outputs.source_commit }} + run: node scripts/product-release-artifacts.mjs verify "$RUNNER_TEMP/product-release" + + - name: Publish the verified release and update its channel atomically + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} + PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} + run: | + node scripts/product-release-authority.mjs publish-draft \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "$RUNNER_TEMP/product-release" diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index bda8058009..169410a7be 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -155,7 +155,7 @@ jobs: echo "## maka-agent@$RELEASE_VERSION staging" echo echo "After this workflow succeeds, review and approve the staged package with 2FA on npmjs.com." - echo "After the package becomes public, run **Finalize CLI npm channel** with:" + echo "After the package becomes public, run **Finalize product release** with:" echo echo "- stage run ID: \`$RELEASE_RUN_ID\`" echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf7e5eb180..eed837f7e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,6 +187,20 @@ jobs: if: matrix.platform == 'macos' run: npm run verify:macos-arm64 -- "apps/desktop/release/${{ needs.release-identity.outputs.dmg }}" + - name: Build the version-bumped macOS update + if: matrix.platform == 'macos' + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: npm run package:macos-autoupdate-next + + - name: Verify macOS automatic update end to end + if: matrix.platform == 'macos' + run: | + npm run verify:macos-autoupdate -- \ + "apps/desktop/release/Maka-${{ needs.release-identity.outputs.version }}-mac-arm64.zip" \ + apps/desktop/release-autoupdate-next + # Windows has no Authenticode certificate yet, so this build is unsigned # and there is nothing to notarize between packaging and verification. - name: Package the Windows installer and ZIP diff --git a/.gitignore b/.gitignore index 6ccc1f76fd..cb40d44dfd 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ apps/desktop/bundled-git.json # Generated desktop release inputs and outputs. apps/desktop/resources/tools/ apps/desktop/release/ +apps/desktop/release-autoupdate-next/ apps/desktop/release-sources/ packages/cli/release/ packages/cli/.development/ diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index e42dce6e32..e42e517204 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { describe, test } from 'node:test'; import type { AppUpdater } from 'electron-updater'; +import { resolveUpdateTestUserDataDirectory } from '../app-update-test-context.js'; import { createAppUpdateService, resolveUpdateFeedOverride, @@ -229,6 +230,36 @@ describe('AppUpdateService', () => { } }); + test('the macOS update harness keeps candidate and relaunched successor in one profile', () => { + const explicit = resolveUpdateTestUserDataDirectory({ + feedUrl: 'http://127.0.0.1:1234', + explicitDirectory: '/tmp/update/candidate/.maka-update-test-user-data', + isPackaged: true, + appPath: '/tmp/update/candidate/Maka.app/Contents/Resources/app.asar', + executablePath: '/tmp/update/candidate/Maka.app/Contents/MacOS/Maka', + platform: 'darwin', + }); + const relaunched = resolveUpdateTestUserDataDirectory({ + isPackaged: true, + appPath: '/tmp/update/candidate/Maka.app/Contents/Resources/app.asar', + executablePath: '/tmp/update/candidate/Maka.app/Contents/MacOS/Maka', + platform: 'darwin', + read: () => JSON.stringify({ makaUpdateTestProfile: true }), + }); + assert.equal(relaunched, explicit); + assert.throws( + () => + resolveUpdateTestUserDataDirectory({ + explicitDirectory: '/tmp/update/profile', + isPackaged: true, + appPath: '', + executablePath: '', + platform: 'darwin', + }), + /requires MAKA_UPDATE_TEST_FEED/u, + ); + }); + test('does not overlap checks and cannot re-arm after disposal', async () => { const updater = new FakeUpdater(); let settleCheck!: (value: unknown) => void; diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index b6d82672a2..3aff9847e6 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -20,6 +20,9 @@ import electronUpdater from 'electron-updater'; import type { AppUpdater, UpdateCheckResult } from 'electron-updater'; import type { ProgressInfo, UpdateInfo } from 'electron-updater'; +import { resolveUpdateFeedOverride } from './app-update-test-context.js'; + +export { resolveUpdateFeedOverride } from './app-update-test-context.js'; export type AppUpdateProgress = { percent: number; @@ -140,35 +143,6 @@ const UPDATE_CHECK_ON_FOCUS_MIN_INTERVAL_MS = 15 * 60 * 1000; * exists) applies to overridden feeds exactly as it does to the GitHub feed — * nothing here relaxes it. */ -export function resolveUpdateFeedOverride( - raw: string | undefined, -): { provider: 'generic'; url: string } | undefined { - if (raw === undefined || raw === '') return undefined; - let url: URL; - try { - url = new URL(raw); - } catch { - throw new TypeError( - `MAKA_UPDATE_TEST_FEED is not a URL: ${JSON.stringify(raw)}`, - ); - } - if ( - url.protocol !== 'http:' || - url.hostname !== '127.0.0.1' || - url.port === '' || - url.username !== '' || - url.password !== '' || - url.search !== '' || - url.hash !== '' - ) { - throw new TypeError( - 'MAKA_UPDATE_TEST_FEED must be http://127.0.0.1:[/path] ' + - `(got ${JSON.stringify(raw)})`, - ); - } - return { provider: 'generic', url: url.toString() }; -} - function normalizeVersion(version: string): string { return version.trim().replace(/^v/i, ''); } diff --git a/apps/desktop/src/main/app-update-test-context.ts b/apps/desktop/src/main/app-update-test-context.ts new file mode 100644 index 0000000000..f2a705744b --- /dev/null +++ b/apps/desktop/src/main/app-update-test-context.ts @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'node:fs'; +import { basename, dirname, isAbsolute, join } from 'node:path'; + +/** Exact loopback-only feed accepted by packaged auto-update E2E tests. */ +export function resolveUpdateFeedOverride( + raw: string | undefined, +): { provider: 'generic'; url: string } | undefined { + if (raw === undefined || raw === '') return undefined; + let url: URL; + try { + url = new URL(raw); + } catch { + throw new TypeError(`MAKA_UPDATE_TEST_FEED is not a URL: ${JSON.stringify(raw)}`); + } + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + url.port === '' || + url.username !== '' || + url.password !== '' || + url.search !== '' || + url.hash !== '' + ) { + throw new TypeError( + 'MAKA_UPDATE_TEST_FEED must be http://127.0.0.1:[/path] ' + + `(got ${JSON.stringify(raw)})`, + ); + } + return { provider: 'generic', url: url.toString() }; +} + +/** + * Keeps both sides of a macOS replacement in one disposable profile. + * + * The candidate receives an explicit directory paired with the loopback feed. + * Squirrel.Mac does not preserve that environment when it relaunches, so the + * test-only successor carries `makaUpdateTestProfile: true` in package.json and + * derives the same directory from its unchanged bundle location. + */ +export function resolveUpdateTestUserDataDirectory({ + feedUrl, + explicitDirectory, + isPackaged, + appPath, + executablePath, + platform = process.platform, + read = (path, encoding) => readFileSync(path, encoding), +}: { + feedUrl?: string; + explicitDirectory?: string; + isPackaged: boolean; + appPath: string; + executablePath: string; + platform?: NodeJS.Platform; + read?: (path: string, encoding: 'utf8') => string; +}): string | undefined { + if (explicitDirectory) { + if (!resolveUpdateFeedOverride(feedUrl)) { + throw new TypeError('MAKA_UPDATE_TEST_USER_DATA_DIR requires MAKA_UPDATE_TEST_FEED'); + } + if (!isAbsolute(explicitDirectory)) { + throw new TypeError('MAKA_UPDATE_TEST_USER_DATA_DIR must be an absolute path'); + } + return explicitDirectory; + } + if (!isPackaged || platform !== 'darwin') return undefined; + + let manifest: unknown; + try { + manifest = JSON.parse(read(join(appPath, 'package.json'), 'utf8')); + } catch { + return undefined; + } + if ( + !manifest || + typeof manifest !== 'object' || + !('makaUpdateTestProfile' in manifest) || + manifest.makaUpdateTestProfile !== true + ) { + return undefined; + } + const bundle = dirname(dirname(dirname(executablePath))); + if (basename(bundle) !== 'Maka.app') { + throw new TypeError(`Update-test executable is not inside Maka.app: ${executablePath}`); + } + return join(dirname(bundle), '.maka-update-test-user-data'); +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 6b0405cd58..1780b0c4c2 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -26,6 +26,7 @@ import { import { app, clipboard, dialog, ipcMain } from 'electron'; import { join } from 'node:path'; import { resolveBuildInfo } from './build-info.js'; +import { resolveUpdateTestUserDataDirectory } from './app-update-test-context.js'; import { captureDesktopDiagnosticEnvironment, copyDesktopDiagnosticReport, @@ -56,6 +57,15 @@ installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDi // path logic. See https://github.com/maka-agent/maka-agent/issues/2252. app.setName(app.isPackaged ? 'Maka' : 'Maka Dev'); +const updateTestUserData = resolveUpdateTestUserDataDirectory({ + feedUrl: process.env.MAKA_UPDATE_TEST_FEED, + explicitDirectory: process.env.MAKA_UPDATE_TEST_USER_DATA_DIR, + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + executablePath: process.execPath, +}); +if (updateTestUserData) app.setPath('userData', updateTestUserData); + // E2E isolation: redirect userData BEFORE the single-instance lock so the // lock judges the throwaway dir, not the real user data — otherwise a // developer with Maka open makes the E2E process exit as a "second instance". diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index ce8aa89af2..961138f124 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -32,7 +32,7 @@ retaining Maka's stronger protected-Environment, staged-publishing, 2FA, and Fin ## Release invariants - Dispatch the product Release workflow only from the exact approved ASF source candidate tag. - Dispatch npm Stage only from the resulting product `v` tag and npm Finalize from `main`. + Dispatch both npm Stage and product Finalize only from the resulting product `v` tag. - Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. @@ -50,13 +50,18 @@ The two workflow boundaries are: tag and GitHub Release, checks out that exact product commit, builds and validates one immutable tarball, records that single tag commit and workflow run, enters the protected `npm-release` Environment, and submits it to npm staging through OIDC. -2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) accepts only the exact successful Stage run and attempt, then verifies the public registry bytes, signature, provenance, and dist-tag. It creates no tag or GitHub Release. +2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) accepts only the exact + successful Stage run and attempt, verifies the public registry bytes, signature, provenance, and + dist-tag, then waits at the protected `product-release` Environment. After independent Desktop + acceptance, approval makes it reverify every Draft asset and publish the GitHub Release together + with its Stable/Latest classification in one operation. ## One-time control-plane configuration ### GitHub Environment -The checked-in `.asf.yaml` is the authority for the `npm-release` Environment. After it reaches +The checked-in `.asf.yaml` is the authority for the `npm-release` and `product-release` +Environments. After it reaches `main`, confirm ASF reconciliation produced: - a selected deployment tag rule matching `v*`, with no branch rule; @@ -189,15 +194,18 @@ Do not change `next` when it already points to a newer version such as `0.2.0-be intentionally manual: npm Trusted Publishing authenticates `npm publish` and `npm stage publish`, not dist-tag mutations, and the release workflows must not gain a long-lived npm token. -## Finalize the public npm channel +## Finalize the product release After npm reports the version as public: -1. Open **Actions → Finalize CLI npm channel → Run workflow** on `main`. +1. Open **Actions → Finalize product release → Run workflow** on `v`. 2. Enter the successful Stage run ID, its exact run attempt, and the version. 3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. -4. Confirm the workflow preserved the verified public package as an Actions artifact and did not create or modify any Git tag or GitHub Release. +4. While the publication job waits for `product-release` approval, complete the product checklist's + cross-machine acceptance against the Draft. +5. Approve the Environment. Confirm the workflow redownloads and verifies every Draft artifact, + publishes it, and makes a stable release Latest without a separate manual action. Check the resulting registry state: @@ -211,8 +219,8 @@ Finally, install the exact public version on each release platform and complete turn. On the supported Eval host, complete at least one real experiment cell and inspect score, usage, cost, and artifacts. -Return to the [product release checklist](../.github/RELEASE_CHECKLIST.md) and exercise remote Runtime -Host setup from the packaged Desktop apps before publishing the GitHub Release. +The [product release checklist](../.github/RELEASE_CHECKLIST.md) remains the authority for the +acceptance evidence required before approving publication. ## Failure recovery diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 9deaa1d35f..e7d1fc083d 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -42,13 +42,16 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,记录这个唯一的 tag commit 与 workflow run,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; -2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 Stage run 和 attempt,并验证公共 registry 字节、signature、provenance 和 dist-tag;它不创建 tag 或 GitHub Release。 +2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 + Stage run 和 attempt,验证公共 registry 字节、signature、provenance 和 dist-tag,然后等待受保护的 + `product-release` Environment;独立 Desktop 验收完成并批准后,它会重新验证 Draft 的每个 + artifact,并在同一个操作中发布 GitHub Release 及其 Stable/Latest 分类。 ## 一次性控制面配置 ### GitHub Environment -仓库中的 `.asf.yaml` 是 `npm-release` Environment 的权威。该配置进入 `main` 后,确认 ASF +仓库中的 `.asf.yaml` 是 `npm-release` 和 `product-release` Environment 的权威。该配置进入 `main` 后,确认 ASF 同步出的 live 配置满足: - 使用匹配 `v*` 的 selected deployment tag rule,不配置 branch rule; @@ -171,15 +174,17 @@ npm dist-tag add "maka-agent@$version" next --registry https://registry.npmjs.or npm Trusted Publishing 只认证 `npm publish` 和 `npm stage publish`,不认证 dist-tag 变更,而 release workflow 不得获得长期 npm token。 -## Finalize 公共 npm 渠道 +## Finalize 产品发布 npm 显示该版本已经公开后: -1. 在 `main` 上打开 **Actions → Finalize CLI npm channel → Run workflow**; +1. 在 `v` 上打开 **Actions → Finalize product release → Run workflow**; 2. 输入成功 Stage 的 run ID、精确 run attempt 和 version; 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; -4. 确认 workflow 将验证后的公开包保存为 Actions artifact,且没有创建或修改任何 Git tag 或 GitHub Release。 +4. publication job 等待 `product-release` 批准期间,针对 Draft 完成产品检查清单中的跨机器验收; +5. 批准 Environment,并确认 workflow 重新下载并验证每个 Draft artifact,随后发布 Release; + stable release 会同时成为 Latest,不再需要单独人工操作。 检查最终 registry 状态: @@ -192,8 +197,7 @@ npm view maka-agent dist-tags --json 最后,在每个发布平台安装精确的公共版本,并完成一次真实的 TUI/model turn。在支持的 Eval host 上完成至少一个真实 experiment cell,检查 score、usage、cost 和 artifacts。 -回到[产品发布检查清单](../.github/RELEASE_CHECKLIST.md),使用打包后的 Desktop 应用完成远程 -Runtime Host setup 验收,再发布 GitHub Release。 +[产品发布检查清单](../.github/RELEASE_CHECKLIST.md)仍是批准发布前所需验收证据的权威。 ## 失败恢复 diff --git a/package.json b/package.json index 2d5a2528b3..fe2831a6ac 100644 --- a/package.json +++ b/package.json @@ -69,9 +69,12 @@ "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/desktop-update-contract.test.mjs scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", + "package:macos-autoupdate-next": "node scripts/package-macos-autoupdate-next.mjs", + "verify:macos-autoupdate": "node scripts/verify-macos-autoupdate.mjs", + "experiment:macos-autoupdate": "node scripts/experiment-macos-autoupdate.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", "verify:windows-x64": "node scripts/verify-windows-x64.mjs", "verify:windows-installer": "node scripts/verify-windows-installer-lifecycle.mjs", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 4e28e4b0b1..1032132b36 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -35,6 +35,7 @@ const FULL_SUITE_FILES = new Set([ ]); const RELEASE_CONTRACT_FILES = new Set([ + 'apps/desktop/src/main/app-update-test-context.ts', 'apps/desktop/build/entitlements.mac.inherit.plist', 'apps/desktop/build/entitlements.mac.plist', 'apps/desktop/bundled-tools.json', @@ -47,6 +48,7 @@ const RELEASE_CONTRACT_FILES = new Set([ '.github/workflows/release.yml', '.github/workflows/release-windows-check.yml', 'scripts/package-macos-arm64.mjs', + 'scripts/package-macos-autoupdate-next.mjs', 'scripts/package-macos-arm64-cli.mjs', 'scripts/package-windows-autoupdate-next.mjs', 'scripts/package-windows-x64.mjs', @@ -58,6 +60,10 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/release-version.mjs', 'scripts/verify-macos-arm64-cli.mjs', 'scripts/verify-macos-arm64-dmg.mjs', + 'scripts/verify-macos-autoupdate.mjs', + 'scripts/desktop-update-contract.mjs', + 'scripts/desktop-update-contract.test.mjs', + 'scripts/experiment-macos-autoupdate.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-windows-autoupdate.mjs', 'scripts/verify-windows-installer-lifecycle.mjs', diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 24a100987e..011b3faa4a 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -113,6 +113,7 @@ test('release metadata selects only the gate that consumes it', () => { test('source legal authority and generated provenance select the ASF source gate', () => { for (const path of [ + 'apps/desktop/src/main/app-update-test-context.ts', 'DISCLAIMER-WIP', 'biome.jsonc', 'patches/node-pty+1.2.0-beta.15.patch', @@ -138,6 +139,7 @@ test('release authority changes select their dedicated contract gate', () => { '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', 'scripts/package-macos-arm64.mjs', + 'scripts/package-macos-autoupdate-next.mjs', 'scripts/package-macos-arm64-cli.mjs', 'scripts/package-windows-x64.mjs', 'scripts/prepare-windows-upgrade-baseline.mjs', @@ -153,6 +155,10 @@ test('release authority changes select their dedicated contract gate', () => { 'scripts/release-cli-publication.test.mjs', 'scripts/verify-macos-arm64-cli.mjs', 'scripts/verify-macos-arm64-dmg.mjs', + 'scripts/verify-macos-autoupdate.mjs', + 'scripts/desktop-update-contract.mjs', + 'scripts/desktop-update-contract.test.mjs', + 'scripts/experiment-macos-autoupdate.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-windows-x64.mjs', 'scripts/windows-upgrade-baseline.json', diff --git a/scripts/desktop-update-contract.mjs b/scripts/desktop-update-contract.mjs new file mode 100644 index 0000000000..e9f198a34f --- /dev/null +++ b/scripts/desktop-update-contract.mjs @@ -0,0 +1,215 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { parseProductReleaseVersion } from './release-version.mjs'; + +export const DESKTOP_UPDATE_PROVIDER = Object.freeze({ + provider: 'github', + owner: 'apache', + repo: 'maka', + updaterCacheDirName: '@makadesktop-updater', +}); + +/** A stable successor lets both stable and prerelease candidates use one feed contract. */ +export function bumpedAutoupdateVersion(candidateVersion) { + const { core, prerelease } = parseProductReleaseVersion(candidateVersion); + const [major, minor, patch] = core; + return prerelease.length > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}.${patch + 1n}`; +} + +function requireExactObject(actual, expected, subject) { + const exact = + actual && + typeof actual === 'object' && + !Array.isArray(actual) && + Object.keys(actual).length === Object.keys(expected).length && + Object.entries(expected).every(([key, value]) => actual[key] === value); + if (!exact) { + throw new Error( + `${subject} is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, + ); + } +} + +/** Proves that a packaged client points at the one production release authority. */ +export async function assertPackagedUpdateConfiguration(resourcesPath, { read = readFile } = {}) { + const path = join(resourcesPath, 'app-update.yml'); + let configuration; + try { + configuration = parseYaml(await read(path, 'utf8')); + } catch (error) { + throw new Error(`Packaged update configuration is unreadable: ${path}`, { cause: error }); + } + requireExactObject(configuration, DESKTOP_UPDATE_PROVIDER, 'Packaged update configuration'); + return configuration; +} + +/** + * Validates the update metadata against the bytes that will be published. + * The release is single-platform and single-architecture, so accepting extra + * payloads here would create an unverified update path. + */ +export async function verifyDesktopUpdateArtifacts({ + directory, + metadataName, + version, + artifactName, +}) { + const metadataPath = join(directory, metadataName); + let metadata; + try { + metadata = parseYaml(await readFile(metadataPath, 'utf8')); + } catch (error) { + throw new Error(`Desktop update metadata is unreadable: ${metadataPath}`, { cause: error }); + } + if (metadata?.version !== version) { + throw new Error( + `${metadataName} advertises version ${JSON.stringify(metadata?.version)}, expected ${version}`, + ); + } + if (metadata.path !== artifactName || metadata.files?.length !== 1) { + throw new Error(`${metadataName} must advertise only ${artifactName}`); + } + const file = metadata.files[0]; + if (file?.url !== artifactName || file.sha512 !== metadata.sha512) { + throw new Error(`${metadataName} has inconsistent payload identity for ${artifactName}`); + } + const artifactPath = join(directory, artifactName); + const artifact = await stat(artifactPath); + if (!artifact.isFile()) throw new Error(`Desktop update payload is not a file: ${artifactPath}`); + const sha512 = await new Promise((resolvePromise, reject) => { + const hash = createHash('sha512'); + const stream = createReadStream(artifactPath); + stream.once('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.once('end', () => resolvePromise(hash.digest('base64'))); + }); + if (metadata.sha512 !== sha512 || file.sha512 !== sha512) { + throw new Error(`${metadataName} sha512 does not match ${artifactName}`); + } + if (file.size !== artifact.size) { + throw new Error( + `${metadataName} records ${artifactName} size ${JSON.stringify(file.size)}, expected ${artifact.size}`, + ); + } + const blockmapPath = join(directory, `${artifactName}.blockmap`); + if (!(await stat(blockmapPath)).isFile()) { + throw new Error(`Desktop update blockmap is not a file: ${blockmapPath}`); + } + return { artifactName, metadata, metadataName, version }; +} + +/** + * Exact loopback replica of the generic feed used by the packaged E2E tests. + * Mapped-but-absent files intentionally return 404: that is how the updater + * probes an unavailable previous blockmap before falling back to a full file. + */ +export async function startDesktopUpdateFeed(files) { + const requests = []; + let unexpectedRequests = 0; + const bodies = new Map(); + for (const [name, filePath] of files) { + try { + bodies.set(`/${name}`, await readFile(filePath)); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + const knownPaths = new Set([...files.keys()].map((name) => `/${name}`)); + const server = createServer((request, response) => { + const method = request.method ?? 'GET'; + const target = request.url ?? '/'; + const queryIndex = target.indexOf('?'); + const path = queryIndex === -1 ? target : target.slice(0, queryIndex); + const record = { method, path, target, status: 0 }; + requests.push(record); + if ((method !== 'GET' && method !== 'HEAD') || !knownPaths.has(path)) { + unexpectedRequests += 1; + record.status = 404; + response.writeHead(404).end(); + return; + } + const body = bodies.get(path); + if (body === undefined) { + record.status = 404; + response.writeHead(404).end(); + return; + } + const range = /^bytes=(\d+)-(\d*)$/u.exec(request.headers.range ?? ''); + if (range) { + const start = Number(range[1]); + const end = range[2] === '' ? body.length - 1 : Math.min(Number(range[2]), body.length - 1); + if (start > end || start >= body.length) { + record.status = 416; + response.writeHead(416, { 'Content-Range': `bytes */${body.length}` }).end(); + return; + } + record.status = 206; + response.writeHead(206, { + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${body.length}`, + 'Content-Type': 'application/octet-stream', + }); + response.end(method === 'HEAD' ? undefined : body.subarray(start, end + 1)); + return; + } + record.status = 200; + response.writeHead(200, { + 'Accept-Ranges': 'bytes', + 'Content-Length': body.length, + 'Content-Type': 'application/octet-stream', + }); + response.end(method === 'HEAD' ? undefined : body); + }); + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolvePromise); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Could not start the loopback update feed.'); + } + return { + url: `http://127.0.0.1:${address.port}`, + requests, + unexpectedCount: () => unexpectedRequests, + close: () => + new Promise((resolvePromise) => { + server.close(() => resolvePromise()); + server.closeAllConnections?.(); + }), + }; +} + +export function feedServed(feed, name) { + return feed.requests.some( + (request) => + request.method === 'GET' && + request.path === `/${name}` && + (request.status === 200 || request.status === 206), + ); +} diff --git a/scripts/desktop-update-contract.test.mjs b/scripts/desktop-update-contract.test.mjs new file mode 100644 index 0000000000..1ecc529cfc --- /dev/null +++ b/scripts/desktop-update-contract.test.mjs @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + assertPackagedUpdateConfiguration, + bumpedAutoupdateVersion, + startDesktopUpdateFeed, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; + +test('packaged clients have one exact production update authority', async () => { + const expected = + 'owner: apache\nrepo: maka\nprovider: github\nupdaterCacheDirName: "@makadesktop-updater"\n'; + await assertPackagedUpdateConfiguration('resources', { read: async () => expected }); + await assert.rejects( + assertPackagedUpdateConfiguration('resources', { + read: async () => expected.replace('owner: apache', 'owner: personal-fork'), + }), + /expected.*apache/u, + ); + await assert.rejects( + assertPackagedUpdateConfiguration('resources', { + read: async () => `${expected}extra: rejected\n`, + }), + /expected/u, + ); +}); + +test('the successor version follows the shared product SemVer contract', () => { + assert.equal(bumpedAutoupdateVersion('1.2.3-beta.2'), '1.2.3'); + assert.equal(bumpedAutoupdateVersion('1.2.3'), '1.2.4'); +}); + +test('update metadata is bound to the only published payload bytes', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-update-contract-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const artifactName = 'Maka-1.2.3-mac-arm64.zip'; + const bytes = Buffer.from('signed application zip'); + const sha512 = createHash('sha512').update(bytes).digest('base64'); + await Promise.all([ + writeFile(join(directory, artifactName), bytes), + writeFile(join(directory, `${artifactName}.blockmap`), 'blockmap'), + writeFile( + join(directory, 'latest-mac.yml'), + [ + 'version: 1.2.3', + 'files:', + ` - url: ${artifactName}`, + ` sha512: ${sha512}`, + ` size: ${bytes.length}`, + `path: ${artifactName}`, + `sha512: ${sha512}`, + '', + ].join('\n'), + ), + ]); + await verifyDesktopUpdateArtifacts({ + directory, + metadataName: 'latest-mac.yml', + version: '1.2.3', + artifactName, + }); + await writeFile(join(directory, artifactName), 'changed'); + await assert.rejects( + verifyDesktopUpdateArtifacts({ + directory, + metadataName: 'latest-mac.yml', + version: '1.2.3', + artifactName, + }), + /sha512 does not match/u, + ); +}); + +test('the shared update feed is exact and supports ranged transfers', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-update-feed-')); + t.after(() => rm(directory, { recursive: true, force: true })); + await mkdir(join(directory, 'unused')); + const payload = join(directory, 'payload.zip'); + await writeFile(payload, '0123456789'); + const feed = await startDesktopUpdateFeed(new Map([['payload.zip', payload]])); + t.after(() => feed.close()); + + const ranged = await fetch(`${feed.url}/payload.zip`, { headers: { Range: 'bytes=2-5' } }); + assert.equal(ranged.status, 206); + assert.equal(await ranged.text(), '2345'); + assert.equal((await fetch(`${feed.url}/nested/payload.zip`)).status, 404); + assert.equal(feed.unexpectedCount(), 1); +}); diff --git a/scripts/experiment-macos-autoupdate.mjs b/scripts/experiment-macos-autoupdate.mjs new file mode 100644 index 0000000000..168b85ccf1 --- /dev/null +++ b/scripts/experiment-macos-autoupdate.mjs @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; +import { packageMacosAutoupdateNext } from './package-macos-autoupdate-next.mjs'; +import { verifyMacosAutoupdate } from './verify-macos-autoupdate.mjs'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const desktopRoot = join(repoRoot, 'apps', 'desktop'); + +function run(command, args, { env = process.env } = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd: repoRoot, env, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolvePromise({ stdout: '', stderr: '' }); + else + reject(new Error(`${command} failed with ${signal ? `signal ${signal}` : `exit ${code}`}`)); + }); + }); +} + +export async function experimentMacosAutoupdate({ + platform = process.platform, + arch = process.arch, +} = {}) { + if (platform !== 'darwin' || arch !== 'arm64') { + throw new Error('The local macOS auto-update experiment requires Apple Silicon macOS.'); + } + const temporaryDirectory = await mkdtemp(join(tmpdir(), 'maka-update-signing-')); + const password = 'maka-local-update-test'; + const privateKey = join(temporaryDirectory, 'identity.key'); + const certificate = join(temporaryDirectory, 'identity.crt'); + const identity = join(temporaryDirectory, 'identity.p12'); + const keychain = join(temporaryDirectory, 'local-signing.keychain-db'); + const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); + const candidateZipName = `Maka-${manifest.version}-mac-arm64.zip`; + const candidateZip = join(desktopRoot, 'release', candidateZipName); + + try { + await run('openssl', [ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-sha256', + '-days', + '1', + '-nodes', + '-subj', + '/CN=Maka Local Update Test', + '-addext', + 'keyUsage=digitalSignature', + '-addext', + 'extendedKeyUsage=codeSigning', + '-keyout', + privateKey, + '-out', + certificate, + ]); + await run('openssl', [ + 'pkcs12', + '-export', + // macOS Security still imports PKCS#12 through its legacy-compatible + // cipher path; OpenSSL 3's defaults otherwise fail as a wrong password. + '-legacy', + '-inkey', + privateKey, + '-in', + certificate, + '-name', + 'Maka Local Update Test', + '-passout', + `pass:${password}`, + '-out', + identity, + ]); + await run('security', ['create-keychain', '-p', password, keychain]); + await run('security', ['unlock-keychain', '-p', password, keychain]); + await run('security', ['set-keychain-settings', '-lut', '21600', keychain]); + await run('security', [ + 'import', + identity, + '-k', + keychain, + '-P', + password, + '-T', + '/usr/bin/codesign', + '-T', + '/usr/bin/productbuild', + ]); + await run('security', [ + 'set-key-partition-list', + '-S', + 'apple-tool:,apple:', + '-s', + '-k', + password, + keychain, + ]); + await run('security', [ + 'add-trusted-cert', + '-r', + 'trustRoot', + '-p', + 'codeSign', + '-k', + keychain, + certificate, + ]); + await run('security', ['find-identity', '-v', '-p', 'codesigning', keychain]); + const env = { + ...process.env, + CSC_KEYCHAIN: keychain, + MAKA_LOCAL_UPDATE_SIGNING_IDENTITY: 'Maka Local Update Test', + }; + delete env.CSC_LINK; + delete env.CSC_KEY_PASSWORD; + + console.log('[experiment-macos-autoupdate] building the application once'); + await run('npm', ['run', 'clean'], { env }); + await run('npm', ['run', 'build'], { env }); + await run('npm', ['run', 'build:runtime-host-peer'], { env }); + await run('npm', ['run', 'check:runtime-host-peer-notices'], { env }); + await rm(join(desktopRoot, 'release'), { recursive: true, force: true }); + + console.log(`[experiment-macos-autoupdate] packaging signed candidate ${manifest.version}`); + await run( + 'npm', + [ + '--workspace', + '@maka/desktop', + 'exec', + '--', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + '--mac', + 'zip', + '--arm64', + '--publish', + 'never', + '-c.mac.notarize=false', + '-c.mac.identity=Maka Local Update Test', + '-c.mac.timestamp=none', + // A self-signed identity has no Apple Team ID, so macOS library + // validation cannot relate Electron's nested signatures to the app. + // Production and release E2E builds keep hardenedRuntime enabled and + // use the real Developer ID identity. + '-c.mac.hardenedRuntime=false', + ], + { env }, + ); + await verifyDesktopUpdateArtifacts({ + directory: join(desktopRoot, 'release'), + metadataName: 'latest-mac.yml', + version: manifest.version, + artifactName: candidateZipName, + }); + + console.log('[experiment-macos-autoupdate] packaging the signed successor'); + await packageMacosAutoupdateNext({ run, env }); + return await verifyMacosAutoupdate(candidateZip, join(desktopRoot, 'release-autoupdate-next')); + } finally { + await run('security', ['remove-trusted-cert', certificate]).catch(() => {}); + await run('security', ['delete-keychain', keychain]).catch(() => {}); + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const result = await experimentMacosAutoupdate(); + console.log(JSON.stringify(result, null, 2)); +} diff --git a/scripts/package-macos-autoupdate-next.mjs b/scripts/package-macos-autoupdate-next.mjs new file mode 100644 index 0000000000..169734599c --- /dev/null +++ b/scripts/package-macos-autoupdate-next.mjs @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { access, readFile, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + bumpedAutoupdateVersion, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; +import { runCommand } from './verify-packaged-app.mjs'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const desktopRoot = join(repoRoot, 'apps', 'desktop'); + +/** Builds only the signed ZIP needed to prove the candidate's next update. */ +export async function packageMacosAutoupdateNext({ + platform = process.platform, + arch = process.arch, + run = runCommand, + env = process.env, +} = {}) { + if (platform !== 'darwin' || arch !== 'arm64') { + throw new Error('The macOS auto-update build requires an Apple Silicon macOS host.'); + } + const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); + const nextVersion = bumpedAutoupdateVersion(manifest.version); + const outputDirectory = join(desktopRoot, 'release-autoupdate-next'); + const zipName = `Maka-${nextVersion}-mac-arm64.zip`; + + await access(join(desktopRoot, 'dist')); + await access(join(desktopRoot, 'release', `Maka-${manifest.version}-mac-arm64.zip`)); + await rm(outputDirectory, { recursive: true, force: true }); + + const args = [ + '--workspace', + '@maka/desktop', + 'exec', + '--', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + '--mac', + 'zip', + '--arm64', + '--publish', + 'never', + `-c.extraMetadata.version=${nextVersion}`, + '-c.extraMetadata.makaUpdateTestProfile=true', + '-c.mac.notarize=false', + '-c.directories.output=release-autoupdate-next', + ]; + if (env.MAKA_LOCAL_UPDATE_SIGNING_IDENTITY) { + args.push(`-c.mac.identity=${env.MAKA_LOCAL_UPDATE_SIGNING_IDENTITY}`); + args.push('-c.mac.timestamp=none'); + args.push('-c.mac.hardenedRuntime=false'); + } + await run('npm', args, { cwd: repoRoot, env }); + + await verifyDesktopUpdateArtifacts({ + directory: outputDirectory, + metadataName: 'latest-mac.yml', + version: nextVersion, + artifactName: zipName, + }); + await rm(join(outputDirectory, 'mac-arm64'), { recursive: true, force: true }); + return { + version: nextVersion, + zipPath: join(outputDirectory, zipName), + metadataPath: join(outputDirectory, 'latest-mac.yml'), + blockmapPath: join(outputDirectory, `${zipName}.blockmap`), + directory: outputDirectory, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + console.log(JSON.stringify(await packageMacosAutoupdateNext())); +} diff --git a/scripts/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index e23461a557..c48b783ab6 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -17,12 +17,14 @@ * under the License. */ -import { createHash } from 'node:crypto'; import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { runCommand } from './package-windows-x64.mjs'; -import { parseProductReleaseVersion } from './release-version.mjs'; +import { + bumpedAutoupdateVersion, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); @@ -33,22 +35,6 @@ const desktopRoot = join(repoRoot, 'apps', 'desktop'); * channel behavior: a prerelease advances to its stable core, while a stable * candidate advances to the next patch. */ -export function bumpedAutoupdateVersion(candidateVersion) { - const { core, prerelease } = parseProductReleaseVersion(candidateVersion); - const [major, minor, patch] = core; - return prerelease.length > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}.${patch + 1n}`; -} - -/** - * Minimal parser for the handful of flat keys the assertions need. electron- - * builder writes plain `key: value` lines for these; a format drift makes the - * assertions below fail loudly rather than letting a malformed feed pass. - */ -function readFlatYamlValue(source, key) { - const match = new RegExp(`^${key}:\\s*(.+)\\s*$`, 'm').exec(source); - return match ? match[1].trim() : undefined; -} - /** * Build the version-bumped NSIS installer the auto-update harness serves as * the "next" release. @@ -104,26 +90,12 @@ export async function packageWindowsAutoupdateNext({ // Assert the properties the harness depends on, not just process exit 0. await access(exePath); await access(blockmapPath); - const latestYml = await readFile(latestYmlPath, 'utf8'); - const feedVersion = readFlatYamlValue(latestYml, 'version'); - if (feedVersion !== nextVersion) { - throw new Error( - `latest.yml advertises version ${JSON.stringify(feedVersion)}, expected ${nextVersion}`, - ); - } - const feedPath = readFlatYamlValue(latestYml, 'path'); - if (feedPath !== exeName) { - throw new Error(`latest.yml path points at ${JSON.stringify(feedPath)}, expected ${exeName}`); - } - const feedSha512 = readFlatYamlValue(latestYml, 'sha512'); - const actualSha512 = createHash('sha512') - .update(await readFile(exePath)) - .digest('base64'); - if (feedSha512 !== actualSha512) { - throw new Error( - 'latest.yml sha512 does not match the built installer; the feed would fail integrity checks', - ); - } + await verifyDesktopUpdateArtifacts({ + directory: outputDirectory, + metadataName: 'latest.yml', + version: nextVersion, + artifactName: exeName, + }); return { version: nextVersion, exePath, latestYmlPath, blockmapPath, directory: outputDirectory }; } diff --git a/scripts/product-release-artifacts.mjs b/scripts/product-release-artifacts.mjs index e8e66d6967..252c29d211 100644 --- a/scripts/product-release-artifacts.mjs +++ b/scripts/product-release-artifacts.mjs @@ -17,9 +17,12 @@ * under the License. */ -import { copyFile, mkdir, readdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { copyFile, mkdir, readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; import { readProductReleaseIdentity } from './product-release-identity.mjs'; export function assertExactArtifactSet(actualNames, expectedNames) { @@ -71,6 +74,48 @@ export async function verifyProductReleaseArtifactDirectory(directory, expectedN return assertExactArtifactSet(await regularFileNames(directory), expectedNames); } +function digestFile(path, algorithm = 'sha256') { + return new Promise((resolvePromise, reject) => { + const hash = createHash(algorithm); + const stream = createReadStream(path); + stream.once('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.once('end', () => resolvePromise(hash.digest('hex'))); + }); +} + +export async function verifyProductReleaseArtifactIntegrity(directory, identity) { + await verifyProductReleaseArtifactDirectory(directory, allArtifactNames(identity)); + const checksumNames = allArtifactNames(identity).filter((name) => name.endsWith('.sha256')); + for (const checksumName of checksumNames) { + const artifactName = checksumName.slice(0, -'.sha256'.length); + const source = await readFile(join(directory, checksumName), 'utf8'); + const match = /^([0-9a-f]{64}) {2}([^\r\n]+)\r?\n?$/u.exec(source); + if (!match || match[2] !== artifactName) { + throw new Error(`Product release checksum is malformed: ${checksumName}`); + } + const digest = await digestFile(join(directory, artifactName)); + if (digest !== match[1]) { + throw new Error(`Product release checksum does not match: ${artifactName}`); + } + } + await Promise.all([ + verifyDesktopUpdateArtifacts({ + directory, + metadataName: 'latest-mac.yml', + version: identity.version, + artifactName: `Maka-${identity.version}-mac-arm64.zip`, + }), + verifyDesktopUpdateArtifacts({ + directory, + metadataName: 'latest.yml', + version: identity.version, + artifactName: identity.exe, + }), + ]); + return allArtifactNames(identity); +} + function allArtifactNames(identity) { return Object.values(identity.artifacts).flat(); } @@ -93,8 +138,8 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) if (!directory) { throw new Error('usage: product-release-artifacts.mjs verify '); } - await verifyProductReleaseArtifactDirectory(directory, allArtifactNames(identity)); - console.log(`Verified exact product release artifacts in ${directory}`); + await verifyProductReleaseArtifactIntegrity(directory, identity); + console.log(`Verified exact product release artifact bytes in ${directory}`); } else if (command === 'list' && args.length === 0) { console.log(JSON.stringify(identity.artifacts, null, 2)); } else { diff --git a/scripts/product-release-artifacts.test.mjs b/scripts/product-release-artifacts.test.mjs index 7a2f9877db..2552b34494 100644 --- a/scripts/product-release-artifacts.test.mjs +++ b/scripts/product-release-artifacts.test.mjs @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +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'; @@ -25,8 +26,23 @@ import test from 'node:test'; import { assertExactArtifactSet, stageProductReleaseArtifactGroup, + verifyProductReleaseArtifactIntegrity, } from './product-release-artifacts.mjs'; +function updateMetadata(version, artifactName, bytes) { + const sha512 = createHash('sha512').update(bytes).digest('base64'); + return [ + `version: ${version}`, + 'files:', + ` - url: ${artifactName}`, + ` sha512: ${sha512}`, + ` size: ${bytes.length}`, + `path: ${artifactName}`, + `sha512: ${sha512}`, + '', + ].join('\n'); +} + test('product release artifact validation rejects missing and unexpected files', () => { assert.deepEqual(assertExactArtifactSet(['b.zip', 'a.dmg'], ['a.dmg', 'b.zip']), [ 'a.dmg', @@ -81,3 +97,46 @@ test('artifact staging refuses to replace an existing target directory', async ( ); assert.equal(await readFile(join(targetDirectory, 'keep.txt'), 'utf8'), 'keep'); }); + +test('final artifact verification binds checksums and both update channels to exact bytes', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-release-integrity-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const version = '1.2.3'; + const macZip = `Maka-${version}-mac-arm64.zip`; + const exe = `Maka-${version}-win-x64.exe`; + const manual = 'manual.txt'; + const macBytes = Buffer.from('mac application'); + const windowsBytes = Buffer.from('windows application'); + const manualBytes = Buffer.from('release notes'); + const names = [ + macZip, + `${macZip}.blockmap`, + 'latest-mac.yml', + exe, + `${exe}.blockmap`, + 'latest.yml', + manual, + `${manual}.sha256`, + ]; + await Promise.all([ + writeFile(join(directory, macZip), macBytes), + writeFile(join(directory, `${macZip}.blockmap`), 'mac blockmap'), + writeFile(join(directory, 'latest-mac.yml'), updateMetadata(version, macZip, macBytes)), + writeFile(join(directory, exe), windowsBytes), + writeFile(join(directory, `${exe}.blockmap`), 'windows blockmap'), + writeFile(join(directory, 'latest.yml'), updateMetadata(version, exe, windowsBytes)), + writeFile(join(directory, manual), manualBytes), + writeFile( + join(directory, `${manual}.sha256`), + `${createHash('sha256').update(manualBytes).digest('hex')} ${manual}\n`, + ), + ]); + const identity = { version, exe, artifacts: { test: names } }; + + assert.deepEqual(await verifyProductReleaseArtifactIntegrity(directory, identity), names); + await writeFile(join(directory, `${manual}.sha256`), `${'0'.repeat(64)} ${manual}\n`); + await assert.rejects( + verifyProductReleaseArtifactIntegrity(directory, identity), + /checksum does not match/u, + ); +}); diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs index eff55e0687..05d93fccc4 100644 --- a/scripts/product-release-authority.mjs +++ b/scripts/product-release-authority.mjs @@ -18,6 +18,10 @@ */ import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { parseProductTag, remoteProductTagCommit } from './product-release-tag.mjs'; @@ -43,6 +47,49 @@ export function assertDraftProductRelease(release, tag) { return release; } +export function assertPublishedProductRelease(release, tag) { + const expected = expectedReleaseIdentity(tag); + if (!release || release.tagName !== tag || release.isDraft !== false) { + throw new Error(`GitHub Release ${tag} was not published`); + } + if (release.isPrerelease !== expected.isPrerelease) { + throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); + } + return release; +} + +function digestFile(path) { + return new Promise((resolvePromise, reject) => { + const hash = createHash('sha256'); + const stream = createReadStream(path); + stream.once('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.once('end', () => resolvePromise(hash.digest('hex'))); + }); +} + +async function localAssetRecords(directory) { + const records = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (!entry.isFile()) throw new Error(`Release artifact must be a regular file: ${entry.name}`); + const path = join(directory, entry.name); + const [details, digest] = await Promise.all([stat(path), digestFile(path)]); + records.push({ name: entry.name, size: details.size, digest: `sha256:${digest}` }); + } + return records.sort((left, right) => left.name.localeCompare(right.name)); +} + +export async function assertGitHubReleaseAssetDigests({ release, artifactDirectory }) { + const local = await localAssetRecords(artifactDirectory); + const remote = (release?.assets ?? []) + .map(({ name, size, digest }) => ({ name, size, digest })) + .sort((left, right) => left.name.localeCompare(right.name)); + if (JSON.stringify(remote) !== JSON.stringify(local)) { + throw new Error('Draft GitHub Release assets do not match the locally verified artifact bytes'); + } + return local; +} + export async function verifyDraftProductRelease({ tag, sourceCommit, @@ -73,7 +120,15 @@ export async function verifyDraftProductRelease({ const release = await run( 'gh', - ['release', 'view', tag, '--repo', repository, '--json', 'tagName,isDraft,isPrerelease'], + [ + 'release', + 'view', + tag, + '--repo', + repository, + '--json', + 'databaseId,tagName,isDraft,isPrerelease', + ], { cwd }, ); let parsedRelease; @@ -85,14 +140,112 @@ export async function verifyDraftProductRelease({ return assertDraftProductRelease(parsedRelease, tag); } +export async function publishDraftProductRelease({ + tag, + sourceCommit, + repository, + artifactDirectory, + cwd = process.cwd(), + run = execFileAsync, + pause = (milliseconds) => + new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)), +}) { + const draft = await verifyDraftProductRelease({ tag, sourceCommit, repository, cwd, run }); + const response = await run('gh', ['api', `repos/${repository}/releases/tags/${tag}`], { cwd }); + let live; + try { + live = JSON.parse(response.stdout); + } catch (error) { + throw new Error(`GitHub returned an invalid live Release record for ${tag}`, { cause: error }); + } + if ( + live.tag_name !== tag || + live.draft !== true || + live.prerelease !== expectedReleaseIdentity(tag).isPrerelease || + live.id !== draft.databaseId + ) { + throw new Error(`GitHub Release ${tag} changed during finalization`); + } + await assertGitHubReleaseAssetDigests({ release: live, artifactDirectory }); + + const isPrerelease = expectedReleaseIdentity(tag).isPrerelease; + const published = await run( + 'gh', + [ + 'api', + '--method', + 'PATCH', + `repos/${repository}/releases/${live.id}`, + '-F', + 'draft=false', + '-F', + `prerelease=${isPrerelease}`, + '-f', + `make_latest=${isPrerelease ? 'false' : 'true'}`, + ], + { cwd }, + ); + let record; + try { + const value = JSON.parse(published.stdout); + record = { + tagName: value.tag_name, + isDraft: value.draft, + isPrerelease: value.prerelease, + }; + } catch (error) { + throw new Error(`GitHub returned an invalid publication result for ${tag}`, { cause: error }); + } + assertPublishedProductRelease(record, tag); + + if (!isPrerelease) { + let latestTag; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const latest = await run('gh', ['api', `repos/${repository}/releases/latest`], { cwd }); + latestTag = JSON.parse(latest.stdout).tag_name; + } catch (error) { + if (attempt === 4) { + throw new Error('GitHub returned an invalid Latest release record', { cause: error }); + } + } + if (latestTag === tag) break; + if (attempt < 4) await pause(1_000); + } + if (latestTag !== tag) { + throw new Error(`Stable release ${tag} was published but Latest points to ${latestTag}`); + } + } + return record; +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [command, tag, sourceCommit, repository = process.env.GITHUB_REPOSITORY] = - process.argv.slice(2); - if (command !== 'verify-draft' || !tag || !sourceCommit || !repository) { + const [ + command, + tag, + sourceCommit, + repository = process.env.GITHUB_REPOSITORY, + artifactDirectory, + ] = process.argv.slice(2); + if (!tag || !sourceCommit || !repository) { + throw new Error( + 'usage: product-release-authority.mjs [artifact-directory]', + ); + } + if (command === 'verify-draft' && !artifactDirectory) { + await verifyDraftProductRelease({ tag, sourceCommit, repository }); + console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); + } else if (command === 'publish-draft' && artifactDirectory) { + await publishDraftProductRelease({ + tag, + sourceCommit, + repository, + artifactDirectory, + }); + console.log(`Published product Release ${tag} from ${sourceCommit}`); + } else { throw new Error( - 'usage: product-release-authority.mjs verify-draft ', + 'usage: product-release-authority.mjs [artifact-directory]', ); } - await verifyDraftProductRelease({ tag, sourceCommit, repository }); - console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); } diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs index 0fb14cf3c5..842aae8fc1 100644 --- a/scripts/product-release-authority.test.mjs +++ b/scripts/product-release-authority.test.mjs @@ -18,9 +18,15 @@ */ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; import { assertDraftProductRelease, + assertPublishedProductRelease, + publishDraftProductRelease, verifyDraftProductRelease, } from './product-release-authority.mjs'; @@ -52,6 +58,24 @@ test('Draft state and prerelease classification are one product Release contract ); }); +test('published state keeps stable and prerelease classification exact', () => { + assert.equal( + assertPublishedProductRelease( + { tagName: 'v1.2.3', isDraft: false, isPrerelease: false }, + 'v1.2.3', + ).tagName, + 'v1.2.3', + ); + assert.throws( + () => + assertPublishedProductRelease( + { tagName: 'v1.2.3-beta.1', isDraft: false, isPrerelease: false }, + 'v1.2.3-beta.1', + ), + /prerelease state/u, + ); +}); + test('the live authority verifier binds the tag, main ancestry, and Draft Release', async () => { const sourceCommit = 'a'.repeat(40); const calls = []; @@ -88,7 +112,10 @@ test('the live authority verifier binds the tag, main ancestry, and Draft Releas ['gh', 'release'], ], ); - assert.deepEqual(calls.at(-1)[1].slice(-2), ['--json', 'tagName,isDraft,isPrerelease']); + assert.deepEqual(calls.at(-1)[1].slice(-2), [ + '--json', + 'databaseId,tagName,isDraft,isPrerelease', + ]); }); test('the live authority verifier rejects product tag drift before later checks', async () => { @@ -107,3 +134,71 @@ test('the live authority verifier rejects product tag drift before later checks' ); assert.equal(calls.length, 1); }); + +test('publication verifies live asset digests before one Stable/Latest mutation', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-publish-authority-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const contents = Buffer.from('verified artifact'); + await writeFile(join(directory, 'artifact.zip'), contents); + const digest = `sha256:${createHash('sha256').update(contents).digest('hex')}`; + const sourceCommit = 'a'.repeat(40); + const calls = []; + let latestReads = 0; + const run = async (command, args) => { + calls.push([command, args]); + if (command === 'git' && args[0] === 'ls-remote') { + return { stdout: `${sourceCommit}\trefs/tags/v1.2.3\n` }; + } + if (command === 'gh' && args[0] === 'release') { + return { + stdout: JSON.stringify({ + databaseId: 42, + tagName: 'v1.2.3', + isDraft: true, + isPrerelease: false, + }), + }; + } + if (command === 'gh' && args.includes('repos/apache/maka/releases/tags/v1.2.3')) { + return { + stdout: JSON.stringify({ + id: 42, + tag_name: 'v1.2.3', + draft: true, + prerelease: false, + assets: [{ name: 'artifact.zip', size: contents.length, digest }], + }), + }; + } + if (command === 'gh' && args.includes('PATCH')) { + return { + stdout: JSON.stringify({ tag_name: 'v1.2.3', draft: false, prerelease: false }), + }; + } + if (command === 'gh' && args.includes('repos/apache/maka/releases/latest')) { + latestReads += 1; + return { stdout: JSON.stringify({ tag_name: latestReads === 1 ? 'v1.2.2' : 'v1.2.3' }) }; + } + return { stdout: '' }; + }; + + await publishDraftProductRelease({ + tag: 'v1.2.3', + sourceCommit, + repository: 'apache/maka', + artifactDirectory: directory, + run, + pause: async () => {}, + }); + + const patchCall = calls.find(([, args]) => args.includes('PATCH')); + assert.ok(patchCall); + assert.ok(patchCall[1].includes('draft=false')); + assert.ok(patchCall[1].includes('prerelease=false')); + assert.ok(patchCall[1].includes('make_latest=true')); + assert.ok( + calls.findIndex(([, args]) => args.includes('repos/apache/maka/releases/tags/v1.2.3')) < + calls.indexOf(patchCall), + ); + assert.equal(latestReads, 2); +}); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 4a8bfc4fd6..71bab3451a 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -233,7 +233,7 @@ test('platform package verifiers keep Git checks out of current artifacts', asyn ); assert.match( windowsSource, - /if \(requiresCurrentContract\) await assertPackagedDependencyClosure\(resources\);\s*else await requirePath\(join\(resources, ['"]git['"]/u, + /if \(requiresCurrentContract\) \{\s*await assertPackagedUpdateConfiguration\(resources\);\s*await assertPackagedDependencyClosure\(resources\);\s*\}\s*else await requirePath\(join\(resources, ['"]git['"]/u, ); const macosSource = await readFile( @@ -653,7 +653,9 @@ test('one product workflow gates one draft release on every required artifact', assert.ok(uploadIndex >= 0); for (const verifier of [ 'Verify the final DMG', + 'Verify macOS automatic update end to end', 'Verify the Windows release', + 'Verify automatic update end to end', 'Prove deterministic mid-install failure rollback', ]) { const verifierIndex = desktopStepNames.indexOf(verifier); @@ -687,6 +689,8 @@ test('one product workflow gates one draft release on every required artifact', assert.equal(jobs.desktop['timeout-minutes'], 75); assert.match(commands, /npm run package:windows-autoupdate-next/u); assert.match(commands, /npm run verify:windows-autoupdate/u); + assert.match(commands, /npm run package:macos-autoupdate-next/u); + assert.match(commands, /npm run verify:macos-autoupdate/u); assert.match(commands, /npm run verify:windows-installer-rollback/u); assert.match(commands, /product-release-tag\.mjs ensure/u); assert.doesNotMatch(commands, /RECOVERY_SOURCE|inputs\.source_commit/u); @@ -729,6 +733,7 @@ test('repository control plane admits only reviewed immutable release tags', asy for (const [name, tagPattern] of [ ['release', 'v*-incubating-rc*'], ['npm-release', 'v*'], + ['product-release', 'v*'], ]) { assert.deepEqual(environments[name], { required_reviewers: [{ id: 'M4n5ter', type: 'User' }], diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 1144dc2458..d72fe63e20 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -87,7 +87,7 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); -test('finalize validates one exact stage attempt before running the current verifier', () => { +test('finalize validates one exact stage attempt from the product tag', () => { const workflow = readWorkflow('release-cli-finalize.yml'); const steps = workflowSteps(workflow); assert.match(workflow, /stage_run_attempt:[\s\S]*?required: true/u); @@ -97,6 +97,8 @@ test('finalize validates one exact stage attempt before running the current veri assert.match(workflow, /actions\/runs\/\$STAGE_RUN_ID\/attempts\/\$STAGE_RUN_ATTEMPT/u); const checkout = namedStep(steps, 'Check out the current release verifier'); assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); + const exactTag = namedStep(steps, 'Require the exact product tag'); + assert.match(exactTag, /refs\/tags\/\$PRODUCT_TAG/u); }); test('finalize revalidates the live product release before trusting public npm bytes', () => { @@ -112,11 +114,16 @@ test('finalize revalidates the live product release before trusting public npm b ); }); -test('finalize preserves verified npm bytes without creating another product release', () => { +test('finalize preserves npm evidence and owns the single product publication boundary', () => { const workflow = readWorkflow('release-cli-finalize.yml'); assert.match(workflow, /name: Preserve the verified public npm package/u); assert.match(workflow, /path: \$\{\{ runner\.temp \}\}\/registry-release/u); - assert.doesNotMatch(workflow, /cli-v|contents: write/u); + assert.doesNotMatch(workflow, /cli-v/u); + assert.match(workflow, /name: product-release/u); + assert.match(workflow, /contents: write/u); + assert.match(workflow, /product-release-artifacts\.mjs verify/u); + assert.match(workflow, /product-release-authority\.mjs publish-draft/u); + assert.doesNotMatch(workflow, /gh release edit|gh release create/u); }); test('release workflows select npm from the root packageManager authority', () => { diff --git a/scripts/verify-macos-arm64-dmg.mjs b/scripts/verify-macos-arm64-dmg.mjs index 2608beb9b1..d3c9fa77f1 100644 --- a/scripts/verify-macos-arm64-dmg.mjs +++ b/scripts/verify-macos-arm64-dmg.mjs @@ -32,6 +32,7 @@ import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION } from '../packages/runtime/dist/filesystem-worker/protocol.js'; import { readProductManifestIdentity } from './product-release-identity.mjs'; +import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -147,6 +148,7 @@ export async function verifyPackagedMacApp( await requirePath(executable); await assertPackagedResources(resources, { requirePath, forbidPath }); + await assertPackagedUpdateConfiguration(resources); await assertPackagedDependencyClosure(resources); const executableArchitectures = await run('lipo', ['-archs', executable]); diff --git a/scripts/verify-macos-autoupdate.mjs b/scripts/verify-macos-autoupdate.mjs new file mode 100644 index 0000000000..4a548bb790 --- /dev/null +++ b/scripts/verify-macos-autoupdate.mjs @@ -0,0 +1,330 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { access, mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parse as parseYaml } from 'yaml'; +import { + assertPackagedUpdateConfiguration, + feedServed, + startDesktopUpdateFeed, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; +import { + evaluateInRenderer, + findRendererTarget, + isolatedUserEnv, + runCommand, + smokePackagedRenderer, + stopChild, + waitForDevToolsPort, + waitForUsableRenderer, +} from './verify-packaged-app.mjs'; +import { compareProductReleaseVersions } from './release-version.mjs'; + +const delay = (milliseconds) => + new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +const step = (label) => console.log(`[verify-macos-autoupdate] ${label}`); +const { extractFile } = createRequire(import.meta.url)('@electron/asar'); + +function packagedManifest(appPath) { + const archive = join(appPath, 'Contents', 'Resources', 'app.asar'); + return JSON.parse(extractFile(archive, 'package.json').toString('utf8')); +} + +async function readBundleVersion(appPath, run) { + const { stdout } = await run('plutil', [ + '-extract', + 'CFBundleShortVersionString', + 'raw', + '-o', + '-', + join(appPath, 'Contents', 'Info.plist'), + ]); + return stdout.trim(); +} + +async function signingRequirement(appPath, run) { + const { stdout, stderr } = await run('codesign', ['-d', '-r-', appPath]); + const requirement = `${stdout}\n${stderr}` + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line.startsWith('designated =>')); + if (!requirement) throw new Error(`Could not read the signing requirement from ${appPath}`); + return requirement; +} + +async function waitForExit(child, timeoutMs) { + if (child.exitCode !== null) return; + const exited = await Promise.race([ + new Promise((resolvePromise) => child.once('exit', resolvePromise)).then(() => true), + delay(timeoutMs).then(() => false), + ]); + if (!exited) + throw new Error(`Candidate process ${child.pid} did not exit for the update handoff.`); +} + +async function processIdsForExecutable(executable, run) { + const { stdout } = await run('ps', ['-axo', 'pid=,command=']); + return ( + stdout + .split(/\r?\n/u) + .map((line) => /^(\s*\d+)\s+(.+)$/u.exec(line)) + // Electron child processes reuse the app executable with extra arguments. + // Only the argument-free command is the application Squirrel relaunched. + .filter((match) => match?.[2] === executable) + .map((match) => Number(match[1])) + ); +} + +function processExists(processId) { + try { + process.kill(processId, 0); + return true; + } catch (error) { + if (error?.code === 'ESRCH') return false; + throw error; + } +} + +async function stopProcess(processId) { + if (!processExists(processId)) return; + process.kill(processId, 'SIGTERM'); + const deadline = Date.now() + 5_000; + while (processExists(processId) && Date.now() < deadline) await delay(100); + if (!processExists(processId)) return; + process.kill(processId, 'SIGKILL'); + while (processExists(processId) && Date.now() < deadline + 5_000) await delay(100); + if (processExists(processId)) throw new Error(`Updated process ${processId} did not exit.`); +} + +/** check → download → Squirrel.Mac replacement → automatic relaunch → smoke. */ +export async function verifyMacosAutoupdate( + candidateInput, + nextDirectoryInput, + { + platform = process.platform, + arch = process.arch, + run = runCommand, + makeTemporaryDirectory = () => mkdtemp(join(tmpdir(), 'maka-macos-autoupdate-')), + smokeRenderer = smokePackagedRenderer, + } = {}, +) { + if (platform !== 'darwin' || arch !== 'arm64') { + throw new Error('macOS auto-update verification requires an Apple Silicon macOS host.'); + } + if (!candidateInput || !nextDirectoryInput) { + throw new Error( + 'Usage: npm run verify:macos-autoupdate -- ', + ); + } + + const candidateZip = resolve(candidateInput); + const nextDirectory = resolve(nextDirectoryInput); + await access(candidateZip); + const metadata = parseYaml(await readFile(join(nextDirectory, 'latest-mac.yml'), 'utf8')); + const nextVersion = metadata?.version; + const nextZipName = metadata?.path; + if (typeof nextVersion !== 'string' || typeof nextZipName !== 'string') { + throw new Error(`latest-mac.yml in ${nextDirectory} has no update identity.`); + } + await verifyDesktopUpdateArtifacts({ + directory: nextDirectory, + metadataName: 'latest-mac.yml', + version: nextVersion, + artifactName: nextZipName, + }); + + const temporaryDirectory = await makeTemporaryDirectory(); + const candidateRoot = join(temporaryDirectory, 'candidate'); + const nextRoot = join(temporaryDirectory, 'next'); + const candidateApp = join(candidateRoot, 'Maka.app'); + const nextApp = join(nextRoot, 'Maka.app'); + let executable = join(candidateApp, 'Contents', 'MacOS', 'Maka'); + let feed; + let child; + let relaunchedPid; + let primaryError; + + try { + await Promise.all([mkdir(candidateRoot), mkdir(nextRoot)]); + step('extracting and verifying both signed application bundles'); + await run('ditto', ['-x', '-k', candidateZip, candidateRoot]); + await run('ditto', ['-x', '-k', join(nextDirectory, nextZipName), nextRoot]); + await Promise.all([ + run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', candidateApp]), + run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', nextApp]), + assertPackagedUpdateConfiguration(join(candidateApp, 'Contents', 'Resources')), + assertPackagedUpdateConfiguration(join(nextApp, 'Contents', 'Resources')), + ]); + if (packagedManifest(candidateApp).makaUpdateTestProfile !== undefined) { + throw new Error('The production candidate must not carry the update-test profile marker.'); + } + if (packagedManifest(nextApp).makaUpdateTestProfile !== true) { + throw new Error('The test successor must carry the isolated relaunch profile marker.'); + } + // macOS reports /private/var in process commands even when mkdtemp handed + // the harness /var. Compare the filesystem identity, not either spelling. + executable = await realpath(executable); + const [candidateRequirement, nextRequirement] = await Promise.all([ + signingRequirement(candidateApp, run), + signingRequirement(nextApp, run), + ]); + if (candidateRequirement !== nextRequirement) { + throw new Error('Candidate and next app do not have the same macOS signing identity.'); + } + const candidateVersion = await readBundleVersion(candidateApp, run); + if (compareProductReleaseVersions(nextVersion, candidateVersion) <= 0) { + throw new Error(`The served version ${nextVersion} is not newer than ${candidateVersion}.`); + } + + feed = await startDesktopUpdateFeed( + new Map([ + ['latest-mac.yml', join(nextDirectory, 'latest-mac.yml')], + [nextZipName, join(nextDirectory, nextZipName)], + [`${nextZipName}.blockmap`, join(nextDirectory, `${nextZipName}.blockmap`)], + [`${basename(candidateZip)}.blockmap`, `${candidateZip}.blockmap`], + ]), + ); + + const home = join(temporaryDirectory, 'home'); + const userData = join(candidateRoot, '.maka-update-test-user-data'); + await Promise.all([mkdir(home), mkdir(userData)]); + const childEnv = { + ...process.env, + ...isolatedUserEnv(home), + MAKA_SKIP_SHELL_ENV: '1', + MAKA_UPDATE_TEST_FEED: feed.url, + MAKA_UPDATE_TEST_USER_DATA_DIR: userData, + }; + delete childEnv.MAKA_UPDATE_MOCK_VERSION; + delete childEnv.MAKA_UPDATE_MOCK_STATE; + step(`launching candidate ${candidateVersion} against the loopback feed`); + child = spawn( + executable, + ['--remote-debugging-port=0', `--user-data-dir=${userData}`, '--enable-logging=stderr'], + { cwd: temporaryDirectory, env: childEnv, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-32_768); + }); + const port = await waitForDevToolsPort(child); + const target = await findRendererTarget(port, child); + await waitForUsableRenderer(target.webSocketDebuggerUrl, child, { + description: 'Candidate renderer', + }); + + step('driving the real update check and download'); + await evaluateInRenderer(target.webSocketDebuggerUrl, 'window.maka.app.checkForUpdates()', { + awaitPromise: true, + timeoutMs: 30_000, + }); + const downloadDeadline = Date.now() + 180_000; + let status; + for (;;) { + status = await evaluateInRenderer( + target.webSocketDebuggerUrl, + 'window.maka.app.updateStatus()', + { awaitPromise: true }, + ); + if (status?.state === 'error') { + throw new Error(`Update ${status.operation ?? 'flow'} failed: ${status.message}`); + } + if (status?.state === 'downloaded') break; + if (child.exitCode !== null) throw new Error(`Candidate exited during download.\n${stderr}`); + if (Date.now() >= downloadDeadline) { + throw new Error(`Update never reached downloaded: ${JSON.stringify(status)}\n${stderr}`); + } + await delay(500); + } + if (status.currentVersion !== candidateVersion || status.latestVersion !== nextVersion) { + throw new Error( + `Downloaded update reported ${status.currentVersion} -> ${status.latestVersion}, ` + + `expected ${candidateVersion} -> ${nextVersion}.`, + ); + } + if (!feedServed(feed, 'latest-mac.yml') || !feedServed(feed, nextZipName)) { + throw new Error(`The update did not use the loopback feed: ${JSON.stringify(feed.requests)}`); + } + if (feed.unexpectedCount() > 0) { + throw new Error(`The app requested unexpected feed paths: ${JSON.stringify(feed.requests)}`); + } + + step('handing off to Squirrel.Mac and waiting for automatic relaunch'); + try { + const result = await evaluateInRenderer( + target.webSocketDebuggerUrl, + 'window.maka.app.installUpdate({ allowInterruptActiveTasks: true })', + { awaitPromise: true, timeoutMs: 30_000 }, + ); + if (result?.ok === false) throw new Error(`installUpdate refused: ${JSON.stringify(result)}`); + } catch (error) { + if (child.exitCode === null && !String(error.message).includes('WebSocket')) throw error; + } + await waitForExit(child, 60_000); + + const installDeadline = Date.now() + 180_000; + let installedVersion; + while (Date.now() < installDeadline) { + try { + installedVersion = await readBundleVersion(candidateApp, run); + const pids = await processIdsForExecutable(executable, run); + relaunchedPid = pids.find((pid) => pid !== child.pid); + if (installedVersion === nextVersion && relaunchedPid) break; + } catch { + // Squirrel replaces the bundle through a short path-missing window. + } + await delay(500); + } + if (installedVersion !== nextVersion || !relaunchedPid) { + throw new Error( + `Squirrel.Mac did not replace and relaunch ${candidateVersion} as ${nextVersion}; ` + + `installed=${installedVersion ?? 'unreadable'}, pid=${relaunchedPid ?? 'missing'}.`, + ); + } + await stopProcess(relaunchedPid); + relaunchedPid = undefined; + await run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', candidateApp]); + await smokeRenderer(executable, { workingDirectory: temporaryDirectory }); + step(`verified ${candidateVersion} -> ${nextVersion} replacement and relaunch`); + return { candidateVersion, nextVersion, requests: feed.requests }; + } catch (error) { + primaryError = error; + throw error; + } finally { + const cleanupErrors = []; + if (child) await stopChild(child).catch((error) => cleanupErrors.push(error)); + if (relaunchedPid) await stopProcess(relaunchedPid).catch((error) => cleanupErrors.push(error)); + if (feed) await feed.close().catch((error) => cleanupErrors.push(error)); + await rm(temporaryDirectory, { recursive: true, force: true, maxRetries: 5 }).catch((error) => + cleanupErrors.push(error), + ); + if (!primaryError && cleanupErrors.length > 0) throw cleanupErrors[0]; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await verifyMacosAutoupdate(process.argv[2], process.argv[3]); +} diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index da558a154d..fe0a1f477d 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -20,10 +20,14 @@ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { access, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; -import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { basename, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { + feedServed, + startDesktopUpdateFeed, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; import { evaluateInRenderer, findRendererTarget, @@ -46,6 +50,7 @@ import { powerShellLiteral, verifyPackagedWindowsApp, } from './verify-windows-x64.mjs'; +import { compareProductReleaseVersions } from './release-version.mjs'; const uninstallExecutableName = 'Uninstall Maka.exe'; const executableName = 'Maka.exe'; @@ -58,113 +63,6 @@ function step(label) { console.log(`[verify-windows-autoupdate] ${label}`); } -function compareStableVersions(left, right) { - const parse = (value) => value.split('.').map(Number); - const [lMaj, lMin, lPat] = parse(left); - const [rMaj, rMin, rPat] = parse(right); - if (lMaj !== rMaj) return lMaj - rMaj; - if (lMin !== rMin) return lMin - rMin; - return lPat - rPat; -} - -/** - * Loopback static file server for the update feed. Serves exactly the mapped - * root-level paths (`/latest.yml`, `/`, …) — the *full* request - * path is matched, so a nested `/x/latest.yml` counts as unexpected; a wrong - * updater request shape must surface, not be absorbed. A mapped path whose - * file is absent 404s without counting: the updater legitimately probes the - * *previous* version's blockmap for a differential download and falls back to - * the full installer when the feed does not have it. Bodies are read once at - * startup — electron-updater issues many ranged requests against the - * multi-hundred-megabyte installer, and re-reading it per request could push - * the download stage past its deadline. Supports single-range GETs because - * differential downloads use ranged requests. - */ -async function startFeedServer(files) { - const requests = []; - let unexpectedRequests = 0; - const bodies = new Map(); - for (const [name, filePath] of files) { - try { - bodies.set(`/${name}`, await readFile(filePath)); - } catch { - // Mapped but absent (the previous blockmap): served as an expected 404. - } - } - const server = createServer((request, response) => { - const method = request.method ?? 'GET'; - // The raw path segment is matched without decoding, so `/%6catest.yml` - // cannot alias `/latest.yml`. The query is allowed and recorded, not - // matched: electron-updater cache-busts its channel request with - // `?noCache=`, so rejecting queries rejects the updater's own - // documented request shape (the first live run proved exactly that). - const target = request.url ?? '/'; - const queryIndex = target.indexOf('?'); - const pathName = queryIndex === -1 ? target : target.slice(0, queryIndex); - const record = { method, path: pathName, target, status: 0 }; - requests.push(record); - const known = [...files.keys()].some((name) => `/${name}` === pathName); - if ((method !== 'GET' && method !== 'HEAD') || !known) { - unexpectedRequests += 1; - record.status = 404; - response.writeHead(404).end(); - return; - } - const body = bodies.get(pathName); - if (body === undefined) { - // Known path, absent file: the expected 404 shape (previous blockmap). - record.status = 404; - response.writeHead(404).end(); - return; - } - const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range ?? ''); - if (range) { - const start = Number(range[1]); - const end = range[2] === '' ? body.length - 1 : Math.min(Number(range[2]), body.length - 1); - if (start > end || start >= body.length) { - record.status = 416; - response.writeHead(416, { 'Content-Range': `bytes */${body.length}` }).end(); - return; - } - record.status = 206; - response.writeHead(206, { - 'Content-Type': 'application/octet-stream', - 'Content-Range': `bytes ${start}-${end}/${body.length}`, - 'Content-Length': end - start + 1, - 'Accept-Ranges': 'bytes', - }); - response.end(method === 'HEAD' ? undefined : body.subarray(start, end + 1)); - return; - } - record.status = 200; - response.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Content-Length': body.length, - 'Accept-Ranges': 'bytes', - }); - response.end(method === 'HEAD' ? undefined : body); - }); - await new Promise((resolvePromise, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolvePromise); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Could not start the loopback update feed.'); - } - return { - url: `http://127.0.0.1:${address.port}`, - requests, - unexpectedCount: () => unexpectedRequests, - close: () => - new Promise((resolvePromise) => { - server.close(() => resolvePromise()); - server.closeAllConnections?.(); - }), - }; -} - export async function waitForInstalledProductVersion( executablePath, { @@ -237,15 +135,19 @@ export async function verifyWindowsAutoupdate( if (!nextVersion) { throw new Error(`latest.yml in ${nextDirectory} does not advertise a version.`); } - if (compareStableVersions(nextVersion, candidateVersion) <= 0) { + if (compareProductReleaseVersions(nextVersion, candidateVersion) <= 0) { throw new Error( `The served version ${nextVersion} must be newer than the candidate ${candidateVersion}.`, ); } const nextInstallerName = `Maka-${nextVersion}-win-x64.exe`; installerVersion(join(nextDirectory, nextInstallerName)); - await access(join(nextDirectory, nextInstallerName)); - await access(join(nextDirectory, `${nextInstallerName}.blockmap`)); + await verifyDesktopUpdateArtifacts({ + directory: nextDirectory, + metadataName: 'latest.yml', + version: nextVersion, + artifactName: nextInstallerName, + }); const temporaryDirectory = await makeTemporaryDirectory(); const installDirectory = join(temporaryDirectory, 'installed'); @@ -264,7 +166,7 @@ export async function verifyWindowsAutoupdate( // a differential download. If the file is missing next to the candidate // installer, the mapped-but-absent 404 makes the updater fall back to the // full download — both are valid production shapes. - feed = await startFeedServer( + feed = await startDesktopUpdateFeed( new Map([ ['latest.yml', join(nextDirectory, 'latest.yml')], [nextInstallerName, join(nextDirectory, nextInstallerName)], @@ -366,19 +268,12 @@ export async function verifyWindowsAutoupdate( step('asserting the download really came from the loopback feed'); // Exact root-level paths, matching the server's own allowlist shape. - const served = (name) => - feed.requests.some( - (request) => - request.method === 'GET' && - request.path === `/${name}` && - (request.status === 200 || request.status === 206), - ); - if (!served('latest.yml')) { + if (!feedServed(feed, 'latest.yml')) { throw new Error( `The app never fetched latest.yml from the loopback feed: ${JSON.stringify(feed.requests)}`, ); } - if (!served(nextInstallerName)) { + if (!feedServed(feed, nextInstallerName)) { throw new Error( `The app never downloaded the installer from the loopback feed: ${JSON.stringify(feed.requests)}`, ); diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 81231b14c6..8aaae12534 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -25,7 +25,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { after, describe, it } from 'node:test'; -import { bumpedAutoupdateVersion } from './package-windows-autoupdate-next.mjs'; +import { bumpedAutoupdateVersion } from './desktop-update-contract.mjs'; import { validateWindowsUpgradeBaseline } from './prepare-windows-upgrade-baseline.mjs'; import { diffTreeManifests, diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 219a7c7072..e46cf03d8a 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { readProductManifestIdentity } from './product-release-identity.mjs'; +import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -148,8 +149,10 @@ export async function verifyPackagedWindowsApp( requireAppIconCatalog: requiresCurrentContract, requireDirectPeerArtifact: requiresCurrentContract, }); - if (requiresCurrentContract) await assertPackagedDependencyClosure(resources); - else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); + if (requiresCurrentContract) { + await assertPackagedUpdateConfiguration(resources); + await assertPackagedDependencyClosure(resources); + } else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); step('reading the executable architecture'); const machine = await readMachine(executable); From ad3b57fb3d481139a47ae0d29956d9e99392fa3a Mon Sep 17 00:00:00 2001 From: Wang Date: Thu, 27 Aug 2026 19:17:10 +0800 Subject: [PATCH 2/7] fix(release): bind publication to immutable build evidence Generated-by: OpenAI Codex --- .github/RELEASE_CHECKLIST.md | 8 +- .github/workflows/release-cli-finalize.yml | 49 ++++++- .github/workflows/release.yml | 35 +++-- docs/cli-npm-release.md | 17 ++- docs/cli-npm-release.zh-CN.md | 14 +- scripts/ci-test-plan.test.mjs | 2 +- scripts/desktop-update-contract.mjs | 12 +- scripts/product-release-authority.mjs | 142 +++++++++++-------- scripts/product-release-authority.test.mjs | 82 ++++++++--- scripts/product-release-identity.mjs | 4 + scripts/product-release.test.mjs | 34 +++++ scripts/release-cli-workflow-policy.test.mjs | 12 +- 12 files changed, 294 insertions(+), 117 deletions(-) diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 321a2bd382..64c3e9b927 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -106,7 +106,8 @@ then rerun. If only the tag exists, the retry creates the missing Draft. Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact product tag and Draft: -1. Run **Stage CLI npm release** from `v` and record its successful run ID and attempt. +1. Record the successful **Release** workflow run ID and attempt that built the Draft assets. Run + **Stage CLI npm release** from `v` and record its successful run ID and attempt. 2. Inspect the staged tarball and provenance, then approve that exact stage with npm 2FA. 3. Run **Finalize product release** from `v`. Its first job verifies the public package bytes, provenance, signature, and release dist-tag. @@ -114,8 +115,9 @@ Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact p Keep the GitHub Release in Draft throughout this sequence. The final workflow job waits at the `product-release` Environment. Approve it only after every npm and cross-machine acceptance check -has passed. It redownloads and verifies every Draft byte, then publishes the release and makes a -stable release Latest in the same GitHub operation; prereleases remain non-Latest. Do not publish or +has passed. It verifies the live Draft digests against the immutable artifacts from the exact +successful Release run, then publishes the release and makes a stable release Latest in the same +GitHub operation; prereleases remain non-Latest. Do not publish or change the Latest designation manually. A failed or rejected npm candidate requires a new product version; never publish the Draft to work around npm state. diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 1160a1206a..13a49c9484 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -28,6 +28,14 @@ on: description: Successful Stage CLI npm release workflow run attempt required: true type: string + release_run_id: + description: Successful Release workflow run ID that built the Draft assets + required: true + type: string + release_run_attempt: + description: Successful Release workflow run attempt that built the Draft assets + required: true + type: string version: description: Exact staged maka-agent product version required: true @@ -76,6 +84,22 @@ jobs: fi gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID/attempts/$STAGE_RUN_ATTEMPT" > "$RUNNER_TEMP/stage-run.json" + - name: Load the exact Release workflow run + env: + GH_TOKEN: ${{ github.token }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} + run: | + if [[ ! "$RELEASE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "Release workflow run ID must be a positive integer" >&2 + exit 1 + fi + if [[ ! "$RELEASE_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "Release workflow run attempt must be a positive integer" >&2 + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID/attempts/$RELEASE_RUN_ATTEMPT" > "$RUNNER_TEMP/release-run.json" + - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -117,6 +141,13 @@ jobs: PRODUCT_SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} PRODUCT_TAG: ${{ steps.release.outputs.product_tag }} run: | + node scripts/product-release-authority.mjs verify-build-run \ + "$RUNNER_TEMP/release-run.json" \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "${{ inputs.release_run_id }}" \ + "${{ inputs.release_run_attempt }}" node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" @@ -155,6 +186,7 @@ jobs: name: product-release url: https://github.com/apache/maka/releases/tag/${{ needs.inspect.outputs.product_tag }} permissions: + actions: read contents: write steps: - name: Check out the exact product source @@ -169,14 +201,21 @@ jobs: node-version: '24' package-manager-cache: false + - name: Select the release npm toolchain + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + - name: Install the release verifier dependencies run: npm ci --ignore-scripts --no-audit --no-fund - - name: Download the complete Draft release - env: - GH_TOKEN: ${{ github.token }} - PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} - run: gh release download "$PRODUCT_TAG" --dir "$RUNNER_TEMP/product-release" + - name: Download the exact verified Release run artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-*-${{ inputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release + merge-multiple: true + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.release_run_id }} - name: Revalidate every product artifact env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eed837f7e6..685b8498fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -256,10 +256,10 @@ jobs: - name: Upload the verified release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-${{ matrix.platform }} + name: release-desktop-${{ matrix.platform }}-${{ github.run_attempt }} path: ${{ runner.temp }}/release-assets if-no-files-found: error - retention-days: 7 + retention-days: 30 - name: Remove temporary release credentials if: always() && matrix.platform == 'macos' @@ -352,10 +352,10 @@ jobs: - name: Upload the verified CLI release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-cli-macos-arm64 + name: release-cli-macos-arm64-${{ github.run_attempt }} path: ${{ runner.temp }}/release-assets if-no-files-found: error - retention-days: 7 + retention-days: 30 - name: Remove temporary release credentials if: always() @@ -384,16 +384,26 @@ jobs: - name: Download the verified release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: release-* + pattern: release-*-${{ github.run_attempt }} path: release-assets merge-multiple: true - - name: Verify the exact product artifact manifest + - name: Set up the pinned release Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ needs.release-identity.outputs.node_version }} + package-manager-cache: false + + - name: Select the pinned npm release toolchain run: | - node scripts/product-release-artifacts.mjs verify release-assets - while IFS= read -r -d '' checksum; do - (cd "$(dirname "$checksum")" && sha256sum -c "$(basename "$checksum")") - done < <(find release-assets -type f -name '*.sha256' -print0) + npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}" + test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}" + + - name: Install the release verifier dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + + - name: Verify the exact product artifact manifest + run: node scripts/product-release-artifacts.mjs verify release-assets - name: Revalidate the live ASF source reference env: @@ -507,4 +517,7 @@ jobs: gh release upload "$TAG" "${missing_assets[@]}" fi - echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." >> "$GITHUB_STEP_SUMMARY" + { + echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." + echo "Release workflow run: ${GITHUB_RUN_ID}, attempt: ${GITHUB_RUN_ATTEMPT}." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 961138f124..c49cbe78b1 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -51,10 +51,11 @@ The two workflow boundaries are: tarball, records that single tag commit and workflow run, enters the protected `npm-release` Environment, and submits it to npm staging through OIDC. 2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) accepts only the exact - successful Stage run and attempt, verifies the public registry bytes, signature, provenance, and - dist-tag, then waits at the protected `product-release` Environment. After independent Desktop - acceptance, approval makes it reverify every Draft asset and publish the GitHub Release together - with its Stable/Latest classification in one operation. + successful Stage run and Release build run attempts. It verifies the public registry bytes, + signature, provenance, dist-tag, and the live Draft digests against the immutable artifacts from + that Release run, then waits at the protected `product-release` Environment. After independent + Desktop acceptance, approval publishes the GitHub Release together with its Stable/Latest + classification in one operation. ## One-time control-plane configuration @@ -199,13 +200,15 @@ not dist-tag mutations, and the release workflows must not gain a long-lived npm After npm reports the version as public: 1. Open **Actions → Finalize product release → Run workflow** on `v`. -2. Enter the successful Stage run ID, its exact run attempt, and the version. +2. Enter the successful Stage run ID and attempt, the successful Release build run ID and attempt, + and the version. 3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. 4. While the publication job waits for `product-release` approval, complete the product checklist's cross-machine acceptance against the Draft. -5. Approve the Environment. Confirm the workflow redownloads and verifies every Draft artifact, - publishes it, and makes a stable release Latest without a separate manual action. +5. Approve the Environment. Confirm the workflow matches every live Draft digest to the immutable + artifacts from the exact Release attempt, publishes it, and makes a stable release Latest without + a separate manual action. Check the resulting registry state: diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index e7d1fc083d..69f1944218 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -43,9 +43,10 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,记录这个唯一的 tag commit 与 workflow run,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; 2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 - Stage run 和 attempt,验证公共 registry 字节、signature、provenance 和 dist-tag,然后等待受保护的 - `product-release` Environment;独立 Desktop 验收完成并批准后,它会重新验证 Draft 的每个 - artifact,并在同一个操作中发布 GitHub Release 及其 Stable/Latest 分类。 + Stage run attempt 和 Release build run attempt,验证公共 registry 字节、signature、provenance、 + dist-tag,并将 live Draft digest 与该 Release run 的不可变 artifact 对比,然后等待受保护的 + `product-release` Environment;独立 Desktop 验收完成并批准后,它会在同一个操作中发布 GitHub + Release 及其 Stable/Latest 分类。 ## 一次性控制面配置 @@ -179,12 +180,13 @@ release workflow 不得获得长期 npm token。 npm 显示该版本已经公开后: 1. 在 `v` 上打开 **Actions → Finalize product release → Run workflow**; -2. 输入成功 Stage 的 run ID、精确 run attempt 和 version; +2. 输入成功 Stage 的 run ID 与精确 attempt、成功 Release build 的 run ID 与精确 attempt,以及 + version; 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; 4. publication job 等待 `product-release` 批准期间,针对 Draft 完成产品检查清单中的跨机器验收; -5. 批准 Environment,并确认 workflow 重新下载并验证每个 Draft artifact,随后发布 Release; - stable release 会同时成为 Latest,不再需要单独人工操作。 +5. 批准 Environment,并确认 workflow 将每个 live Draft digest 与精确 Release attempt 的不可变 + artifact 对比后发布 Release;stable release 会同时成为 Latest,不再需要单独人工操作。 检查最终 registry 状态: diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 011b3faa4a..e0d52f99ad 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -113,7 +113,6 @@ test('release metadata selects only the gate that consumes it', () => { test('source legal authority and generated provenance select the ASF source gate', () => { for (const path of [ - 'apps/desktop/src/main/app-update-test-context.ts', 'DISCLAIMER-WIP', 'biome.jsonc', 'patches/node-pty+1.2.0-beta.15.patch', @@ -131,6 +130,7 @@ test('source legal authority and generated provenance select the ASF source gate test('release authority changes select their dedicated contract gate', () => { for (const path of [ + 'apps/desktop/src/main/app-update-test-context.ts', 'apps/desktop/build/entitlements.mac.plist', 'apps/desktop/electron-builder.config.mjs', 'apps/desktop/package.json', diff --git a/scripts/desktop-update-contract.mjs b/scripts/desktop-update-contract.mjs index e9f198a34f..35b5004732 100644 --- a/scripts/desktop-update-contract.mjs +++ b/scripts/desktop-update-contract.mjs @@ -22,7 +22,6 @@ import { createReadStream } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; import { createServer } from 'node:http'; import { join } from 'node:path'; -import { parse as parseYaml } from 'yaml'; import { parseProductReleaseVersion } from './release-version.mjs'; export const DESKTOP_UPDATE_PROVIDER = Object.freeze({ @@ -32,13 +31,18 @@ export const DESKTOP_UPDATE_PROVIDER = Object.freeze({ updaterCacheDirName: '@makadesktop-updater', }); -/** A stable successor lets both stable and prerelease candidates use one feed contract. */ +/** A stable successor lets stable, alpha, and beta candidates use one feed contract. */ export function bumpedAutoupdateVersion(candidateVersion) { const { core, prerelease } = parseProductReleaseVersion(candidateVersion); const [major, minor, patch] = core; return prerelease.length > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}.${patch + 1n}`; } +async function readYaml(path, read = readFile) { + const [source, { parse }] = await Promise.all([read(path, 'utf8'), import('yaml')]); + return parse(source); +} + function requireExactObject(actual, expected, subject) { const exact = actual && @@ -58,7 +62,7 @@ export async function assertPackagedUpdateConfiguration(resourcesPath, { read = const path = join(resourcesPath, 'app-update.yml'); let configuration; try { - configuration = parseYaml(await read(path, 'utf8')); + configuration = await readYaml(path, read); } catch (error) { throw new Error(`Packaged update configuration is unreadable: ${path}`, { cause: error }); } @@ -80,7 +84,7 @@ export async function verifyDesktopUpdateArtifacts({ const metadataPath = join(directory, metadataName); let metadata; try { - metadata = parseYaml(await readFile(metadataPath, 'utf8')); + metadata = await readYaml(metadataPath); } catch (error) { throw new Error(`Desktop update metadata is unreadable: ${metadataPath}`, { cause: error }); } diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs index 05d93fccc4..fac86e0d6c 100644 --- a/scripts/product-release-authority.mjs +++ b/scripts/product-release-authority.mjs @@ -20,10 +20,11 @@ import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { readdir, stat } from 'node:fs/promises'; +import { readFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; +import { parseAsfSourceReferenceTag } from './product-release-identity.mjs'; import { parseProductTag, remoteProductTagCommit } from './product-release-tag.mjs'; const execFileAsync = promisify(execFile); @@ -35,13 +36,18 @@ function expectedReleaseIdentity(tag) { export function assertDraftProductRelease(release, tag) { const expected = expectedReleaseIdentity(tag); - if (!release || release.tagName !== expected.tag) { + if ( + !release || + !Number.isSafeInteger(release.id) || + release.id < 1 || + release.tag !== expected.tag + ) { throw new Error(`GitHub Release does not identify product tag ${tag}`); } - if (release.isDraft !== true) { + if (release.draft !== true) { throw new Error(`GitHub Release ${tag} must remain a Draft`); } - if (release.isPrerelease !== expected.isPrerelease) { + if (release.prerelease !== expected.isPrerelease) { throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); } return release; @@ -49,15 +55,64 @@ export function assertDraftProductRelease(release, tag) { export function assertPublishedProductRelease(release, tag) { const expected = expectedReleaseIdentity(tag); - if (!release || release.tagName !== tag || release.isDraft !== false) { + if (!release || release.tag !== tag || release.draft !== false) { throw new Error(`GitHub Release ${tag} was not published`); } - if (release.isPrerelease !== expected.isPrerelease) { + if (release.prerelease !== expected.isPrerelease) { throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); } return release; } +function releaseSnapshotFromGhView(value) { + return { + id: value?.databaseId, + tag: value?.tagName, + draft: value?.isDraft, + prerelease: value?.isPrerelease, + assets: value?.assets ?? [], + }; +} + +function releaseSnapshotFromRest(value) { + return { + id: value?.id, + tag: value?.tag_name, + draft: value?.draft, + prerelease: value?.prerelease, + assets: value?.assets ?? [], + }; +} + +export function assertProductReleaseWorkflowRun({ + run, + tag, + sourceCommit, + repository, + runId, + runAttempt, +}) { + if (!/^[1-9]\d*$/u.test(String(runId)) || !/^[1-9]\d*$/u.test(String(runAttempt))) { + throw new Error('Release workflow run ID and attempt must be positive integers'); + } + const product = parseProductTag(tag); + const source = parseAsfSourceReferenceTag(run?.head_branch); + const exact = + String(run?.id) === String(runId) && + String(run?.run_attempt) === String(runAttempt) && + run?.path === '.github/workflows/release.yml' && + run?.event === 'workflow_dispatch' && + run?.status === 'completed' && + run?.conclusion === 'success' && + run?.head_sha === sourceCommit && + run?.head_repository?.full_name === repository && + source.version === product.version; + if (!exact) { + throw new Error('Release workflow run does not match the approved product source'); + } + return run; +} + function digestFile(path) { return new Promise((resolvePromise, reject) => { const hash = createHash('sha256'); @@ -79,17 +134,6 @@ async function localAssetRecords(directory) { return records.sort((left, right) => left.name.localeCompare(right.name)); } -export async function assertGitHubReleaseAssetDigests({ release, artifactDirectory }) { - const local = await localAssetRecords(artifactDirectory); - const remote = (release?.assets ?? []) - .map(({ name, size, digest }) => ({ name, size, digest })) - .sort((left, right) => left.name.localeCompare(right.name)); - if (JSON.stringify(remote) !== JSON.stringify(local)) { - throw new Error('Draft GitHub Release assets do not match the locally verified artifact bytes'); - } - return local; -} - export async function verifyDraftProductRelease({ tag, sourceCommit, @@ -127,7 +171,7 @@ export async function verifyDraftProductRelease({ '--repo', repository, '--json', - 'databaseId,tagName,isDraft,isPrerelease', + 'databaseId,tagName,isDraft,isPrerelease,assets', ], { cwd }, ); @@ -137,7 +181,7 @@ export async function verifyDraftProductRelease({ } catch (error) { throw new Error(`GitHub returned an invalid Release record for ${tag}`, { cause: error }); } - return assertDraftProductRelease(parsedRelease, tag); + return assertDraftProductRelease(releaseSnapshotFromGhView(parsedRelease), tag); } export async function publishDraftProductRelease({ @@ -150,23 +194,14 @@ export async function publishDraftProductRelease({ pause = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)), }) { + const localAssets = await localAssetRecords(artifactDirectory); const draft = await verifyDraftProductRelease({ tag, sourceCommit, repository, cwd, run }); - const response = await run('gh', ['api', `repos/${repository}/releases/tags/${tag}`], { cwd }); - let live; - try { - live = JSON.parse(response.stdout); - } catch (error) { - throw new Error(`GitHub returned an invalid live Release record for ${tag}`, { cause: error }); - } - if ( - live.tag_name !== tag || - live.draft !== true || - live.prerelease !== expectedReleaseIdentity(tag).isPrerelease || - live.id !== draft.databaseId - ) { - throw new Error(`GitHub Release ${tag} changed during finalization`); + const remoteAssets = (draft.assets ?? []) + .map(({ name, size, digest }) => ({ name, size, digest })) + .sort((left, right) => left.name.localeCompare(right.name)); + if (JSON.stringify(remoteAssets) !== JSON.stringify(localAssets)) { + throw new Error('Draft GitHub Release assets do not match the verified Release run artifacts'); } - await assertGitHubReleaseAssetDigests({ release: live, artifactDirectory }); const isPrerelease = expectedReleaseIdentity(tag).isPrerelease; const published = await run( @@ -175,7 +210,7 @@ export async function publishDraftProductRelease({ 'api', '--method', 'PATCH', - `repos/${repository}/releases/${live.id}`, + `repos/${repository}/releases/${draft.id}`, '-F', 'draft=false', '-F', @@ -187,12 +222,7 @@ export async function publishDraftProductRelease({ ); let record; try { - const value = JSON.parse(published.stdout); - record = { - tagName: value.tag_name, - isDraft: value.draft, - isPrerelease: value.prerelease, - }; + record = releaseSnapshotFromRest(JSON.parse(published.stdout)); } catch (error) { throw new Error(`GitHub returned an invalid publication result for ${tag}`, { cause: error }); } @@ -220,22 +250,20 @@ export async function publishDraftProductRelease({ } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [ - command, - tag, - sourceCommit, - repository = process.env.GITHUB_REPOSITORY, - artifactDirectory, - ] = process.argv.slice(2); - if (!tag || !sourceCommit || !repository) { - throw new Error( - 'usage: product-release-authority.mjs [artifact-directory]', - ); - } - if (command === 'verify-draft' && !artifactDirectory) { + const [command, ...args] = process.argv.slice(2); + const usage = + 'usage: product-release-authority.mjs verify-build-run | [artifact-directory]'; + if (command === 'verify-build-run' && args.length === 6) { + const [runPath, tag, sourceCommit, repository, runId, runAttempt] = args; + const run = JSON.parse(await readFile(runPath, 'utf8')); + assertProductReleaseWorkflowRun({ run, tag, sourceCommit, repository, runId, runAttempt }); + console.log(`Verified Release workflow run ${runId}/${runAttempt} for ${tag}`); + } else if (command === 'verify-draft' && args.length === 3) { + const [tag, sourceCommit, repository] = args; await verifyDraftProductRelease({ tag, sourceCommit, repository }); console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); - } else if (command === 'publish-draft' && artifactDirectory) { + } else if (command === 'publish-draft' && args.length === 4) { + const [tag, sourceCommit, repository, artifactDirectory] = args; await publishDraftProductRelease({ tag, sourceCommit, @@ -244,8 +272,6 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) }); console.log(`Published product Release ${tag} from ${sourceCommit}`); } else { - throw new Error( - 'usage: product-release-authority.mjs [artifact-directory]', - ); + throw new Error(usage); } } diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs index 842aae8fc1..a2a097f03e 100644 --- a/scripts/product-release-authority.test.mjs +++ b/scripts/product-release-authority.test.mjs @@ -25,6 +25,7 @@ import { join } from 'node:path'; import test from 'node:test'; import { assertDraftProductRelease, + assertProductReleaseWorkflowRun, assertPublishedProductRelease, publishDraftProductRelease, verifyDraftProductRelease, @@ -36,14 +37,14 @@ test('Draft state and prerelease classification are one product Release contract ['v1.2.3-beta.1', true], ]) { assert.equal( - assertDraftProductRelease({ tagName: tag, isDraft: true, isPrerelease }, tag).tagName, + assertDraftProductRelease({ id: 42, tag, draft: true, prerelease: isPrerelease }, tag).tag, tag, ); } assert.throws( () => assertDraftProductRelease( - { tagName: 'v1.2.3', isDraft: false, isPrerelease: false }, + { id: 42, tag: 'v1.2.3', draft: false, prerelease: false }, 'v1.2.3', ), /must remain a Draft/u, @@ -51,7 +52,7 @@ test('Draft state and prerelease classification are one product Release contract assert.throws( () => assertDraftProductRelease( - { tagName: 'v1.2.3-beta.1', isDraft: true, isPrerelease: false }, + { id: 42, tag: 'v1.2.3-beta.1', draft: true, prerelease: false }, 'v1.2.3-beta.1', ), /prerelease state must be true/u, @@ -61,21 +62,71 @@ test('Draft state and prerelease classification are one product Release contract test('published state keeps stable and prerelease classification exact', () => { assert.equal( assertPublishedProductRelease( - { tagName: 'v1.2.3', isDraft: false, isPrerelease: false }, + { id: 42, tag: 'v1.2.3', draft: false, prerelease: false }, 'v1.2.3', - ).tagName, + ).tag, 'v1.2.3', ); assert.throws( () => assertPublishedProductRelease( - { tagName: 'v1.2.3-beta.1', isDraft: false, isPrerelease: false }, + { id: 42, tag: 'v1.2.3-beta.1', draft: false, prerelease: false }, 'v1.2.3-beta.1', ), /prerelease state/u, ); }); +test('Release workflow evidence binds an exact successful attempt to approved source', () => { + const sourceCommit = 'a'.repeat(40); + const run = { + id: 123, + run_attempt: 2, + path: '.github/workflows/release.yml', + event: 'workflow_dispatch', + status: 'completed', + conclusion: 'success', + head_sha: sourceCommit, + head_branch: 'v1.2.3-incubating-rc1', + head_repository: { full_name: 'apache/maka' }, + }; + assert.equal( + assertProductReleaseWorkflowRun({ + run, + tag: 'v1.2.3', + sourceCommit, + repository: 'apache/maka', + runId: '123', + runAttempt: '2', + }), + run, + ); + assert.throws( + () => + assertProductReleaseWorkflowRun({ + run: { ...run, head_sha: 'b'.repeat(40) }, + tag: 'v1.2.3', + sourceCommit, + repository: 'apache/maka', + runId: '123', + runAttempt: '2', + }), + /does not match the approved product source/u, + ); + assert.throws( + () => + assertProductReleaseWorkflowRun({ + run, + tag: 'v1.2.3', + sourceCommit, + repository: 'apache/maka', + runId: '0', + runAttempt: '2', + }), + /must be positive integers/u, + ); +}); + test('the live authority verifier binds the tag, main ancestry, and Draft Release', async () => { const sourceCommit = 'a'.repeat(40); const calls = []; @@ -87,9 +138,11 @@ test('the live authority verifier binds the tag, main ancestry, and Draft Releas if (command === 'gh') { return { stdout: JSON.stringify({ + databaseId: 42, tagName: 'v1.2.3', isDraft: true, isPrerelease: false, + assets: [], }), }; } @@ -114,7 +167,7 @@ test('the live authority verifier binds the tag, main ancestry, and Draft Releas ); assert.deepEqual(calls.at(-1)[1].slice(-2), [ '--json', - 'databaseId,tagName,isDraft,isPrerelease', + 'databaseId,tagName,isDraft,isPrerelease,assets', ]); }); @@ -156,16 +209,6 @@ test('publication verifies live asset digests before one Stable/Latest mutation' tagName: 'v1.2.3', isDraft: true, isPrerelease: false, - }), - }; - } - if (command === 'gh' && args.includes('repos/apache/maka/releases/tags/v1.2.3')) { - return { - stdout: JSON.stringify({ - id: 42, - tag_name: 'v1.2.3', - draft: true, - prerelease: false, assets: [{ name: 'artifact.zip', size: contents.length, digest }], }), }; @@ -196,9 +239,6 @@ test('publication verifies live asset digests before one Stable/Latest mutation' assert.ok(patchCall[1].includes('draft=false')); assert.ok(patchCall[1].includes('prerelease=false')); assert.ok(patchCall[1].includes('make_latest=true')); - assert.ok( - calls.findIndex(([, args]) => args.includes('repos/apache/maka/releases/tags/v1.2.3')) < - calls.indexOf(patchCall), - ); + assert.ok(calls.every(([, args]) => !args.includes('repos/apache/maka/releases/tags/v1.2.3'))); assert.equal(latestReads, 2); }); diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index b44b9c71f8..3e868d9b2a 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -68,6 +68,10 @@ export function parseAsfSourceReferenceTag(tag) { export function resolveProductManifestIdentity({ rootManifest, desktopManifest, cliManifest }) { const { version, prerelease } = parseProductReleaseVersion(rootManifest.version); + const channel = prerelease[0]; + if (channel !== undefined && channel !== 'alpha' && channel !== 'beta') { + throw new Error('Product prerelease channel must be alpha or beta'); + } for (const [label, manifest] of [ ['Desktop', desktopManifest], ['CLI', cliManifest], diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 71bab3451a..43185ea9c4 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -157,6 +157,30 @@ test('the product identity classifies prereleases once for every publication sur assert.equal(identity.tag, `v${version}`); }); +test('product prereleases use only updater-compatible alpha and beta channels', () => { + for (const version of ['1.2.3-alpha.1', '1.2.3-beta.2']) { + assert.equal( + resolveProductManifestIdentity({ + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + }).isPrerelease, + true, + ); + } + for (const version of ['1.2.3-rc.1', '1.2.3-dev.1']) { + assert.throws( + () => + resolveProductManifestIdentity({ + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + }), + /prerelease channel must be alpha or beta/u, + ); + } +}); + test('Desktop packaging derives the Runtime Host setup package from product manifests', async () => { const manifestIdentity = resolveProductManifestIdentity({ rootManifest, @@ -673,12 +697,22 @@ test('one product workflow gates one draft release on every required artifact', String(step.uses).startsWith('actions/upload-artifact@'), ); assert.equal(upload.with.path, '${{ runner.temp }}/release-assets'); + assert.equal(upload.with['retention-days'], 30); + assert.match(upload.with.name, /release-.*\$\{\{ github\.run_attempt \}\}/u); } const verifyArtifacts = jobs.publish.steps.find( (step) => step.name === 'Verify the exact product artifact manifest', ).run; assert.match(verifyArtifacts, /product-release-artifacts\.mjs verify release-assets/u); + assert.doesNotMatch(verifyArtifacts, /sha256sum|find release-assets/u); assert.doesNotMatch(verifyArtifacts, /required=\(|Maka-\*|latest\*\.yml/u); + const installVerifier = jobs.publish.steps.findIndex( + (step) => step.name === 'Install the release verifier dependencies', + ); + const verifyManifest = jobs.publish.steps.findIndex( + (step) => step.name === 'Verify the exact product artifact manifest', + ); + assert.ok(installVerifier >= 0 && installVerifier < verifyManifest); const commands = Object.values(jobs) .flatMap((job) => job.steps ?? []) diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index d72fe63e20..ecf7c217af 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -87,7 +87,7 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); -test('finalize validates one exact stage attempt from the product tag', () => { +test('finalize validates exact stage and Release attempts from the product tag', () => { const workflow = readWorkflow('release-cli-finalize.yml'); const steps = workflowSteps(workflow); assert.match(workflow, /stage_run_attempt:[\s\S]*?required: true/u); @@ -95,6 +95,8 @@ test('finalize validates one exact stage attempt from the product tag', () => { const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); assert.ok(loadIndex >= 0 && checkoutIndex > loadIndex); assert.match(workflow, /actions\/runs\/\$STAGE_RUN_ID\/attempts\/\$STAGE_RUN_ATTEMPT/u); + assert.match(workflow, /release_run_attempt:[\s\S]*?required: true/u); + assert.match(workflow, /actions\/runs\/\$RELEASE_RUN_ID\/attempts\/\$RELEASE_RUN_ATTEMPT/u); const checkout = namedStep(steps, 'Check out the current release verifier'); assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); const exactTag = namedStep(steps, 'Require the exact product tag'); @@ -108,6 +110,7 @@ test('finalize revalidates the live product release before trusting public npm b assert.match(record, /id: release/u); assert.match(record, /"\$GITHUB_OUTPUT"/u); const authority = namedStep(steps, 'Revalidate the product release authority'); + assert.match(authority, /product-release-authority\.mjs verify-build-run/u); assert.match(authority, /product-release-authority\.mjs verify-draft/u); assert.ok( workflow.indexOf(authority) < workflow.indexOf('Fetch and verify the public registry bytes'), @@ -123,6 +126,13 @@ test('finalize preserves npm evidence and owns the single product publication bo assert.match(workflow, /contents: write/u); assert.match(workflow, /product-release-artifacts\.mjs verify/u); assert.match(workflow, /product-release-authority\.mjs publish-draft/u); + const artifacts = namedStep( + workflowSteps(workflow), + 'Download the exact verified Release run artifacts', + ); + assert.match(artifacts, /run-id: \$\{\{ inputs\.release_run_id \}\}/u); + assert.match(artifacts, /release-\*-\$\{\{ inputs\.release_run_attempt \}\}/u); + assert.doesNotMatch(workflow, /gh release download/u); assert.doesNotMatch(workflow, /gh release edit|gh release create/u); }); From 9337e565ec7f91dc458e87583dde8ba3c695a6d9 Mon Sep 17 00:00:00 2001 From: Wang Date: Thu, 27 Aug 2026 21:11:14 +0800 Subject: [PATCH 3/7] fix(release): separate evidence from publication control --- .asf.yaml | 4 +- .github/RELEASE_CHECKLIST.md | 15 +- .github/workflows/release-cli-finalize.yml | 68 ++++-- .github/workflows/release.yml | 23 +- docs/cli-npm-release.md | 46 ++-- docs/cli-npm-release.zh-CN.md | 36 +-- scripts/product-release-artifacts.mjs | 220 ++++++++++++++++++- scripts/product-release-artifacts.test.mjs | 77 +++++++ scripts/product-release-authority.mjs | 124 ++++++++--- scripts/product-release-authority.test.mjs | 82 ++++++- scripts/product-release.test.mjs | 32 ++- scripts/release-cli-workflow-policy.test.mjs | 27 ++- 12 files changed, 645 insertions(+), 109 deletions(-) diff --git a/.asf.yaml b/.asf.yaml index bc0a69bf36..adc2cf51f4 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -119,8 +119,8 @@ github: deployment_branch_policy: protected_branches: false policies: - - name: "v*" - type: tag + - name: main + type: branch notifications: commits: commits@maka.apache.org diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 64c3e9b927..2ada8a8e2c 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -60,8 +60,12 @@ must never be exposed to fork or ordinary pull-request jobs. Before the first product release, confirm the checked-in `.asf.yaml` has reconciled the live repository: - the `Immutable release tags` ruleset blocks updates, force-pushes, and deletions of `v*` tags; -- the `release`, `npm-release`, and `product-release` Environments accept only their declared tag patterns and require a reviewer other than the triggering user; +- the `release` and `npm-release` Environments accept only their declared tag patterns, + `product-release` accepts only `main`, and each requires a reviewer other than the triggering user; - enable immutable releases so assets and the associated tag cannot change after publication. +- install the release-policy GitHub App only on `apache/maka` with repository + **Administration: read**, then configure its client ID and private key only in the + `product-release` Environment. These controls close the check-to-upload and check-to-stage windows. Keep the Release in Draft while assets and acceptance are incomplete; publishing early must make subsequent mutation fail closed. @@ -109,15 +113,16 @@ Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact p 1. Record the successful **Release** workflow run ID and attempt that built the Draft assets. Run **Stage CLI npm release** from `v` and record its successful run ID and attempt. 2. Inspect the staged tarball and provenance, then approve that exact stage with npm 2FA. -3. Run **Finalize product release** from `v`. Its first job verifies the public package +3. Run **Finalize product release** from `main`. Its first job verifies the public package bytes, provenance, signature, and release dist-tag. 4. Install the exact public version on each release platform and complete the npm acceptance steps. Keep the GitHub Release in Draft throughout this sequence. The final workflow job waits at the `product-release` Environment. Approve it only after every npm and cross-machine acceptance check -has passed. It verifies the live Draft digests against the immutable artifacts from the exact -successful Release run, then publishes the release and makes a stable release Latest in the same -GitHub operation; prereleases remain non-Latest. Do not publish or +has passed. It verifies the live Draft digests against the immutable publication record from the +exact successful Release run, verifies the repository immutable-release policy, then publishes an +immutable release and makes a stable release Latest in the same GitHub operation; prereleases remain +non-Latest. Do not publish or change the Latest designation manually. A failed or rejected npm candidate requires a new product version; never publish the Draft to work around npm state. diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 13a49c9484..b37a06805d 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -46,7 +46,7 @@ permissions: contents: read concurrency: - group: product-release-finalize + group: product-release cancel-in-progress: false jobs: @@ -57,14 +57,14 @@ jobs: outputs: product_tag: ${{ steps.release.outputs.product_tag }} source_commit: ${{ steps.release.outputs.source_commit }} + source_reference_tag: ${{ steps.authority.outputs.source_reference_tag }} steps: - - name: Require the exact product tag + - name: Require main env: RELEASE_REF: ${{ github.ref }} - PRODUCT_TAG: v${{ inputs.version }} run: | - if [[ "$RELEASE_REF" != "refs/tags/$PRODUCT_TAG" ]]; then - echo "Product finalization must be dispatched from $PRODUCT_TAG; found $RELEASE_REF" >&2 + if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then + echo "Product finalization must be dispatched from main; found $RELEASE_REF" >&2 exit 1 fi @@ -124,6 +124,15 @@ jobs: repository: ${{ github.repository }} run-id: ${{ inputs.stage_run_id }} + - name: Download the immutable publication record + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: product-release-record-${{ inputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release-record + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.release_run_id }} + - name: Verify the stage run and release record id: release env: @@ -136,6 +145,7 @@ jobs: "$GITHUB_OUTPUT" - name: Revalidate the product release authority + id: authority env: GH_TOKEN: ${{ github.token }} PRODUCT_SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} @@ -148,8 +158,18 @@ jobs: "$GITHUB_REPOSITORY" \ "${{ inputs.release_run_id }}" \ "${{ inputs.release_run_attempt }}" + source_reference_tag="$(jq -r .head_branch "$RUNNER_TEMP/release-run.json")" + node scripts/product-release-artifacts.mjs inspect-record \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$GITHUB_REPOSITORY" \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$source_reference_tag" \ + "${{ inputs.release_run_id }}" \ + "${{ inputs.release_run_attempt }}" node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" + echo "source_reference_tag=$source_reference_tag" >> "$GITHUB_OUTPUT" - name: Fetch and verify the public registry bytes run: | @@ -189,24 +209,26 @@ jobs: actions: read contents: write steps: - - name: Check out the exact product source + - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.inspect.outputs.source_commit }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false + - name: Mint a read-only repository policy token + id: release-policy + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.RELEASE_POLICY_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_POLICY_APP_PRIVATE_KEY }} + permission-administration: read + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' package-manager-cache: false - - name: Select the release npm toolchain - run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - - - name: Install the release verifier dependencies - run: npm ci --ignore-scripts --no-audit --no-fund - - name: Download the exact verified Release run artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -217,19 +239,29 @@ jobs: repository: ${{ github.repository }} run-id: ${{ inputs.release_run_id }} - - name: Revalidate every product artifact - env: - GITHUB_SHA: ${{ needs.inspect.outputs.source_commit }} - run: node scripts/product-release-artifacts.mjs verify "$RUNNER_TEMP/product-release" + - name: Download the immutable publication record + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: product-release-record-${{ inputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release-record + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.release_run_id }} - name: Publish the verified release and update its channel atomically env: GH_TOKEN: ${{ github.token }} + RELEASE_POLICY_TOKEN: ${{ steps.release-policy.outputs.token }} PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} + PRODUCT_SOURCE_REFERENCE_TAG: ${{ needs.inspect.outputs.source_reference_tag }} PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} run: | node scripts/product-release-authority.mjs publish-draft \ "$PRODUCT_TAG" \ "$PRODUCT_SOURCE_COMMIT" \ "$GITHUB_REPOSITORY" \ - "$RUNNER_TEMP/product-release" + "$RUNNER_TEMP/product-release" \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$PRODUCT_SOURCE_REFERENCE_TAG" \ + "${{ inputs.release_run_id }}" \ + "${{ inputs.release_run_attempt }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 685b8498fb..0463d1b1a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ permissions: contents: read concurrency: - group: release + group: product-release cancel-in-progress: false jobs: @@ -405,6 +405,27 @@ jobs: - name: Verify the exact product artifact manifest run: node scripts/product-release-artifacts.mjs verify release-assets + - name: Record the immutable publication evidence + env: + GITHUB_SHA: ${{ needs.release-identity.outputs.source_commit }} + SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }} + run: | + node scripts/product-release-artifacts.mjs record \ + release-assets \ + "$RUNNER_TEMP/product-release.json" \ + "$GITHUB_REPOSITORY" \ + "$GITHUB_RUN_ID" \ + "$GITHUB_RUN_ATTEMPT" + + - name: Upload the immutable publication evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: product-release-record-${{ github.run_attempt }} + path: ${{ runner.temp }}/product-release.json + if-no-files-found: error + compression-level: 0 + retention-days: 30 + - name: Revalidate the live ASF source reference env: SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }} diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index c49cbe78b1..121eabfb62 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -32,7 +32,7 @@ retaining Maka's stronger protected-Environment, staged-publishing, 2FA, and Fin ## Release invariants - Dispatch the product Release workflow only from the exact approved ASF source candidate tag. - Dispatch both npm Stage and product Finalize only from the resulting product `v` tag. + Dispatch npm Stage from the resulting product `v` tag and product Finalize from `main`. - Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. @@ -51,11 +51,11 @@ The two workflow boundaries are: tarball, records that single tag commit and workflow run, enters the protected `npm-release` Environment, and submits it to npm staging through OIDC. 2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) accepts only the exact - successful Stage run and Release build run attempts. It verifies the public registry bytes, - signature, provenance, dist-tag, and the live Draft digests against the immutable artifacts from - that Release run, then waits at the protected `product-release` Environment. After independent - Desktop acceptance, approval publishes the GitHub Release together with its Stable/Latest - classification in one operation. + successful Stage run, Release build run, and self-contained publication record. The current + reviewed verifier on `main` checks the public registry bytes, signature, provenance, dist-tag, + immutable build artifacts, and live Draft digests, then waits at the protected `product-release` + Environment. After independent Desktop acceptance, approval publishes the GitHub Release together + with its Stable/Latest classification in one operation. ## One-time control-plane configuration @@ -65,15 +65,20 @@ The checked-in `.asf.yaml` is the authority for the `npm-release` and `product-r Environments. After it reaches `main`, confirm ASF reconciliation produced: -- a selected deployment tag rule matching `v*`, with no branch rule; +- a selected `v*` tag rule for `npm-release` and a selected `main` branch rule for + `product-release`; - `M4n5ter` as the required reviewer; - self-review disabled; - administrator bypass disabled where repository policy permits it; -- no environment secrets or variables. +- `RELEASE_POLICY_APP_CLIENT_ID` as a `product-release` Environment variable and + `RELEASE_POLICY_APP_PRIVATE_KEY` as an Environment secret. Repository administration permission is required to inspect or repair reconciliation. Do not maintain -a second manual Environment policy in GitHub. The workflow itself uses GitHub OIDC and does not read -an npm token. +a second manual Environment policy in GitHub. Install the policy GitHub App only on `apache/maka`, +grant it only repository **Administration: read**, and enable immutable releases for the repository. +Finalize mints a one-hour, repository-scoped installation token, verifies that policy immediately +before publication, and requires the published Release response to report `immutable: true`. The +workflow uses GitHub OIDC for npm and never reads an npm token. ### npm Trusted Publisher @@ -199,16 +204,16 @@ not dist-tag mutations, and the release workflows must not gain a long-lived npm After npm reports the version as public: -1. Open **Actions → Finalize product release → Run workflow** on `v`. +1. Open **Actions → Finalize product release → Run workflow** on `main`. 2. Enter the successful Stage run ID and attempt, the successful Release build run ID and attempt, and the version. 3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. 4. While the publication job waits for `product-release` approval, complete the product checklist's cross-machine acceptance against the Draft. -5. Approve the Environment. Confirm the workflow matches every live Draft digest to the immutable - artifacts from the exact Release attempt, publishes it, and makes a stable release Latest without - a separate manual action. +5. Approve the Environment. Confirm the workflow verifies repository release immutability, matches + every live Draft digest to the exact Release attempt's publication record, publishes an immutable + Release, and makes a stable release Latest without a separate manual action. Check the resulting registry state: @@ -254,10 +259,15 @@ Reject the stage, fix the problem on `main`, increment the product version, crea ### npm approval succeeded but Finalize failed The npm version is already immutable. Do not publish or approve it again. Preserve the Stage run ID, -attempt, version, and artifacts. If the package bytes and provenance are valid, fix the current -Finalize verifier on `main` and rerun Finalize against that same successful Stage identity. - -Finalize is read-only with respect to product release state. If the npm package version, bytes, dist-tag, signature, provenance, or recorded Stage identity differ, stop and investigate; do not modify the product tag or GitHub Release to make npm verification pass. +attempt, version, and the Release run ID, attempt, publication record, and artifacts. If those bytes +and provenance are valid, fix the Finalize verifier on `main` and rerun it against the same immutable +Stage and Release evidence. + +The inspection job is read-only. Only the protected publication job may perform the single +Draft-to-immutable transition. If npm identity, build evidence, or the Draft differs, stop and +investigate; do not modify the product tag or GitHub Release to make verification pass. If failure is +reported after the publication request, inspect the exact Release first: a successful immutable +transition must not be repeated. ### The public version is defective diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 69f1944218..10531441b6 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -43,10 +43,10 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,记录这个唯一的 tag commit 与 workflow run,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; 2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 - Stage run attempt 和 Release build run attempt,验证公共 registry 字节、signature、provenance、 - dist-tag,并将 live Draft digest 与该 Release run 的不可变 artifact 对比,然后等待受保护的 - `product-release` Environment;独立 Desktop 验收完成并批准后,它会在同一个操作中发布 GitHub - Release 及其 Stable/Latest 分类。 + Stage run、Release build run 及其自包含 publication record;`main` 上当前已审查的 verifier + 验证公共 registry 字节、signature、provenance、dist-tag、不可变 build artifacts 与 live Draft + digest,然后等待受保护的 `product-release` Environment;独立 Desktop 验收完成并批准后,它会 + 在同一个操作中发布 GitHub Release 及其 Stable/Latest 分类。 ## 一次性控制面配置 @@ -55,14 +55,18 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 仓库中的 `.asf.yaml` 是 `npm-release` 和 `product-release` Environment 的权威。该配置进入 `main` 后,确认 ASF 同步出的 live 配置满足: -- 使用匹配 `v*` 的 selected deployment tag rule,不配置 branch rule; +- `npm-release` 使用 selected `v*` tag rule,`product-release` 使用 selected `main` branch rule; - required reviewer 为 `M4n5ter`; - 禁止 self-review; - 仓库策略允许时禁用 administrator bypass; -- 不配置 environment secret 或 variable。 +- 在 `product-release` Environment 中配置 variable `RELEASE_POLICY_APP_CLIENT_ID` 与 secret + `RELEASE_POLICY_APP_PRIVATE_KEY`。 检查或修复同步结果需要仓库 administration 权限;不要再在 GitHub UI 中维护第二套手工 -Environment policy。workflow 使用 GitHub OIDC,不读取 npm token。 +Environment policy。policy GitHub App 只能安装到 `apache/maka`,只能拥有仓库 +**Administration: read**,并且仓库必须启用 immutable releases。Finalize 会生成一小时有效、 +仅限本仓库的 installation token,在发布前立即验证该策略,并要求发布响应返回 +`immutable: true`。workflow 使用 GitHub OIDC,不读取 npm token。 ### npm Trusted Publisher @@ -179,14 +183,15 @@ release workflow 不得获得长期 npm token。 npm 显示该版本已经公开后: -1. 在 `v` 上打开 **Actions → Finalize product release → Run workflow**; +1. 在 `main` 上打开 **Actions → Finalize product release → Run workflow**; 2. 输入成功 Stage 的 run ID 与精确 attempt、成功 Release build 的 run ID 与精确 attempt,以及 version; 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; 4. publication job 等待 `product-release` 批准期间,针对 Draft 完成产品检查清单中的跨机器验收; -5. 批准 Environment,并确认 workflow 将每个 live Draft digest 与精确 Release attempt 的不可变 - artifact 对比后发布 Release;stable release 会同时成为 Latest,不再需要单独人工操作。 +5. 批准 Environment,并确认 workflow 验证仓库 release immutability,将每个 live Draft digest + 与精确 Release attempt 的 publication record 对比,发布 immutable Release;stable release + 会同时成为 Latest,不再需要单独人工操作。 检查最终 registry 状态: @@ -228,11 +233,14 @@ npm stage reject "$stage_id" --registry https://registry.npmjs.org/ ### npm approval 成功,但 Finalize 失败 -npm 版本此时已经 immutable,不要再次 publish 或 approve。保留 Stage run ID、attempt、version -和 artifacts。如果 package 字节与 provenance 有效,在 `main` 修复当前 Finalize verifier, -然后针对同一个成功 Stage identity 重新运行 Finalize。 +npm 版本此时已经 immutable,不要再次 publish 或 approve。保留 Stage run ID、attempt、version, +以及 Release run ID、attempt、publication record 和 artifacts。如果这些字节与 provenance +有效,在 `main` 修复 Finalize verifier,然后针对同一组不可变 Stage 与 Release 证据重新运行。 -Finalize 对产品发布状态只读。如果 npm 包版本、字节、dist-tag、签名、provenance 或记录的 Stage identity 不一致,立即停止并调查;不要修改产品 tag 或 GitHub Release 来让 npm 验证通过。 +inspection job 只读;只有受保护的 publication job 可以执行一次 Draft-to-immutable 转换。如果 +npm identity、build evidence 或 Draft 不一致,立即停止并调查;不要修改产品 tag 或 GitHub +Release 来让验证通过。如果错误发生在 publication request 之后,先检查精确 Release;已经成功 +完成的 immutable 转换不得重复执行。 ### 公共版本存在缺陷 diff --git a/scripts/product-release-artifacts.mjs b/scripts/product-release-artifacts.mjs index 252c29d211..196785fa98 100644 --- a/scripts/product-release-artifacts.mjs +++ b/scripts/product-release-artifacts.mjs @@ -19,11 +19,30 @@ import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; -import { copyFile, mkdir, readFile, readdir } from 'node:fs/promises'; +import { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; -import { readProductReleaseIdentity } from './product-release-identity.mjs'; +import { + parseAsfSourceReferenceTag, + readProductReleaseIdentity, +} from './product-release-identity.mjs'; +import { parseProductTag } from './product-release-tag.mjs'; + +const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release.yml'; +const PUBLICATION_RECORD_KEYS = [ + 'schemaVersion', + 'repository', + 'workflow', + 'runId', + 'runAttempt', + 'sourceReferenceTag', + 'sourceCommit', + 'tag', + 'version', + 'prerelease', + 'assets', +]; export function assertExactArtifactSet(actualNames, expectedNames) { const actual = [...new Set(actualNames)].sort(); @@ -84,6 +103,19 @@ function digestFile(path, algorithm = 'sha256') { }); } +async function artifactRecords(directory, names) { + return Promise.all( + [...names].sort().map(async (name) => { + const path = join(directory, name); + const [details, digest] = await Promise.all([stat(path), digestFile(path)]); + if (!details.isFile()) { + throw new Error(`Product release artifact must be a regular file: ${name}`); + } + return { name, size: details.size, digest: `sha256:${digest}` }; + }), + ); +} + export async function verifyProductReleaseArtifactIntegrity(directory, identity) { await verifyProductReleaseArtifactDirectory(directory, allArtifactNames(identity)); const checksumNames = allArtifactNames(identity).filter((name) => name.endsWith('.sha256')); @@ -120,10 +152,141 @@ function allArtifactNames(identity) { return Object.values(identity.artifacts).flat(); } +function exactKeys(value, expected, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const actual = Object.keys(value).sort(); + const keys = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(keys)) { + throw new Error(`${label} fields are invalid`); + } +} + +function assertRunIdentity(runId, runAttempt) { + if ( + typeof runId !== 'string' || + typeof runAttempt !== 'string' || + !/^[1-9]\d*$/u.test(runId) || + !/^[1-9]\d*$/u.test(runAttempt) + ) { + throw new Error('Product release run ID and attempt must be positive integers'); + } +} + +function assertRepository(repository) { + if (typeof repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + throw new Error(`Product repository must be an exact owner/name; found ${repository}`); + } +} + +export function assertProductReleasePublicationRecord(record, expected = {}) { + exactKeys(record, PUBLICATION_RECORD_KEYS, 'Product release publication record'); + if (record.schemaVersion !== 1) { + throw new Error('Unsupported product release publication record'); + } + assertRepository(record.repository); + if (record.workflow !== PRODUCT_RELEASE_WORKFLOW) { + throw new Error(`Product release workflow must be ${PRODUCT_RELEASE_WORKFLOW}`); + } + assertRunIdentity(record.runId, record.runAttempt); + if (!/^[0-9a-f]{40}$/u.test(record.sourceCommit)) { + throw new Error('Product release source commit must be an exact SHA'); + } + const product = parseProductTag(record.tag); + const source = parseAsfSourceReferenceTag(record.sourceReferenceTag); + if ( + record.version !== product.version || + source.version !== product.version || + record.prerelease !== product.prerelease.length > 0 + ) { + throw new Error('Product release publication identity is inconsistent'); + } + for (const [key, value] of Object.entries(expected)) { + if (value !== undefined && record[key] !== value) { + throw new Error(`Product release publication record ${key} does not match`); + } + } + if (!Array.isArray(record.assets) || record.assets.length === 0) { + throw new Error('Product release publication record must contain assets'); + } + const names = new Set(); + for (const asset of record.assets) { + exactKeys(asset, ['name', 'size', 'digest'], 'Product release publication asset'); + if ( + typeof asset.name !== 'string' || + asset.name.length === 0 || + asset.name.includes('/') || + asset.name.includes('\\') || + names.has(asset.name) || + !Number.isSafeInteger(asset.size) || + asset.size < 0 || + !/^sha256:[0-9a-f]{64}$/u.test(asset.digest) + ) { + throw new Error('Product release publication asset is invalid'); + } + names.add(asset.name); + } + if (JSON.stringify(record.assets.map(({ name }) => name)) !== JSON.stringify([...names].sort())) { + throw new Error('Product release publication assets must be sorted'); + } + return record; +} + +export async function createProductReleasePublicationRecord({ + artifactDirectory, + identity, + repository, + runId, + runAttempt, +}) { + assertRepository(repository); + assertRunIdentity(runId, runAttempt); + await verifyProductReleaseArtifactIntegrity(artifactDirectory, identity); + return assertProductReleasePublicationRecord({ + schemaVersion: 1, + repository, + workflow: PRODUCT_RELEASE_WORKFLOW, + runId, + runAttempt, + sourceReferenceTag: identity.sourceReferenceTag, + sourceCommit: identity.sourceCommit, + tag: identity.tag, + version: identity.version, + prerelease: identity.isPrerelease, + assets: await artifactRecords(artifactDirectory, allArtifactNames(identity)), + }); +} + +export async function verifyProductReleasePublicationRecord({ + artifactDirectory, + record, + expected, +}) { + assertProductReleasePublicationRecord(record, expected); + const names = record.assets.map(({ name }) => name); + await verifyProductReleaseArtifactDirectory(artifactDirectory, names); + const actual = await artifactRecords(artifactDirectory, names); + if (JSON.stringify(actual) !== JSON.stringify(record.assets)) { + throw new Error('Product release artifacts do not match the immutable publication record'); + } + return record; +} + +export async function readProductReleasePublicationRecord(path, expected) { + let record; + try { + record = JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + throw new Error('Product release publication record is not valid JSON', { cause: error }); + } + return assertProductReleasePublicationRecord(record, expected); +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [command, ...args] = process.argv.slice(2); - const identity = await readProductReleaseIdentity(); if (command === 'stage') { + const identity = await readProductReleaseIdentity(); const [group, sourceDirectory, targetDirectory] = args; const expectedNames = identity.artifacts[group]; if (!expectedNames || !sourceDirectory || !targetDirectory) { @@ -134,15 +297,64 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await stageProductReleaseArtifactGroup({ sourceDirectory, targetDirectory, expectedNames }); console.log(`Staged exact ${group} product artifacts in ${targetDirectory}`); } else if (command === 'verify') { + const identity = await readProductReleaseIdentity(); const [directory] = args; if (!directory) { throw new Error('usage: product-release-artifacts.mjs verify '); } await verifyProductReleaseArtifactIntegrity(directory, identity); console.log(`Verified exact product release artifact bytes in ${directory}`); + } else if (command === 'record') { + const identity = await readProductReleaseIdentity(); + const [directory, recordPath, repository, runId, runAttempt] = args; + if (!directory || !recordPath || !repository || !runId || !runAttempt) { + throw new Error( + 'usage: product-release-artifacts.mjs record ', + ); + } + const record = await createProductReleasePublicationRecord({ + artifactDirectory: directory, + identity, + repository, + runId, + runAttempt, + }); + await writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o644, + }); + console.log(`Recorded immutable product release evidence in ${recordPath}`); + } else if (command === 'inspect-record') { + const [recordPath, repository, tag, sourceCommit, sourceReferenceTag, runId, runAttempt] = args; + if ( + !recordPath || + !repository || + !tag || + !sourceCommit || + !sourceReferenceTag || + !runId || + !runAttempt + ) { + throw new Error( + 'usage: product-release-artifacts.mjs inspect-record ', + ); + } + await readProductReleasePublicationRecord(recordPath, { + repository, + tag, + sourceCommit, + sourceReferenceTag, + runId, + runAttempt, + }); + console.log(`Verified immutable product release evidence for ${tag}`); } else if (command === 'list' && args.length === 0) { + const identity = await readProductReleaseIdentity(); console.log(JSON.stringify(identity.artifacts, null, 2)); } else { - throw new Error('usage: product-release-artifacts.mjs ...'); + throw new Error( + 'usage: product-release-artifacts.mjs ...', + ); } } diff --git a/scripts/product-release-artifacts.test.mjs b/scripts/product-release-artifacts.test.mjs index 2552b34494..af8cf64f0a 100644 --- a/scripts/product-release-artifacts.test.mjs +++ b/scripts/product-release-artifacts.test.mjs @@ -25,8 +25,11 @@ import { join } from 'node:path'; import test from 'node:test'; import { assertExactArtifactSet, + assertProductReleasePublicationRecord, + createProductReleasePublicationRecord, stageProductReleaseArtifactGroup, verifyProductReleaseArtifactIntegrity, + verifyProductReleasePublicationRecord, } from './product-release-artifacts.mjs'; function updateMetadata(version, artifactName, bytes) { @@ -140,3 +143,77 @@ test('final artifact verification binds checksums and both update channels to ex /checksum does not match/u, ); }); + +test('publication record binds exact build identity and every artifact byte', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-release-publication-record-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const version = '1.2.3'; + const macZip = `Maka-${version}-mac-arm64.zip`; + const exe = `Maka-${version}-win-x64.exe`; + const macBytes = Buffer.from('mac application'); + const windowsBytes = Buffer.from('windows application'); + const names = [ + macZip, + `${macZip}.blockmap`, + 'latest-mac.yml', + exe, + `${exe}.blockmap`, + 'latest.yml', + ]; + await Promise.all([ + writeFile(join(directory, macZip), macBytes), + writeFile(join(directory, `${macZip}.blockmap`), 'mac blockmap'), + writeFile(join(directory, 'latest-mac.yml'), updateMetadata(version, macZip, macBytes)), + writeFile(join(directory, exe), windowsBytes), + writeFile(join(directory, `${exe}.blockmap`), 'windows blockmap'), + writeFile(join(directory, 'latest.yml'), updateMetadata(version, exe, windowsBytes)), + ]); + const identity = { + version, + isPrerelease: false, + tag: `v${version}`, + sourceReferenceTag: `v${version}-incubating-rc1`, + sourceCommit: 'a'.repeat(40), + exe, + artifacts: { test: names }, + }; + const record = await createProductReleasePublicationRecord({ + artifactDirectory: directory, + identity, + repository: 'apache/maka', + runId: '123', + runAttempt: '2', + }); + + assert.equal(record.workflow, '.github/workflows/release.yml'); + assert.deepEqual( + record.assets.map(({ name }) => name), + [...names].sort(), + ); + assert.equal( + assertProductReleasePublicationRecord(record, { + repository: 'apache/maka', + tag: 'v1.2.3', + sourceCommit: 'a'.repeat(40), + sourceReferenceTag: 'v1.2.3-incubating-rc1', + runId: '123', + runAttempt: '2', + }), + record, + ); + await verifyProductReleasePublicationRecord({ + artifactDirectory: directory, + record, + expected: { runId: '123', runAttempt: '2' }, + }); + assert.throws( + () => assertProductReleasePublicationRecord(record, { runAttempt: '3' }), + /runAttempt does not match/u, + ); + + await writeFile(join(directory, macZip), 'tampered mac application'); + await assert.rejects( + verifyProductReleasePublicationRecord({ artifactDirectory: directory, record }), + /do not match the immutable publication record/u, + ); +}); diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs index fac86e0d6c..d50d0028aa 100644 --- a/scripts/product-release-authority.mjs +++ b/scripts/product-release-authority.mjs @@ -18,12 +18,13 @@ */ import { execFile } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { createReadStream } from 'node:fs'; -import { readFile, readdir, stat } from 'node:fs/promises'; -import { join } from 'node:path'; +import { readFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; +import { + readProductReleasePublicationRecord, + verifyProductReleasePublicationRecord, +} from './product-release-artifacts.mjs'; import { parseAsfSourceReferenceTag } from './product-release-identity.mjs'; import { parseProductTag, remoteProductTagCommit } from './product-release-tag.mjs'; @@ -53,24 +54,39 @@ export function assertDraftProductRelease(release, tag) { return release; } -export function assertPublishedProductRelease(release, tag) { +export function assertPublishedProductRelease(release, tag, releaseId, expectedAssets) { const expected = expectedReleaseIdentity(tag); - if (!release || release.tag !== tag || release.draft !== false) { + if (!release || release.id !== releaseId || release.tag !== tag || release.draft !== false) { throw new Error(`GitHub Release ${tag} was not published`); } if (release.prerelease !== expected.isPrerelease) { throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); } + if (release.immutable !== true) { + throw new Error(`GitHub Release ${tag} was published without immutable release protection`); + } + if (JSON.stringify(release.assets) !== JSON.stringify(expectedAssets)) { + throw new Error(`GitHub Release ${tag} assets changed during publication`); + } return release; } +export function assertImmutableReleasePolicy(policy) { + if (policy?.enabled !== true || typeof policy.enforced_by_owner !== 'boolean') { + throw new Error('Immutable releases must be enabled before product publication'); + } + return policy; +} + function releaseSnapshotFromGhView(value) { return { id: value?.databaseId, tag: value?.tagName, draft: value?.isDraft, prerelease: value?.isPrerelease, - assets: value?.assets ?? [], + assets: (value?.assets ?? []) + .map(({ name, size, digest }) => ({ name, size, digest })) + .sort((left, right) => left.name.localeCompare(right.name)), }; } @@ -80,6 +96,7 @@ function releaseSnapshotFromRest(value) { tag: value?.tag_name, draft: value?.draft, prerelease: value?.prerelease, + immutable: value?.immutable, assets: value?.assets ?? [], }; } @@ -113,27 +130,6 @@ export function assertProductReleaseWorkflowRun({ return run; } -function digestFile(path) { - return new Promise((resolvePromise, reject) => { - const hash = createHash('sha256'); - const stream = createReadStream(path); - stream.once('error', reject); - stream.on('data', (chunk) => hash.update(chunk)); - stream.once('end', () => resolvePromise(hash.digest('hex'))); - }); -} - -async function localAssetRecords(directory) { - const records = []; - for (const entry of await readdir(directory, { withFileTypes: true })) { - if (!entry.isFile()) throw new Error(`Release artifact must be a regular file: ${entry.name}`); - const path = join(directory, entry.name); - const [details, digest] = await Promise.all([stat(path), digestFile(path)]); - records.push({ name: entry.name, size: details.size, digest: `sha256:${digest}` }); - } - return records.sort((left, right) => left.name.localeCompare(right.name)); -} - export async function verifyDraftProductRelease({ tag, sourceCommit, @@ -189,25 +185,72 @@ export async function publishDraftProductRelease({ sourceCommit, repository, artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + policyToken = process.env.RELEASE_POLICY_TOKEN, cwd = process.cwd(), run = execFileAsync, pause = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)), }) { - const localAssets = await localAssetRecords(artifactDirectory); + if (typeof policyToken !== 'string' || policyToken.length === 0) { + throw new Error('Product publication requires a read-only repository policy token'); + } + const evidence = await readProductReleasePublicationRecord(publicationRecordPath, { + repository, + tag, + sourceCommit, + sourceReferenceTag, + runId: releaseRunId, + runAttempt: releaseRunAttempt, + }); + await verifyProductReleasePublicationRecord({ + artifactDirectory, + record: evidence, + expected: { + repository, + tag, + sourceCommit, + sourceReferenceTag, + runId: releaseRunId, + runAttempt: releaseRunAttempt, + }, + }); const draft = await verifyDraftProductRelease({ tag, sourceCommit, repository, cwd, run }); const remoteAssets = (draft.assets ?? []) .map(({ name, size, digest }) => ({ name, size, digest })) .sort((left, right) => left.name.localeCompare(right.name)); - if (JSON.stringify(remoteAssets) !== JSON.stringify(localAssets)) { + if (JSON.stringify(remoteAssets) !== JSON.stringify(evidence.assets)) { throw new Error('Draft GitHub Release assets do not match the verified Release run artifacts'); } + const policy = await run( + 'gh', + [ + 'api', + '--header', + 'X-GitHub-Api-Version: 2026-03-10', + `repos/${repository}/immutable-releases`, + ], + { cwd, env: { ...process.env, GH_TOKEN: policyToken } }, + ); + let parsedPolicy; + try { + parsedPolicy = JSON.parse(policy.stdout); + } catch (error) { + throw new Error('GitHub returned an invalid immutable release policy', { cause: error }); + } + assertImmutableReleasePolicy(parsedPolicy); + const isPrerelease = expectedReleaseIdentity(tag).isPrerelease; const published = await run( 'gh', [ 'api', + '--header', + 'X-GitHub-Api-Version: 2026-03-10', '--method', 'PATCH', `repos/${repository}/releases/${draft.id}`, @@ -226,7 +269,7 @@ export async function publishDraftProductRelease({ } catch (error) { throw new Error(`GitHub returned an invalid publication result for ${tag}`, { cause: error }); } - assertPublishedProductRelease(record, tag); + assertPublishedProductRelease(record, tag, draft.id, evidence.assets); if (!isPrerelease) { let latestTag; @@ -252,7 +295,7 @@ export async function publishDraftProductRelease({ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [command, ...args] = process.argv.slice(2); const usage = - 'usage: product-release-authority.mjs verify-build-run | [artifact-directory]'; + 'usage: product-release-authority.mjs verify-build-run | verify-draft | publish-draft '; if (command === 'verify-build-run' && args.length === 6) { const [runPath, tag, sourceCommit, repository, runId, runAttempt] = args; const run = JSON.parse(await readFile(runPath, 'utf8')); @@ -262,13 +305,26 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const [tag, sourceCommit, repository] = args; await verifyDraftProductRelease({ tag, sourceCommit, repository }); console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); - } else if (command === 'publish-draft' && args.length === 4) { - const [tag, sourceCommit, repository, artifactDirectory] = args; + } else if (command === 'publish-draft' && args.length === 8) { + const [ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + ] = args; await publishDraftProductRelease({ tag, sourceCommit, repository, artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, }); console.log(`Published product Release ${tag} from ${sourceCommit}`); } else { diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs index a2a097f03e..822652b1e1 100644 --- a/scripts/product-release-authority.test.mjs +++ b/scripts/product-release-authority.test.mjs @@ -25,6 +25,7 @@ import { join } from 'node:path'; import test from 'node:test'; import { assertDraftProductRelease, + assertImmutableReleasePolicy, assertProductReleaseWorkflowRun, assertPublishedProductRelease, publishDraftProductRelease, @@ -62,19 +63,55 @@ test('Draft state and prerelease classification are one product Release contract test('published state keeps stable and prerelease classification exact', () => { assert.equal( assertPublishedProductRelease( - { id: 42, tag: 'v1.2.3', draft: false, prerelease: false }, + { id: 42, tag: 'v1.2.3', draft: false, prerelease: false, immutable: true, assets: [] }, 'v1.2.3', + 42, + [], ).tag, 'v1.2.3', ); assert.throws( () => assertPublishedProductRelease( - { id: 42, tag: 'v1.2.3-beta.1', draft: false, prerelease: false }, + { + id: 42, + tag: 'v1.2.3-beta.1', + draft: false, + prerelease: false, + immutable: true, + assets: [], + }, 'v1.2.3-beta.1', + 42, + [], ), /prerelease state/u, ); + assert.throws( + () => + assertPublishedProductRelease( + { + id: 42, + tag: 'v1.2.3', + draft: false, + prerelease: false, + immutable: false, + assets: [], + }, + 'v1.2.3', + 42, + [], + ), + /without immutable release protection/u, + ); + assert.equal( + assertImmutableReleasePolicy({ enabled: true, enforced_by_owner: false }).enabled, + true, + ); + assert.throws( + () => assertImmutableReleasePolicy({ enabled: false, enforced_by_owner: false }), + /must be enabled/u, + ); }); test('Release workflow evidence binds an exact successful attempt to approved source', () => { @@ -190,11 +227,30 @@ test('the live authority verifier rejects product tag drift before later checks' test('publication verifies live asset digests before one Stable/Latest mutation', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'maka-publish-authority-')); + const recordDirectory = await mkdtemp(join(tmpdir(), 'maka-publish-record-')); t.after(() => rm(directory, { recursive: true, force: true })); + t.after(() => rm(recordDirectory, { recursive: true, force: true })); const contents = Buffer.from('verified artifact'); await writeFile(join(directory, 'artifact.zip'), contents); const digest = `sha256:${createHash('sha256').update(contents).digest('hex')}`; + const publicationRecordPath = join(recordDirectory, 'product-release.json'); const sourceCommit = 'a'.repeat(40); + await writeFile( + publicationRecordPath, + `${JSON.stringify({ + schemaVersion: 1, + repository: 'apache/maka', + workflow: '.github/workflows/release.yml', + runId: '123', + runAttempt: '2', + sourceReferenceTag: 'v1.2.3-incubating-rc1', + sourceCommit, + tag: 'v1.2.3', + version: '1.2.3', + prerelease: false, + assets: [{ name: 'artifact.zip', size: contents.length, digest }], + })}\n`, + ); const calls = []; let latestReads = 0; const run = async (command, args) => { @@ -213,9 +269,19 @@ test('publication verifies live asset digests before one Stable/Latest mutation' }), }; } + if (command === 'gh' && args.includes('repos/apache/maka/immutable-releases')) { + return { stdout: JSON.stringify({ enabled: true, enforced_by_owner: false }) }; + } if (command === 'gh' && args.includes('PATCH')) { return { - stdout: JSON.stringify({ tag_name: 'v1.2.3', draft: false, prerelease: false }), + stdout: JSON.stringify({ + id: 42, + tag_name: 'v1.2.3', + draft: false, + prerelease: false, + immutable: true, + assets: [{ name: 'artifact.zip', size: contents.length, digest }], + }), }; } if (command === 'gh' && args.includes('repos/apache/maka/releases/latest')) { @@ -230,6 +296,11 @@ test('publication verifies live asset digests before one Stable/Latest mutation' sourceCommit, repository: 'apache/maka', artifactDirectory: directory, + publicationRecordPath, + sourceReferenceTag: 'v1.2.3-incubating-rc1', + releaseRunId: '123', + releaseRunAttempt: '2', + policyToken: 'policy-token', run, pause: async () => {}, }); @@ -240,5 +311,10 @@ test('publication verifies live asset digests before one Stable/Latest mutation' assert.ok(patchCall[1].includes('prerelease=false')); assert.ok(patchCall[1].includes('make_latest=true')); assert.ok(calls.every(([, args]) => !args.includes('repos/apache/maka/releases/tags/v1.2.3'))); + const policyCall = calls.find(([, args]) => + args.includes('repos/apache/maka/immutable-releases'), + ); + assert.ok(policyCall); + assert.ok(calls.indexOf(policyCall) < calls.indexOf(patchCall)); assert.equal(latestReads, 2); }); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 43185ea9c4..2855c8e792 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -713,6 +713,26 @@ test('one product workflow gates one draft release on every required artifact', (step) => step.name === 'Verify the exact product artifact manifest', ); assert.ok(installVerifier >= 0 && installVerifier < verifyManifest); + const publicationRecord = jobs.publish.steps.find( + (step) => step.name === 'Record the immutable publication evidence', + ); + assert.match(publicationRecord.run, /product-release-artifacts\.mjs record/u); + assert.equal( + publicationRecord.env.GITHUB_SHA, + '${{ needs.release-identity.outputs.source_commit }}', + ); + assert.equal( + publicationRecord.env.SOURCE_REFERENCE_TAG, + '${{ needs.release-identity.outputs.source_reference_tag }}', + ); + const publicationRecordUpload = jobs.publish.steps.find( + (step) => step.name === 'Upload the immutable publication evidence', + ); + assert.equal( + publicationRecordUpload.with.name, + 'product-release-record-${{ github.run_attempt }}', + ); + assert.equal(publicationRecordUpload.with['retention-days'], 30); const commands = Object.values(jobs) .flatMap((job) => job.steps ?? []) @@ -760,14 +780,14 @@ test('one product workflow gates one draft release on every required artifact', assert.doesNotMatch(commands, /cli-v|npm (?:stage )?publish/u); }); -test('repository control plane admits only reviewed immutable release tags', async () => { +test('repository control plane admits only each release phase owner ref', async () => { const config = parseYaml(await readFile(new URL('../.asf.yaml', import.meta.url), 'utf8')); assert.deepEqual(config.github.protected_branches.main.required_status_checks.contexts, ['test']); const environments = config.github.environments; - for (const [name, tagPattern] of [ - ['release', 'v*-incubating-rc*'], - ['npm-release', 'v*'], - ['product-release', 'v*'], + for (const [name, pattern, type] of [ + ['release', 'v*-incubating-rc*', 'tag'], + ['npm-release', 'v*', 'tag'], + ['product-release', 'main', 'branch'], ]) { assert.deepEqual(environments[name], { required_reviewers: [{ id: 'M4n5ter', type: 'User' }], @@ -775,7 +795,7 @@ test('repository control plane admits only reviewed immutable release tags', asy prevent_self_review: true, deployment_branch_policy: { protected_branches: false, - policies: [{ name: tagPattern, type: 'tag' }], + policies: [{ name: pattern, type }], }, }); } diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index ecf7c217af..af62249f0f 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -87,9 +87,12 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); -test('finalize validates exact stage and Release attempts from the product tag', () => { +test('finalize runs the current verifier from reviewed main against exact immutable evidence', () => { const workflow = readWorkflow('release-cli-finalize.yml'); + const productWorkflow = readWorkflow('release.yml'); const steps = workflowSteps(workflow); + assert.match(workflow, /concurrency:\n group: product-release/u); + assert.match(productWorkflow, /concurrency:\n group: product-release/u); assert.match(workflow, /stage_run_attempt:[\s\S]*?required: true/u); const loadIndex = workflow.indexOf('name: Load the exact stage workflow run'); const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); @@ -99,8 +102,16 @@ test('finalize validates exact stage and Release attempts from the product tag', assert.match(workflow, /actions\/runs\/\$RELEASE_RUN_ID\/attempts\/\$RELEASE_RUN_ATTEMPT/u); const checkout = namedStep(steps, 'Check out the current release verifier'); assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); - const exactTag = namedStep(steps, 'Require the exact product tag'); - assert.match(exactTag, /refs\/tags\/\$PRODUCT_TAG/u); + const requireMain = namedStep(steps, 'Require main'); + assert.match(requireMain, /refs\/heads\/main/u); + const recordDownloads = steps.filter((step) => + step.includes('name: Download the immutable publication record'), + ); + assert.equal(recordDownloads.length, 2); + for (const download of recordDownloads) { + assert.match(download, /product-release-record-\$\{\{ inputs\.release_run_attempt \}\}/u); + assert.match(download, /run-id: \$\{\{ inputs\.release_run_id \}\}/u); + } }); test('finalize revalidates the live product release before trusting public npm bytes', () => { @@ -111,6 +122,7 @@ test('finalize revalidates the live product release before trusting public npm b assert.match(record, /"\$GITHUB_OUTPUT"/u); const authority = namedStep(steps, 'Revalidate the product release authority'); assert.match(authority, /product-release-authority\.mjs verify-build-run/u); + assert.match(authority, /product-release-artifacts\.mjs inspect-record/u); assert.match(authority, /product-release-authority\.mjs verify-draft/u); assert.ok( workflow.indexOf(authority) < workflow.indexOf('Fetch and verify the public registry bytes'), @@ -124,14 +136,21 @@ test('finalize preserves npm evidence and owns the single product publication bo assert.doesNotMatch(workflow, /cli-v/u); assert.match(workflow, /name: product-release/u); assert.match(workflow, /contents: write/u); - assert.match(workflow, /product-release-artifacts\.mjs verify/u); assert.match(workflow, /product-release-authority\.mjs publish-draft/u); + assert.match(workflow, /actions\/create-github-app-token@[0-9a-f]{40}/u); + assert.match(workflow, /permission-administration: read/u); + assert.match( + workflow, + /RELEASE_POLICY_TOKEN: \$\{\{ steps\.release-policy\.outputs\.token \}\}/u, + ); const artifacts = namedStep( workflowSteps(workflow), 'Download the exact verified Release run artifacts', ); assert.match(artifacts, /run-id: \$\{\{ inputs\.release_run_id \}\}/u); assert.match(artifacts, /release-\*-\$\{\{ inputs\.release_run_attempt \}\}/u); + assert.doesNotMatch(workflow, /ref: \$\{\{ needs\.inspect\.outputs\.source_commit \}\}/u); + assert.doesNotMatch(workflow, /product-release-artifacts\.mjs verify/u); assert.doesNotMatch(workflow, /gh release download/u); assert.doesNotMatch(workflow, /gh release edit|gh release create/u); }); From e6aabf05609c58c60de13b93d85924457071fbfb Mon Sep 17 00:00:00 2001 From: Wang Date: Thu, 27 Aug 2026 21:52:30 +0800 Subject: [PATCH 4/7] fix(release): attest convenience updates --- .github/RELEASE_CHECKLIST.md | 36 +- .github/workflows/release-cli-finalize.yml | 37 +- .github/workflows/release.yml | 4 +- apps/desktop/package.json | 3 + .../licenses/npm/THIRD_PARTY_NOTICES.txt | 1377 ++++++++++++++++- .../__tests__/app-update-attestation.test.ts | 125 ++ .../__tests__/app-update-focus-check.test.ts | 1 + .../main/__tests__/app-update-service.test.ts | 58 + .../src/main/app-update-attestation.ts | 217 +++ apps/desktop/src/main/app-update-service.ts | 45 +- apps/desktop/src/main/runtime-host-boot.ts | 15 +- apps/desktop/src/preload/bridge-contract.d.ts | 1 + .../locales/settings-preferences-copy.ts | 3 + .../renderer/settings/about-update-status.ts | 1 + docs/cli-distribution.md | 13 +- docs/cli-npm-release.md | 34 +- docs/cli-npm-release.zh-CN.md | 25 +- package-lock.json | 108 +- scripts/generate-third-party-notices.mjs | 5 +- scripts/product-release-authority.mjs | 95 +- scripts/product-release-authority.test.mjs | 65 +- scripts/release-cli-workflow-policy.test.mjs | 15 +- 22 files changed, 2084 insertions(+), 199 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/app-update-attestation.test.ts create mode 100644 apps/desktop/src/main/app-update-attestation.ts diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 2ada8a8e2c..92710f9e74 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -19,9 +19,10 @@ # Product release checklist -The `Release` workflow is Maka's convenience-artifact release entry point. Desktop and CLI/TUI are +The IPMC-approved source archive on ASF distribution infrastructure is the Apache release. The +`Release` workflow is Maka's convenience-artifact distribution entry point. Desktop and CLI/TUI are built from the exact IPMC-approved ASF source candidate commit. They share that source commit, the -root product version, one convenience tag, one GitHub Release, one Draft decision, and one release +root product version, one convenience tag, one GitHub Release, one Draft decision, and one distribution gate. The workflow creates no Draft until every required artifact job succeeds. Phase 1 requires: @@ -31,7 +32,7 @@ Phase 1 requires: - the signed, notarized, relocatable Apple Silicon CLI/TUI ZIP; - checksums generated after each artifact reaches its final form. -The ASF Desktop artifacts must not contain a Git runtime, a bundled-Git manifest, or Git/Dugite +The convenience Desktop artifacts must not contain a Git runtime, a bundled-Git manifest, or Git/Dugite redistribution notices. Managed-workspace execution remains unavailable until a separately reviewed, ASF-compatible verified runtime is connected before admission/T1. @@ -61,13 +62,12 @@ Before the first product release, confirm the checked-in `.asf.yaml` has reconci - the `Immutable release tags` ruleset blocks updates, force-pushes, and deletions of `v*` tags; - the `release` and `npm-release` Environments accept only their declared tag patterns, - `product-release` accepts only `main`, and each requires a reviewer other than the triggering user; -- enable immutable releases so assets and the associated tag cannot change after publication. -- install the release-policy GitHub App only on `apache/maka` with repository - **Administration: read**, then configure its client ID and private key only in the - `product-release` Environment. + `product-release` accepts only `main`, and each requires a reviewer other than the triggering user. -These controls close the check-to-upload and check-to-stage windows. Keep the Release in Draft while assets and acceptance are incomplete; publishing early must make subsequent mutation fail closed. +These controls close the check-to-upload and check-to-stage windows. Finalize uses GitHub Actions +OIDC rather than a stored signing key to attest every convenience artifact. Keep the Release in +Draft while assets and acceptance are incomplete; Desktop rejects downloaded updates whose exact +bytes and expected filename are not covered by that protected workflow identity. ## Create the complete Draft @@ -120,9 +120,9 @@ Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact p Keep the GitHub Release in Draft throughout this sequence. The final workflow job waits at the `product-release` Environment. Approve it only after every npm and cross-machine acceptance check has passed. It verifies the live Draft digests against the immutable publication record from the -exact successful Release run, verifies the repository immutable-release policy, then publishes an -immutable release and makes a stable release Latest in the same GitHub operation; prereleases remain -non-Latest. Do not publish or +exact successful Release run, creates Sigstore provenance and an offline +`Maka--attestation.sigstore.json` bundle, then publishes the convenience Release and makes a +stable release Latest in the same GitHub operation; prereleases remain non-Latest. Do not publish or change the Latest designation manually. A failed or rejected npm candidate requires a new product version; never publish the Draft to work around npm state. @@ -168,3 +168,15 @@ candidate tag and convenience `v` tag still resolve to the same recorde only after npm verification and both independent-machine acceptance passes. If any required artifact, npm step, or acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product version, and run the full workflow again. Never replace an existing release identity. + +After Finalize publishes the convenience Release, download its attestation bundle and verify each +installer or archive independently: + +```sh +gh attestation verify path/to/Maka--mac-arm64.zip \ + --bundle path/to/Maka--attestation.sigstore.json \ + --repo apache/maka \ + --signer-workflow apache/maka/.github/workflows/release-cli-finalize.yml +``` + +Desktop performs the same trust decision before exposing a downloaded update for installation. diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index b37a06805d..dd241bb41c 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -124,7 +124,7 @@ jobs: repository: ${{ github.repository }} run-id: ${{ inputs.stage_run_id }} - - name: Download the immutable publication record + - name: Download the publication record uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: product-release-record-${{ inputs.release_run_attempt }} @@ -198,7 +198,7 @@ jobs: retention-days: 30 publish: - name: Publish the immutable product release + name: Publish the attested convenience release needs: inspect runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -207,7 +207,10 @@ jobs: url: https://github.com/apache/maka/releases/tag/${{ needs.inspect.outputs.product_tag }} permissions: actions: read + artifact-metadata: write + attestations: write contents: write + id-token: write steps: - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -216,14 +219,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Mint a read-only repository policy token - id: release-policy - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 - with: - client-id: ${{ vars.RELEASE_POLICY_APP_CLIENT_ID }} - private-key: ${{ secrets.RELEASE_POLICY_APP_PRIVATE_KEY }} - permission-administration: read - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' @@ -239,7 +234,7 @@ jobs: repository: ${{ github.repository }} run-id: ${{ inputs.release_run_id }} - - name: Download the immutable publication record + - name: Download the publication record uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: product-release-record-${{ inputs.release_run_attempt }} @@ -248,10 +243,23 @@ jobs: repository: ${{ github.repository }} run-id: ${{ inputs.release_run_id }} - - name: Publish the verified release and update its channel atomically + - name: Attest the verified convenience artifacts + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 + with: + subject-path: ${{ runner.temp }}/product-release/* + + - name: Name the offline verification bundle + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + PRODUCT_VERSION: ${{ inputs.version }} + run: >- + cp -- "$ATTESTATION_BUNDLE" + "$RUNNER_TEMP/Maka-${PRODUCT_VERSION}-attestation.sigstore.json" + + - name: Publish the verified convenience release env: GH_TOKEN: ${{ github.token }} - RELEASE_POLICY_TOKEN: ${{ steps.release-policy.outputs.token }} PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} PRODUCT_SOURCE_REFERENCE_TAG: ${{ needs.inspect.outputs.source_reference_tag }} PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} @@ -264,4 +272,5 @@ jobs: "$RUNNER_TEMP/product-release-record/product-release.json" \ "$PRODUCT_SOURCE_REFERENCE_TAG" \ "${{ inputs.release_run_id }}" \ - "${{ inputs.release_run_attempt }}" + "${{ inputs.release_run_attempt }}" \ + "$RUNNER_TEMP/Maka-${{ inputs.version }}-attestation.sigstore.json" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0463d1b1a6..73006cb1a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -462,7 +462,9 @@ jobs: The Windows build is unsigned: SmartScreen warns on first launch, and the download has to be checked against its .sha256 file. - Known limitations: Computer Use and managed-workspace execution are not included in this release. The ASF Desktop artifacts do not distribute a Git runtime." + These files are convenience binaries built from the approved ASF source release; they are not ASF release artifacts. + + Known limitations: Computer Use and managed-workspace execution are not included in this release. The Desktop convenience artifacts do not distribute a Git runtime." classification=(--prerelease=false --latest=false) if [[ "$IS_PRERELEASE" == "true" ]]; then diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7551ebbc5c..464cd0c2d6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,9 @@ "@maka/runtime": "0.1.0", "@maka/runtime-host": "0.1.0", "@maka/storage": "0.1.0", + "@sigstore/bundle": "5.0.0", + "@sigstore/tuf": "5.0.0", + "@sigstore/verify": "4.1.2", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 71ec5e9c50..9ffc332ebd 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -987,6 +987,35 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: @gar/promise-retry@1.0.3 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/wraithgar/node-promise-retry.git + +--- LICENSE --- +Copyright (c) 2011 Tim Koschützki (tim@debuggable.com), Felix Geisendörfer (felix@debuggable.com) +Copyright (c) 2014 IndigoUnited + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ + Package: @iconify/types@2.0.0 Declared license: MIT Selected license: MIT @@ -2096,52 +2125,1102 @@ met: may be used to endorse or promote products derived from this software without specific prior written permission. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + +Package: @protobufjs/utf8@1.1.2 +Declared license: BSD-3-Clause +Selected license: BSD-3-Clause +Repository: https://github.com/protobufjs/protobuf.js.git + +--- LICENSE --- +Copyright (c) 2016, Daniel Wirtz All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of its author, nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + +Package: @sigstore/bundle@5.0.0 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/core@4.0.1 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/protobuf-specs@0.5.2 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/protobuf-specs.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/tuf@5.0.0 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/verify@4.1.2 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- VERSION-PINNED LICENSE TEXT OVERRIDE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS -================================================================================ + APPENDIX: How to apply the Apache License to your work. -Package: @protobufjs/utf8@1.1.2 -Declared license: BSD-3-Clause -Selected license: BSD-3-Clause -Repository: https://github.com/protobufjs/protobuf.js.git + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. ---- LICENSE --- -Copyright (c) 2016, Daniel Wirtz All rights reserved. + Copyright [yyyy] [name of copyright owner] -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. -* Neither the name of its author, nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. + http://www.apache.org/licenses/LICENSE-2.0 -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ================================================================================ @@ -2329,6 +3408,66 @@ SOFTWARE. ================================================================================ +Package: @tufjs/canonical-json@2.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + +Package: @tufjs/models@5.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: @types/d3@7.4.3 Declared license: MIT Selected license: MIT @@ -4238,6 +5377,38 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: balanced-match@4.0.4 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/juliangruber/balanced-match.git + +--- LICENSE.md --- +(MIT) + +Original code Copyright Julian Gruber + +Port to TypeScript Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: bare-addon-resolve@1.10.1 Declared license: Apache-2.0 Selected license: Apache-2.0 @@ -4890,6 +6061,38 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: brace-expansion@5.0.9 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/juliangruber/brace-expansion.git + +--- LICENSE --- +MIT License + +Copyright Julian Gruber + +TypeScript port Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: builder-util-runtime@9.7.0 Declared license: MIT Selected license: MIT @@ -10211,6 +11414,70 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ +Package: minimatch@10.2.5 +Declared license: BlueOak-1.0.0 +Selected license: BlueOak-1.0.0 +Repository: git@github.com:isaacs/minimatch + +--- LICENSE.md --- +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +**_As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim._** + +================================================================================ + Package: minisearch@7.2.0 Declared license: MIT Selected license: MIT @@ -12313,6 +13580,36 @@ PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: tuf-js@6.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: turndown@7.2.4 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts new file mode 100644 index 0000000000..6f2fd8d8bc --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Bundle } from '@sigstore/bundle'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + assertProductReleaseAttestationSubject, + productReleaseAttestationName, + productReleaseAttestationUrl, + verifyDownloadedUpdateAttestation, +} from '../app-update-attestation.js'; + +function provenanceBundle(name: string, sha256: string): Bundle { + const statement = { + _type: 'https://in-toto.io/Statement/v1', + subject: [{ name, digest: { sha256 } }], + predicateType: 'https://slsa.dev/provenance/v1', + predicate: {}, + }; + return { + mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json', + verificationMaterial: { + content: undefined, + tlogEntries: [], + timestampVerificationData: undefined, + }, + content: { + $case: 'dsseEnvelope', + dsseEnvelope: { + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from(JSON.stringify(statement)), + signatures: [], + }, + }, + } as unknown as Bundle; +} + +test('product attestation location is one version-derived release asset', () => { + assert.equal(productReleaseAttestationName('1.2.3-beta.1'), 'Maka-1.2.3-beta.1-attestation.sigstore.json'); + assert.equal( + productReleaseAttestationUrl('1.2.3-beta.1'), + 'https://github.com/apache/maka/releases/download/v1.2.3-beta.1/Maka-1.2.3-beta.1-attestation.sigstore.json', + ); + assert.throws(() => productReleaseAttestationName('../escape'), /cannot identify/u); +}); + +test('attestation subject must bind the exact update name and digest', () => { + const digest = 'a'.repeat(64); + const bundle = provenanceBundle('Maka-1.2.3-mac-arm64.zip', digest); + assert.doesNotThrow(() => + assertProductReleaseAttestationSubject(bundle, 'Maka-1.2.3-mac-arm64.zip', digest), + ); + assert.throws( + () => assertProductReleaseAttestationSubject(bundle, 'Maka-1.2.3-win-x64.exe', digest), + /does not identify/u, + ); + assert.throws( + () => assertProductReleaseAttestationSubject(bundle, 'Maka-1.2.3-mac-arm64.zip', 'b'.repeat(64)), + /does not identify/u, + ); +}); + +test('download verification checks cryptography before accepting the exact artifact subject', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-update-attestation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const artifact = join(directory, 'cached-update.zip'); + const bytes = Buffer.from('attested update bytes'); + await writeFile(artifact, bytes); + const digest = createHash('sha256').update(bytes).digest('hex'); + const bundle = provenanceBundle('Maka-1.2.3-mac-arm64.zip', digest); + let cryptographicVerifications = 0; + + await verifyDownloadedUpdateAttestation({ + downloadedFile: artifact, + version: '1.2.3', + platform: 'darwin', + arch: 'arm64', + trustRootCacheDirectory: join(directory, 'trust'), + fetchBundle: async (url) => { + assert.equal(url, productReleaseAttestationUrl('1.2.3')); + return Buffer.from(JSON.stringify({ + mediaType: bundle.mediaType, + verificationMaterial: { + certificate: { rawBytes: Buffer.from('fixture certificate').toString('base64') }, + tlogEntries: [], + }, + dsseEnvelope: { + payloadType: bundle.content.$case === 'dsseEnvelope' + ? bundle.content.dsseEnvelope.payloadType + : '', + payload: bundle.content.$case === 'dsseEnvelope' + ? Buffer.from(bundle.content.dsseEnvelope.payload).toString('base64') + : '', + signatures: [{ sig: Buffer.from('fixture signature').toString('base64') }], + }, + })); + }, + verifyBundle: async () => { + cryptographicVerifications += 1; + }, + }); + + assert.equal(cryptographicVerifications, 1); +}); diff --git a/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts b/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts index d453f7b05c..2f2f3a2977 100644 --- a/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts @@ -50,6 +50,7 @@ function createHarness(options: { start: number }) { currentVersion: '0.1.8', isPackaged: true, updater, + verifyDownloadedUpdate: async () => {}, prepareInstall: async () => ({ kind: 'prepared', rollback() {} }), clock: { // No scheduled checks in these tests: the timer is a separate trigger and diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index e42e517204..8981f98ad7 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -28,6 +28,7 @@ import { type AppUpdateInstallRequest, type AppUpdateStatus, } from '../app-update-service.js'; +import type { DownloadedUpdateAttestationVerifier } from '../app-update-attestation.js'; const FIRST_UPDATE_CHECK_DELAY_MS = 10_000; const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; @@ -130,6 +131,7 @@ function createHarness(input: { mockLatestVersion?: string; mockState?: 'available' | 'downloading' | 'downloaded'; testFeedUrl?: string; + verifyDownloadedUpdate?: DownloadedUpdateAttestationVerifier; } = {}) { const updater = input.updater ?? new FakeUpdater(); const clock = input.clock ?? new FakeClock(); @@ -146,10 +148,15 @@ function createHarness(input: { mockLatestVersion: input.mockLatestVersion, mockState: input.mockState, testFeedUrl: input.testFeedUrl, + verifyDownloadedUpdate: input.verifyDownloadedUpdate ?? (async () => {}), }); return { clock, service, updater }; } +async function settleUpdateVerification(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + describe('AppUpdateService', () => { test('owns one main-process schedule and configures background downloads', async () => { const { clock, service, updater } = createHarness(); @@ -326,11 +333,13 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual(statuses.map((status) => status.state), [ 'checking', 'available', 'downloading', + 'verifying', 'downloaded', ]); assert.deepEqual(service.getStatus(), { @@ -340,6 +349,50 @@ describe('AppUpdateService', () => { }); }); + test('does not expose an update for installation until provenance verification succeeds', async () => { + let finishVerification!: () => void; + const { service, updater } = createHarness({ + verifyDownloadedUpdate: () => new Promise((resolve) => { + finishVerification = resolve; + }), + }); + updater.emit('update-downloaded', { + ...updateInfo('1.1.0'), + downloadedFile: '/tmp/maka-update.zip', + }); + + assert.equal(service.getStatus().state, 'verifying'); + assert.deepEqual(await service.installUpdate({ allowInterruptActiveTasks: false }), { + ok: false, + reason: 'not_downloaded', + }); + finishVerification(); + await settleUpdateVerification(); + assert.equal(service.getStatus().state, 'downloaded'); + }); + + test('fails closed when downloaded update provenance cannot be verified', async () => { + const { service, updater } = createHarness({ + verifyDownloadedUpdate: async () => { + throw new Error('release provenance did not match'); + }, + }); + updater.emit('update-downloaded', { + ...updateInfo('1.1.0'), + downloadedFile: '/tmp/maka-update.zip', + }); + await settleUpdateVerification(); + + assert.deepEqual(service.getStatus(), { + state: 'error', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + operation: 'download', + message: 'release provenance did not match', + }); + assert.equal(updater.quitAndInstallCalls, 0); + }); + test('cancels a stalled auto-download before retrying it', async () => { const updater = new FakeUpdater(); const statuses: AppUpdateStatus[] = []; @@ -421,6 +474,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await service.installUpdate({ allowInterruptActiveTasks: false }), @@ -439,6 +493,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await idle.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true }, @@ -461,6 +516,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual(await service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true, @@ -483,6 +539,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await synchronous.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: false, reason: 'install_failed' }, @@ -510,6 +567,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await asynchronous.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true }, diff --git a/apps/desktop/src/main/app-update-attestation.ts b/apps/desktop/src/main/app-update-attestation.ts new file mode 100644 index 0000000000..5855f49a3e --- /dev/null +++ b/apps/desktop/src/main/app-update-attestation.ts @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { bundleFromJSON, type Bundle } from '@sigstore/bundle'; +import { getTrustedRoot } from '@sigstore/tuf'; +import { toSignedEntity, toTrustMaterial, Verifier } from '@sigstore/verify'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; + +const PRODUCT_REPOSITORY = 'apache/maka'; +const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml'; +const PRODUCT_RELEASE_SIGNER = new RegExp( + `^https://github\\.com/${PRODUCT_REPOSITORY.replace('/', '\\/')}/${PRODUCT_RELEASE_WORKFLOW.replaceAll('.', '\\.')}` + + '@refs/heads/main$', + 'u', +); +const GITHUB_ACTIONS_OIDC_ISSUER = 'https://token.actions.githubusercontent.com'; +const IN_TOTO_STATEMENT_V1 = 'https://in-toto.io/Statement/v1'; +const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1'; +const MAX_ATTESTATION_BYTES = 5 * 1024 * 1024; + +type AttestationSubject = { + readonly name?: unknown; + readonly digest?: unknown; +}; + +type AttestationStatement = { + readonly _type?: unknown; + readonly predicateType?: unknown; + readonly subject?: unknown; +}; + +export type DownloadedUpdateAttestationInput = { + readonly downloadedFile: string; + readonly version: string; +}; + +export type DownloadedUpdateAttestationVerifier = ( + input: DownloadedUpdateAttestationInput, +) => Promise; + +type VerifyDownloadedUpdateAttestationOptions = DownloadedUpdateAttestationInput & { + readonly trustRootCacheDirectory: string; + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly fetchBundle?: (url: string) => Promise; + readonly verifyBundle?: (bundle: Bundle) => Promise; +}; + +function exactDesktopUpdateArtifactName( + version: string, + platform: NodeJS.Platform, + arch: string, +): string { + if (platform === 'darwin' && arch === 'arm64') return `Maka-${version}-mac-arm64.zip`; + if (platform === 'win32' && arch === 'x64') return `Maka-${version}-win-x64.exe`; + throw new Error(`Automatic updates are unsupported on ${platform}/${arch}`); +} + +export function productReleaseAttestationName(version: string): string { + if (!/^[0-9A-Za-z][0-9A-Za-z.+-]*$/u.test(version)) { + throw new Error('Update version cannot identify a product attestation'); + } + return `Maka-${version}-attestation.sigstore.json`; +} + +export function productReleaseAttestationUrl(version: string): string { + const tag = `v${version}`; + const name = productReleaseAttestationName(version); + return `https://github.com/${PRODUCT_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; +} + +async function sha256File(path: string): Promise { + const hash = createHash('sha256'); + const stream = createReadStream(path); + for await (const chunk of stream) hash.update(chunk); + return hash.digest('hex'); +} + +async function fetchBytesCapped(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'follow', + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok || !response.body) { + throw new Error(`Update attestation download failed with HTTP ${response.status}`); + } + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_ATTESTATION_BYTES) { + throw new Error('Update attestation is larger than the accepted limit'); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_ATTESTATION_BYTES) { + await reader.cancel(); + throw new Error('Update attestation is larger than the accepted limit'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function parseBundle(bytes: Uint8Array): Bundle { + let serialized: unknown; + try { + serialized = JSON.parse(new TextDecoder().decode(bytes)); + } catch (error) { + throw new Error('Update attestation is not valid JSON', { cause: error }); + } + try { + return bundleFromJSON(serialized as Parameters[0]); + } catch (error) { + throw new Error('Update attestation is not a valid Sigstore bundle', { cause: error }); + } +} + +function statementFromBundle(bundle: Bundle): AttestationStatement { + if (bundle.content.$case !== 'dsseEnvelope') { + throw new Error('Update attestation must contain an in-toto statement'); + } + const envelope = bundle.content.dsseEnvelope; + if (envelope.payloadType !== 'application/vnd.in-toto+json') { + throw new Error('Update attestation payload type is invalid'); + } + try { + return JSON.parse(Buffer.from(envelope.payload).toString('utf8')) as AttestationStatement; + } catch (error) { + throw new Error('Update attestation statement is not valid JSON', { cause: error }); + } +} + +export function assertProductReleaseAttestationSubject( + bundle: Bundle, + expectedName: string, + expectedSha256: string, +): void { + const statement = statementFromBundle(bundle); + if (statement._type !== IN_TOTO_STATEMENT_V1 || statement.predicateType !== SLSA_PROVENANCE_V1) { + throw new Error('Update attestation is not SLSA provenance v1'); + } + if (!Array.isArray(statement.subject)) { + throw new Error('Update attestation has no subjects'); + } + const exact = (statement.subject as AttestationSubject[]).some((subject) => { + if (subject?.name !== expectedName || !subject.digest || typeof subject.digest !== 'object') { + return false; + } + return (subject.digest as Record).sha256 === expectedSha256; + }); + if (!exact) throw new Error('Update attestation does not identify the downloaded artifact'); +} + +export async function verifyDownloadedUpdateAttestation( + options: VerifyDownloadedUpdateAttestationOptions, +): Promise { + const version = options.version.trim().replace(/^v/iu, ''); + const expectedName = exactDesktopUpdateArtifactName( + version, + options.platform ?? process.platform, + options.arch ?? process.arch, + ); + const [artifactSha256, bundleBytes] = await Promise.all([ + sha256File(options.downloadedFile), + (options.fetchBundle ?? fetchBytesCapped)(productReleaseAttestationUrl(version)), + ]); + const bundle = parseBundle(bundleBytes); + + if (options.verifyBundle) { + await options.verifyBundle(bundle); + } else { + const trustedRoot = await getTrustedRoot({ + cachePath: options.trustRootCacheDirectory, + timeout: 10_000, + }); + const verifier = new Verifier(toTrustMaterial(trustedRoot)); + verifier.verify(toSignedEntity(bundle), { + subjectAlternativeName: PRODUCT_RELEASE_SIGNER, + extensions: { issuer: GITHUB_ACTIONS_OIDC_ISSUER }, + }); + } + + assertProductReleaseAttestationSubject(bundle, expectedName, artifactSha256); +} diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index 3aff9847e6..ff1cbecc72 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -20,6 +20,7 @@ import electronUpdater from 'electron-updater'; import type { AppUpdater, UpdateCheckResult } from 'electron-updater'; import type { ProgressInfo, UpdateInfo } from 'electron-updater'; +import type { DownloadedUpdateAttestationVerifier } from './app-update-attestation.js'; import { resolveUpdateFeedOverride } from './app-update-test-context.js'; export { resolveUpdateFeedOverride } from './app-update-test-context.js'; @@ -46,6 +47,7 @@ export type AppUpdateStatus = latestVersion: string; progress: AppUpdateProgress; } + | { state: 'verifying'; currentVersion: string; latestVersion: string } | { state: 'downloaded'; currentVersion: string; @@ -97,6 +99,7 @@ interface AppUpdateServiceDeps { mockLatestVersion?: string; mockState?: 'available' | 'downloading' | 'downloaded'; onStatusChange?: (status: AppUpdateStatus) => void; + verifyDownloadedUpdate: DownloadedUpdateAttestationVerifier; prepareInstall: ( input: AppUpdateInstallRequest, ) => Promise< @@ -218,6 +221,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer cancellationToken?: UpdateCheckResult['cancellationToken']; cancelledForRetry: boolean; } | undefined; + let activeVerification: Promise | undefined; let checkTimer: unknown; let installHandoff: { rollback(): void } | undefined; let started = false; @@ -242,7 +246,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const currentStatus = (): AppUpdateStatus => status; const latestVersion = () => - status.state === 'available' || status.state === 'downloading' || status.state === 'downloaded' || + status.state === 'available' || status.state === 'downloading' || status.state === 'verifying' || status.state === 'downloaded' || status.state === 'installing' || status.state === 'error' ? status.latestVersion : updateInfoVersion(latestInfo); @@ -326,16 +330,35 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }); updater.on('update-downloaded', (event) => { latestInfo = event; - publish({ - state: 'downloaded', - currentVersion: deps.currentVersion, - latestVersion: updateInfoVersion(event) ?? latestVersion() ?? deps.currentVersion, - }); + const version = updateInfoVersion(event) ?? latestVersion() ?? deps.currentVersion; + publish({ state: 'verifying', currentVersion: deps.currentVersion, latestVersion: version }); + const verification = Promise.resolve().then(() => + deps.verifyDownloadedUpdate({ + downloadedFile: event.downloadedFile, + version, + }), + ); + activeVerification = verification; + void verification + .then(() => { + if (activeVerification !== verification) return; + publish({ + state: 'downloaded', + currentVersion: deps.currentVersion, + latestVersion: version, + }); + }) + .catch((error) => { + if (activeVerification === verification) publishError('download', error); + }) + .finally(() => { + if (activeVerification === verification) activeVerification = undefined; + }); }); updater.on('error', (error) => { const operation = status.state === 'installing' ? 'install' - : status.state === 'available' || status.state === 'downloading' + : status.state === 'available' || status.state === 'downloading' || status.state === 'verifying' ? 'download' : 'check'; if (operation === 'install') rollbackInstallHandoff(); @@ -349,15 +372,17 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer if (!deps.isPackaged) { return publish({ state: 'not-available', currentVersion: deps.currentVersion }); } - if (status.state === 'downloaded' || status.state === 'installing') return status; + if (status.state === 'verifying' || status.state === 'downloaded' || status.state === 'installing') return status; if (status.state === 'downloading' && !allowDuringDownload) return status; if (activeDownload && !allowDuringDownload) return status; if (checkInFlight) return checkInFlight; lastCheckStartedAt = now(); checkInFlight = updater .checkForUpdates() - .then((result) => { + .then(async (result) => { trackAutoDownload(result); + const verification = activeVerification; + if (verification) await verification.catch(() => undefined); return status; }) .catch((error) => status.state === 'error' ? status : publishError('check', error)) @@ -397,7 +422,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer if (deps.mockLatestVersion) { return publish(mockStatus(deps.currentVersion, deps.mockLatestVersion, 'downloaded')); } - if (!deps.isPackaged || status.state === 'downloaded' || status.state === 'installing') { + if (!deps.isPackaged || status.state === 'verifying' || status.state === 'downloaded' || status.state === 'installing') { return status; } if (checkInFlight) await checkInFlight; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9440a70e3f..04f3cf4ec9 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -62,6 +62,7 @@ import { resolveStorageRoot } from "@maka/storage/root-authority"; import { createMcpOAuthController } from "./mcp-oauth-controller.js"; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; +import { verifyDownloadedUpdateAttestation } from "./app-update-attestation.js"; import { createAppUpdateService } from "./app-update-service.js"; import { createAttachmentApprovalRegistry } from "./attachment-approval.js"; import { renderAttachmentPreview, resizeImageForAttachment } from "./attachment-resize-native.js"; @@ -656,14 +657,26 @@ const updateMockState = process.env.MAKA_UPDATE_MOCK_STATE === "downloaded" ? process.env.MAKA_UPDATE_MOCK_STATE : undefined; +const updateTestFeed = process.env.MAKA_UPDATE_TEST_FEED; const updateService = createAppUpdateService({ currentVersion: app.getVersion(), isPackaged: app.isPackaged, - testFeedUrl: process.env.MAKA_UPDATE_TEST_FEED, + testFeedUrl: updateTestFeed, mockLatestVersion: process.env.MAKA_UPDATE_MOCK_VERSION, mockState: updateMockState, onStatusChange: (status) => mainWindowController.send("app:updateStatusChanged", status), + // The loopback-only upgrade harness owns synthetic bytes that cannot carry + // a GitHub Actions identity. Production has no override and always reaches + // the Sigstore verifier below. + verifyDownloadedUpdate: updateTestFeed + ? async () => {} + : ({ downloadedFile, version }) => + verifyDownloadedUpdateAttestation({ + downloadedFile, + version, + trustRootCacheDirectory: join(userDataDir, "update-trust", "sigstore"), + }), prepareInstall: async (input) => { if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); const retirement = await runtimeHostManager.retireOwnedLocalHost( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index c28197801a..01de26dab3 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -270,6 +270,7 @@ export type AppUpdateStatus = total?: number; }; } + | { state: 'verifying'; currentVersion: string; latestVersion: string } | { state: 'downloaded'; currentVersion: string; diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index 86399bcba6..fc4b8b91ec 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -238,6 +238,7 @@ export type SettingsPreferencesCopy = { updateNotAvailable: string; updateAvailable: (version: string) => string; updateDownloading: (version: string, percent: number) => string; + updateVerifying: (version: string) => string; updateDownloaded: (version: string) => string; updateInstalling: (version: string) => string; updateCheckFailed: string; @@ -336,6 +337,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { updateNotAvailable: '已是最新版本。', updateAvailable: (version) => `发现新版本 v${version},正在准备下载…`, updateDownloading: (version, percent) => `正在下载 v${version}(${percent}%)…`, + updateVerifying: (version) => `正在验证 v${version} 的发布来源…`, updateDownloaded: (version) => `v${version} 已下载,可在侧栏选择重启安装。`, updateInstalling: (version) => `正在安装 v${version}…`, updateCheckFailed: '检查更新失败', @@ -387,6 +389,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { updateNotAvailable: 'You are on the latest version.', updateAvailable: (version) => `Version v${version} is available and will download shortly…`, updateDownloading: (version, percent) => `Downloading v${version} (${percent}%)…`, + updateVerifying: (version) => `Verifying the release provenance for v${version}…`, updateDownloaded: (version) => `v${version} is ready. Restart from the sidebar to install.`, updateInstalling: (version) => `Installing v${version}…`, updateCheckFailed: 'Could not check for updates', diff --git a/apps/desktop/src/renderer/settings/about-update-status.ts b/apps/desktop/src/renderer/settings/about-update-status.ts index 005e63ad8d..ca550af56e 100644 --- a/apps/desktop/src/renderer/settings/about-update-status.ts +++ b/apps/desktop/src/renderer/settings/about-update-status.ts @@ -36,6 +36,7 @@ export function aboutUpdateStatusDetail( if (status.state === 'downloading') { return copy.updateDownloading(status.latestVersion, Math.round(status.progress.percent)); } + if (status.state === 'verifying') return copy.updateVerifying(status.latestVersion); if (status.state === 'downloaded') return copy.updateDownloaded(status.latestVersion); if (status.state === 'installing') return copy.updateInstalling(status.latestVersion); return copy.updateCheckFailedDetail(status.message); diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index 8207d0aabf..af6e49bc90 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -17,9 +17,11 @@ under the License. --> -# CLI/TUI distribution contract +# CLI/TUI convenience distribution contract -Maka ships its CLI/TUI as a required artifact of the same product release as Desktop. Phase 1 +Maka's Apache release is the IPMC-approved source archive on ASF distribution infrastructure. The +CLI/TUI ZIP and Desktop installers are required convenience artifacts built from that exact source +identity. Phase 1 publishes one signed and notarized Apple Silicon artifact: `Maka--cli-mac-arm64.zip` @@ -74,7 +76,8 @@ Root `package.json` is the sole version authority. Desktop and CLI manifests mus packaging. Desktop, CLI/TUI, and source jobs build independently from one commit; one publish job collects their verified outputs and creates one Draft GitHub Release. -The GitHub Release ZIP is the immutable standalone distribution source. npm keeps its +The GitHub Release ZIP is the standalone convenience distribution source. Its exact bytes are +covered by a Sigstore provenance bundle signed with the protected Finalize workflow identity. npm keeps its installer-specific tarball, OIDC, staged-publishing, and 2FA approval flow, but may start only after the product `v` tag and GitHub Release exist. It checks out that tag's exact commit and derives the same version, runtime closure, file policy, notices, and source identity. It does not @@ -86,8 +89,8 @@ consume the standalone ZIP. | Question | Decision | Enforced by | | --- | --- | --- | | Which file owns the product version? | Root `package.json`; Desktop and CLI manifests must match it. | `product-release-identity.mjs` and release contract tests | -| Which event defines a product release? | One `v` tag from `main`, one source commit, and one Draft GitHub Release. An interrupted Draft upload may retry only that exact commit. | `release.yml` identity and publish jobs plus the exact-tag helper | -| Which artifacts are required? | macOS and Windows Desktop installers and update assets, the macOS arm64 standalone CLI ZIP, and bundled source. | The exact manifest from `product-release-identity.mjs`, enforced by each artifact job and the publish job | +| Which event defines the Apache release? | The approved source archive and vote result. The `v` tag and Draft GitHub Release identify convenience distributions built from that source commit. | ASF source-release workflow plus `release.yml` identity and exact-tag checks | +| Which convenience artifacts are required? | macOS and Windows Desktop installers and update assets plus the macOS arm64 standalone CLI ZIP. | The exact manifest from `product-release-identity.mjs`, enforced by each artifact job and the publish job | | Is npm another release authority? | No. It is an optional install channel whose Stage ref, source, workflow identity, and provenance all resolve to the existing product tag commit. | Tag-dispatched OIDC staging and read-only finalization; no npm-specific tag or GitHub Release | | Does the standalone CLI define another package policy? | No. It derives the workspace closure, third-party pruning, notices, and Eval runtime assets from their current manifests and shared policy. | Packaging and artifact contract tests | | Which commands are public? | `maka` only; TUI is its default mode. | CLI manifest, help tests, wrapper, and release metadata | diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 121eabfb62..c5a2b7a0ad 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -23,6 +23,10 @@ This runbook is the operational authority for publishing the `maka-agent` npm installation channel. The root `package.json` remains the sole Maka product-version authority, and `packages/cli/package.json` must match it. Every public npm version must come from the exact tarball validated by the Stage workflow. +The IPMC-approved source archive on ASF distribution infrastructure is the Apache release. npm, +Desktop installers, and GitHub Release assets are convenience packages built from that approved +source identity; they are not additional ASF release artifacts. + The source-RC [npm preflight](../.github/ASF_NPM_RELEASE.md) is an earlier, credential-free compatibility check. Its tarball is not carried into publication. After source approval, Stage rebuilds from the final product tag at the same approved commit and becomes the byte authority for @@ -54,8 +58,9 @@ The two workflow boundaries are: successful Stage run, Release build run, and self-contained publication record. The current reviewed verifier on `main` checks the public registry bytes, signature, provenance, dist-tag, immutable build artifacts, and live Draft digests, then waits at the protected `product-release` - Environment. After independent Desktop acceptance, approval publishes the GitHub Release together - with its Stable/Latest classification in one operation. + Environment. After independent Desktop acceptance, approval attests the exact convenience + artifacts with the protected workflow identity, publishes the GitHub Release, and applies its + Stable/Latest classification in one operation. ## One-time control-plane configuration @@ -69,16 +74,12 @@ Environments. After it reaches `product-release`; - `M4n5ter` as the required reviewer; - self-review disabled; -- administrator bypass disabled where repository policy permits it; -- `RELEASE_POLICY_APP_CLIENT_ID` as a `product-release` Environment variable and - `RELEASE_POLICY_APP_PRIVATE_KEY` as an Environment secret. +- administrator bypass disabled where repository policy permits it. Repository administration permission is required to inspect or repair reconciliation. Do not maintain -a second manual Environment policy in GitHub. Install the policy GitHub App only on `apache/maka`, -grant it only repository **Administration: read**, and enable immutable releases for the repository. -Finalize mints a one-hour, repository-scoped installation token, verifies that policy immediately -before publication, and requires the published Release response to report `immutable: true`. The -workflow uses GitHub OIDC for npm and never reads an npm token. +a second manual Environment policy in GitHub. Finalize uses GitHub Actions OIDC to create Sigstore +provenance for the exact convenience artifacts and stores the offline verification bundle beside +them. It requires no repository-administration credential, signing key, or npm token. ### npm Trusted Publisher @@ -211,9 +212,10 @@ After npm reports the version as public: Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. 4. While the publication job waits for `product-release` approval, complete the product checklist's cross-machine acceptance against the Draft. -5. Approve the Environment. Confirm the workflow verifies repository release immutability, matches - every live Draft digest to the exact Release attempt's publication record, publishes an immutable - Release, and makes a stable release Latest without a separate manual action. +5. Approve the Environment. Confirm the workflow matches every live Draft digest to the exact + Release attempt's publication record, creates and uploads + `Maka--attestation.sigstore.json`, publishes the convenience Release, and makes a stable + release Latest without a separate manual action. Check the resulting registry state: @@ -264,10 +266,10 @@ and provenance are valid, fix the Finalize verifier on `main` and rerun it again Stage and Release evidence. The inspection job is read-only. Only the protected publication job may perform the single -Draft-to-immutable transition. If npm identity, build evidence, or the Draft differs, stop and +Draft-to-published transition and attest its bytes. If npm identity, build evidence, or the Draft differs, stop and investigate; do not modify the product tag or GitHub Release to make verification pass. If failure is -reported after the publication request, inspect the exact Release first: a successful immutable -transition must not be repeated. +reported after the publication request, inspect the exact Release first: a successful publication +must not be repeated. ### The public version is defective diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 10531441b6..fbef8f9162 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -23,6 +23,9 @@ 本文档是发布 `maka-agent` npm 安装渠道的操作权威。根目录 `package.json` 仍是 Maka 唯一产品版本权威,`packages/cli/package.json` 必须与其一致。每个公开 npm 版本都必须来自 Stage workflow 验证过的同一个精确 tarball。 +ASF 分发基础设施上经 IPMC 批准的源码归档才是 Apache release。npm、Desktop 安装包和 +GitHub Release assets 都是从该源码身份构建的便利包,不是额外的 ASF release artifacts。 + source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行、且不持有发布凭据的兼容性检查;其 tarball 不会进入正式发布。source release 获批后,Stage 从位于同一获批 commit 的最终产品 tag 重新构建,并成为 npm staging 与 registry 验证所使用的字节权威。这与 Apache OpenDAL 孵化期的实践一致,同时保留了 Maka 更严格的受保护 Environment、staged publishing、2FA 与 Finalize 控制。 ## 发布不变量 @@ -46,7 +49,7 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 Stage run、Release build run 及其自包含 publication record;`main` 上当前已审查的 verifier 验证公共 registry 字节、signature、provenance、dist-tag、不可变 build artifacts 与 live Draft digest,然后等待受保护的 `product-release` Environment;独立 Desktop 验收完成并批准后,它会 - 在同一个操作中发布 GitHub Release 及其 Stable/Latest 分类。 + 用受保护 workflow 的身份证明精确便利包,并在同一个操作中发布 GitHub Release 及其 Stable/Latest 分类。 ## 一次性控制面配置 @@ -58,15 +61,11 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 - `npm-release` 使用 selected `v*` tag rule,`product-release` 使用 selected `main` branch rule; - required reviewer 为 `M4n5ter`; - 禁止 self-review; -- 仓库策略允许时禁用 administrator bypass; -- 在 `product-release` Environment 中配置 variable `RELEASE_POLICY_APP_CLIENT_ID` 与 secret - `RELEASE_POLICY_APP_PRIVATE_KEY`。 +- 仓库策略允许时禁用 administrator bypass。 检查或修复同步结果需要仓库 administration 权限;不要再在 GitHub UI 中维护第二套手工 -Environment policy。policy GitHub App 只能安装到 `apache/maka`,只能拥有仓库 -**Administration: read**,并且仓库必须启用 immutable releases。Finalize 会生成一小时有效、 -仅限本仓库的 installation token,在发布前立即验证该策略,并要求发布响应返回 -`immutable: true`。workflow 使用 GitHub OIDC,不读取 npm token。 +Environment policy。Finalize 使用 GitHub Actions OIDC 为精确便利包生成 Sigstore provenance, +并把离线验证 bundle 与便利包放在一起;不需要仓库 administration credential、签名私钥或 npm token。 ### npm Trusted Publisher @@ -189,9 +188,9 @@ npm 显示该版本已经公开后: 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; 4. publication job 等待 `product-release` 批准期间,针对 Draft 完成产品检查清单中的跨机器验收; -5. 批准 Environment,并确认 workflow 验证仓库 release immutability,将每个 live Draft digest - 与精确 Release attempt 的 publication record 对比,发布 immutable Release;stable release - 会同时成为 Latest,不再需要单独人工操作。 +5. 批准 Environment,并确认 workflow 将每个 live Draft digest 与精确 Release attempt 的 + publication record 对比,生成并上传 `Maka--attestation.sigstore.json`,发布便利包 + Release;stable release 会同时成为 Latest,不再需要单独人工操作。 检查最终 registry 状态: @@ -237,10 +236,10 @@ npm 版本此时已经 immutable,不要再次 publish 或 approve。保留 Sta 以及 Release run ID、attempt、publication record 和 artifacts。如果这些字节与 provenance 有效,在 `main` 修复 Finalize verifier,然后针对同一组不可变 Stage 与 Release 证据重新运行。 -inspection job 只读;只有受保护的 publication job 可以执行一次 Draft-to-immutable 转换。如果 +inspection job 只读;只有受保护的 publication job 可以执行一次 Draft-to-published 转换并证明其字节。如果 npm identity、build evidence 或 Draft 不一致,立即停止并调查;不要修改产品 tag 或 GitHub Release 来让验证通过。如果错误发生在 publication request 之后,先检查精确 Release;已经成功 -完成的 immutable 转换不得重复执行。 +完成的发布不得重复执行。 ### 公共版本存在缺陷 diff --git a/package-lock.json b/package-lock.json index e508644b95..262c8d4ef9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,9 @@ "@maka/runtime": "0.1.0", "@maka/runtime-host": "0.1.0", "@maka/storage": "0.1.0", + "@sigstore/bundle": "5.0.0", + "@sigstore/tuf": "5.0.0", + "@sigstore/verify": "4.1.2", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -2159,6 +2162,15 @@ "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==", "license": "MIT" }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.17", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", @@ -3736,6 +3748,63 @@ } } }, + "node_modules/@sigstore/bundle": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-5.0.0.tgz", + "integrity": "sha512-wefjygudENbzbQMks1t5u34EP0fFoD0XvaEP7DOUP/sXKvogzEJYFw5E6pegGyp3onGWzVEYKVa3bNZWyTYX+A==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@sigstore/core": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-4.0.1.tgz", + "integrity": "sha512-9v5hRjujn5NXq8o7XFEUgLyAtdr5Iisb4pzM05u3K61IS5q3hP3luWAndk0RkPPLTUFoTbg7Vb84UQ1ZQeajWQ==", + "license": "Apache-2.0", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.2.tgz", + "integrity": "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-5.0.0.tgz", + "integrity": "sha512-Zyqg9tcHps3uRAlKHLNmsW4ohsUZAjb9G+31r7lg0ICh/JOcadzmJsIRdjKljlRHpaR0K4aJ2kXXIdywdcdMlA==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^6.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-4.1.2.tgz", + "integrity": "sha512-BfD9eLrz3A/DG58aSgfgZYmIR6V9Yw96QVN/frtu2bEH7ctSTk2TDvHU12un3JsDPBTqTNkqkIkucjxljpOFqQ==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^5.0.0", + "@sigstore/core": "^4.0.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -4008,6 +4077,28 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-5.0.0.tgz", + "integrity": "sha512-U4mVcdFGOi6pt8n38LdWZp67Svn7ppnU1Pj8SGOVaBi1X4gm+G4ztQlLfkoJbKSHfjA6WeaiJp2A4V83AJF6nQ==", + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.2.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -5502,7 +5593,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -5672,7 +5762,6 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -10465,7 +10554,6 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -13492,6 +13580,20 @@ "dev": true, "license": "0BSD" }, + "node_modules/tuf-js": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-6.0.0.tgz", + "integrity": "sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ==", + "license": "MIT", + "dependencies": { + "@gar/promise-retry": "^1.0.3", + "@tufjs/models": "5.0.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/turndown": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs index d406b3dba0..82cf73ee56 100644 --- a/scripts/generate-third-party-notices.mjs +++ b/scripts/generate-third-party-notices.mjs @@ -104,7 +104,10 @@ const LICENSE_METADATA_OVERRIDES = new Map([ ]); // The published tarball omits the repository LICENSE; package.json declares Apache-2.0. // Keyed by exact version so a bump re-checks the license rather than inheriting this. -const APACHE_TEXT_OVERRIDE_KEYS = new Set(['@ai-sdk/provider-utils@5.0.28']); +const APACHE_TEXT_OVERRIDE_KEYS = new Set([ + '@ai-sdk/provider-utils@5.0.28', + '@sigstore/verify@4.1.2', +]); const EMBEDDED_COMPONENT_LICENSES = new Map([ [ '@ai-sdk/code-mode', diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs index d50d0028aa..d7fe78e276 100644 --- a/scripts/product-release-authority.mjs +++ b/scripts/product-release-authority.mjs @@ -18,7 +18,10 @@ */ import { execFile } from 'node:child_process'; -import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; +import { basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { @@ -31,8 +34,13 @@ import { parseProductTag, remoteProductTagCommit } from './product-release-tag.m const execFileAsync = promisify(execFile); function expectedReleaseIdentity(tag) { - const { prerelease } = parseProductTag(tag); - return { tag, isPrerelease: prerelease.length > 0 }; + const { prerelease, version } = parseProductTag(tag); + return { + tag, + version, + isPrerelease: prerelease.length > 0, + attestationName: `Maka-${version}-attestation.sigstore.json`, + }; } export function assertDraftProductRelease(release, tag) { @@ -62,22 +70,12 @@ export function assertPublishedProductRelease(release, tag, releaseId, expectedA if (release.prerelease !== expected.isPrerelease) { throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); } - if (release.immutable !== true) { - throw new Error(`GitHub Release ${tag} was published without immutable release protection`); - } if (JSON.stringify(release.assets) !== JSON.stringify(expectedAssets)) { throw new Error(`GitHub Release ${tag} assets changed during publication`); } return release; } -export function assertImmutableReleasePolicy(policy) { - if (policy?.enabled !== true || typeof policy.enforced_by_owner !== 'boolean') { - throw new Error('Immutable releases must be enabled before product publication'); - } - return policy; -} - function releaseSnapshotFromGhView(value) { return { id: value?.databaseId, @@ -96,11 +94,22 @@ function releaseSnapshotFromRest(value) { tag: value?.tag_name, draft: value?.draft, prerelease: value?.prerelease, - immutable: value?.immutable, - assets: value?.assets ?? [], + assets: (value?.assets ?? []) + .map(({ name, size, digest }) => ({ name, size, digest })) + .sort((left, right) => left.name.localeCompare(right.name)), }; } +async function localAssetRecord(path) { + const details = await stat(path); + if (!details.isFile() || details.size === 0) { + throw new Error('Product release attestation bundle must be a non-empty regular file'); + } + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return { name: basename(path), size: details.size, digest: `sha256:${hash.digest('hex')}` }; +} + export function assertProductReleaseWorkflowRun({ run, tag, @@ -189,14 +198,16 @@ export async function publishDraftProductRelease({ sourceReferenceTag, releaseRunId, releaseRunAttempt, - policyToken = process.env.RELEASE_POLICY_TOKEN, + attestationBundlePath, cwd = process.cwd(), run = execFileAsync, pause = (milliseconds) => new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)), }) { - if (typeof policyToken !== 'string' || policyToken.length === 0) { - throw new Error('Product publication requires a read-only repository policy token'); + const releaseIdentity = expectedReleaseIdentity(tag); + const attestation = await localAssetRecord(attestationBundlePath); + if (attestation.name !== releaseIdentity.attestationName) { + throw new Error(`Product release attestation must be named ${releaseIdentity.attestationName}`); } const evidence = await readProductReleasePublicationRecord(publicationRecordPath, { repository, @@ -222,29 +233,37 @@ export async function publishDraftProductRelease({ const remoteAssets = (draft.assets ?? []) .map(({ name, size, digest }) => ({ name, size, digest })) .sort((left, right) => left.name.localeCompare(right.name)); - if (JSON.stringify(remoteAssets) !== JSON.stringify(evidence.assets)) { + const allowedDraftAssets = remoteAssets.filter(({ name }) => name !== attestation.name); + const unexpectedAttestations = remoteAssets.filter( + ({ name }) => name.endsWith('-attestation.sigstore.json') && name !== attestation.name, + ); + if ( + unexpectedAttestations.length > 0 || + JSON.stringify(allowedDraftAssets) !== JSON.stringify(evidence.assets) + ) { throw new Error('Draft GitHub Release assets do not match the verified Release run artifacts'); } - const policy = await run( + await run( 'gh', - [ - 'api', - '--header', - 'X-GitHub-Api-Version: 2026-03-10', - `repos/${repository}/immutable-releases`, - ], - { cwd, env: { ...process.env, GH_TOKEN: policyToken } }, + ['release', 'upload', tag, attestationBundlePath, '--repo', repository, '--clobber'], + { cwd }, ); - let parsedPolicy; - try { - parsedPolicy = JSON.parse(policy.stdout); - } catch (error) { - throw new Error('GitHub returned an invalid immutable release policy', { cause: error }); + const attestedDraft = await verifyDraftProductRelease({ + tag, + sourceCommit, + repository, + cwd, + run, + }); + const expectedAssets = [...evidence.assets, attestation].sort((left, right) => + left.name.localeCompare(right.name), + ); + if (JSON.stringify(attestedDraft.assets) !== JSON.stringify(expectedAssets)) { + throw new Error('Draft GitHub Release does not contain the exact attestation bundle'); } - assertImmutableReleasePolicy(parsedPolicy); - const isPrerelease = expectedReleaseIdentity(tag).isPrerelease; + const isPrerelease = releaseIdentity.isPrerelease; const published = await run( 'gh', [ @@ -269,7 +288,7 @@ export async function publishDraftProductRelease({ } catch (error) { throw new Error(`GitHub returned an invalid publication result for ${tag}`, { cause: error }); } - assertPublishedProductRelease(record, tag, draft.id, evidence.assets); + assertPublishedProductRelease(record, tag, draft.id, expectedAssets); if (!isPrerelease) { let latestTag; @@ -295,7 +314,7 @@ export async function publishDraftProductRelease({ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [command, ...args] = process.argv.slice(2); const usage = - 'usage: product-release-authority.mjs verify-build-run | verify-draft | publish-draft '; + 'usage: product-release-authority.mjs verify-build-run | verify-draft | publish-draft '; if (command === 'verify-build-run' && args.length === 6) { const [runPath, tag, sourceCommit, repository, runId, runAttempt] = args; const run = JSON.parse(await readFile(runPath, 'utf8')); @@ -305,7 +324,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const [tag, sourceCommit, repository] = args; await verifyDraftProductRelease({ tag, sourceCommit, repository }); console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); - } else if (command === 'publish-draft' && args.length === 8) { + } else if (command === 'publish-draft' && args.length === 9) { const [ tag, sourceCommit, @@ -315,6 +334,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) sourceReferenceTag, releaseRunId, releaseRunAttempt, + attestationBundlePath, ] = args; await publishDraftProductRelease({ tag, @@ -325,6 +345,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) sourceReferenceTag, releaseRunId, releaseRunAttempt, + attestationBundlePath, }); console.log(`Published product Release ${tag} from ${sourceCommit}`); } else { diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs index 822652b1e1..6dd19cf9d7 100644 --- a/scripts/product-release-authority.test.mjs +++ b/scripts/product-release-authority.test.mjs @@ -25,7 +25,6 @@ import { join } from 'node:path'; import test from 'node:test'; import { assertDraftProductRelease, - assertImmutableReleasePolicy, assertProductReleaseWorkflowRun, assertPublishedProductRelease, publishDraftProductRelease, @@ -63,7 +62,7 @@ test('Draft state and prerelease classification are one product Release contract test('published state keeps stable and prerelease classification exact', () => { assert.equal( assertPublishedProductRelease( - { id: 42, tag: 'v1.2.3', draft: false, prerelease: false, immutable: true, assets: [] }, + { id: 42, tag: 'v1.2.3', draft: false, prerelease: false, assets: [] }, 'v1.2.3', 42, [], @@ -78,7 +77,6 @@ test('published state keeps stable and prerelease classification exact', () => { tag: 'v1.2.3-beta.1', draft: false, prerelease: false, - immutable: true, assets: [], }, 'v1.2.3-beta.1', @@ -87,31 +85,6 @@ test('published state keeps stable and prerelease classification exact', () => { ), /prerelease state/u, ); - assert.throws( - () => - assertPublishedProductRelease( - { - id: 42, - tag: 'v1.2.3', - draft: false, - prerelease: false, - immutable: false, - assets: [], - }, - 'v1.2.3', - 42, - [], - ), - /without immutable release protection/u, - ); - assert.equal( - assertImmutableReleasePolicy({ enabled: true, enforced_by_owner: false }).enabled, - true, - ); - assert.throws( - () => assertImmutableReleasePolicy({ enabled: false, enforced_by_owner: false }), - /must be enabled/u, - ); }); test('Release workflow evidence binds an exact successful attempt to approved source', () => { @@ -233,6 +206,14 @@ test('publication verifies live asset digests before one Stable/Latest mutation' const contents = Buffer.from('verified artifact'); await writeFile(join(directory, 'artifact.zip'), contents); const digest = `sha256:${createHash('sha256').update(contents).digest('hex')}`; + const attestationContents = Buffer.from('sigstore bundle'); + const attestationBundlePath = join(recordDirectory, 'Maka-1.2.3-attestation.sigstore.json'); + await writeFile(attestationBundlePath, attestationContents); + const attestation = { + name: 'Maka-1.2.3-attestation.sigstore.json', + size: attestationContents.length, + digest: `sha256:${createHash('sha256').update(attestationContents).digest('hex')}`, + }; const publicationRecordPath = join(recordDirectory, 'product-release.json'); const sourceCommit = 'a'.repeat(40); await writeFile( @@ -253,11 +234,16 @@ test('publication verifies live asset digests before one Stable/Latest mutation' ); const calls = []; let latestReads = 0; + let attestationUploaded = false; const run = async (command, args) => { calls.push([command, args]); if (command === 'git' && args[0] === 'ls-remote') { return { stdout: `${sourceCommit}\trefs/tags/v1.2.3\n` }; } + if (command === 'gh' && args[0] === 'release' && args[1] === 'upload') { + attestationUploaded = true; + return { stdout: '' }; + } if (command === 'gh' && args[0] === 'release') { return { stdout: JSON.stringify({ @@ -265,13 +251,13 @@ test('publication verifies live asset digests before one Stable/Latest mutation' tagName: 'v1.2.3', isDraft: true, isPrerelease: false, - assets: [{ name: 'artifact.zip', size: contents.length, digest }], + assets: [ + ...(attestationUploaded ? [attestation] : []), + { name: 'artifact.zip', size: contents.length, digest }, + ], }), }; } - if (command === 'gh' && args.includes('repos/apache/maka/immutable-releases')) { - return { stdout: JSON.stringify({ enabled: true, enforced_by_owner: false }) }; - } if (command === 'gh' && args.includes('PATCH')) { return { stdout: JSON.stringify({ @@ -279,8 +265,7 @@ test('publication verifies live asset digests before one Stable/Latest mutation' tag_name: 'v1.2.3', draft: false, prerelease: false, - immutable: true, - assets: [{ name: 'artifact.zip', size: contents.length, digest }], + assets: [attestation, { name: 'artifact.zip', size: contents.length, digest }], }), }; } @@ -300,7 +285,7 @@ test('publication verifies live asset digests before one Stable/Latest mutation' sourceReferenceTag: 'v1.2.3-incubating-rc1', releaseRunId: '123', releaseRunAttempt: '2', - policyToken: 'policy-token', + attestationBundlePath, run, pause: async () => {}, }); @@ -311,10 +296,10 @@ test('publication verifies live asset digests before one Stable/Latest mutation' assert.ok(patchCall[1].includes('prerelease=false')); assert.ok(patchCall[1].includes('make_latest=true')); assert.ok(calls.every(([, args]) => !args.includes('repos/apache/maka/releases/tags/v1.2.3'))); - const policyCall = calls.find(([, args]) => - args.includes('repos/apache/maka/immutable-releases'), - ); - assert.ok(policyCall); - assert.ok(calls.indexOf(policyCall) < calls.indexOf(patchCall)); + const uploadCall = calls.find(([, args]) => args[0] === 'release' && args[1] === 'upload'); + assert.ok(uploadCall); + assert.ok(uploadCall[1].includes('--clobber')); + assert.ok(calls.indexOf(uploadCall) < calls.indexOf(patchCall)); + assert.ok(calls.every(([, args]) => !args.includes('repos/apache/maka/immutable-releases'))); assert.equal(latestReads, 2); }); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index af62249f0f..ac800784f2 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -87,7 +87,7 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); -test('finalize runs the current verifier from reviewed main against exact immutable evidence', () => { +test('finalize runs the current verifier from reviewed main against exact build evidence', () => { const workflow = readWorkflow('release-cli-finalize.yml'); const productWorkflow = readWorkflow('release.yml'); const steps = workflowSteps(workflow); @@ -105,7 +105,7 @@ test('finalize runs the current verifier from reviewed main against exact immuta const requireMain = namedStep(steps, 'Require main'); assert.match(requireMain, /refs\/heads\/main/u); const recordDownloads = steps.filter((step) => - step.includes('name: Download the immutable publication record'), + step.includes('name: Download the publication record'), ); assert.equal(recordDownloads.length, 2); for (const download of recordDownloads) { @@ -136,12 +136,15 @@ test('finalize preserves npm evidence and owns the single product publication bo assert.doesNotMatch(workflow, /cli-v/u); assert.match(workflow, /name: product-release/u); assert.match(workflow, /contents: write/u); + assert.match(workflow, /id-token: write/u); + assert.match(workflow, /attestations: write/u); assert.match(workflow, /product-release-authority\.mjs publish-draft/u); - assert.match(workflow, /actions\/create-github-app-token@[0-9a-f]{40}/u); - assert.match(workflow, /permission-administration: read/u); - assert.match( + assert.match(workflow, /actions\/attest@[0-9a-f]{40}/u); + assert.match(workflow, /subject-path: \$\{\{ runner\.temp \}\}\/product-release\/\*/u); + assert.match(workflow, /Maka-\$\{PRODUCT_VERSION\}-attestation\.sigstore\.json/u); + assert.doesNotMatch( workflow, - /RELEASE_POLICY_TOKEN: \$\{\{ steps\.release-policy\.outputs\.token \}\}/u, + /create-github-app-token|permission-administration|RELEASE_POLICY/u, ); const artifacts = namedStep( workflowSteps(workflow), From 161ca1a11fa37a797b870d9b741ce3d0dc8eede3 Mon Sep 17 00:00:00 2001 From: Wang Date: Thu, 27 Aug 2026 22:40:07 +0800 Subject: [PATCH 5/7] test(release): consolidate trust-boundary coverage --- .../__tests__/app-update-attestation.test.ts | 101 ++++---- .../src/main/app-update-attestation.ts | 6 +- package.json | 4 +- scripts/ci-test-plan.test.mjs | 1 - scripts/desktop-update-contract.test.mjs | 73 +----- scripts/product-release-artifacts.mjs | 15 +- scripts/product-release-artifacts.test.mjs | 219 ----------------- scripts/product-release-authority.mjs | 9 +- scripts/product-release-authority.test.mjs | 220 ++++++------------ scripts/product-release.test.mjs | 26 --- scripts/release-cli-workflow-policy.test.mjs | 20 -- 11 files changed, 134 insertions(+), 560 deletions(-) delete mode 100644 scripts/product-release-artifacts.test.mjs diff --git a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts index 6f2fd8d8bc..fae7d8d79a 100644 --- a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts @@ -24,12 +24,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { - assertProductReleaseAttestationSubject, - productReleaseAttestationName, - productReleaseAttestationUrl, - verifyDownloadedUpdateAttestation, -} from '../app-update-attestation.js'; +import { verifyDownloadedUpdateAttestation } from '../app-update-attestation.js'; function provenanceBundle(name: string, sha256: string): Bundle { const statement = { @@ -56,32 +51,7 @@ function provenanceBundle(name: string, sha256: string): Bundle { } as unknown as Bundle; } -test('product attestation location is one version-derived release asset', () => { - assert.equal(productReleaseAttestationName('1.2.3-beta.1'), 'Maka-1.2.3-beta.1-attestation.sigstore.json'); - assert.equal( - productReleaseAttestationUrl('1.2.3-beta.1'), - 'https://github.com/apache/maka/releases/download/v1.2.3-beta.1/Maka-1.2.3-beta.1-attestation.sigstore.json', - ); - assert.throws(() => productReleaseAttestationName('../escape'), /cannot identify/u); -}); - -test('attestation subject must bind the exact update name and digest', () => { - const digest = 'a'.repeat(64); - const bundle = provenanceBundle('Maka-1.2.3-mac-arm64.zip', digest); - assert.doesNotThrow(() => - assertProductReleaseAttestationSubject(bundle, 'Maka-1.2.3-mac-arm64.zip', digest), - ); - assert.throws( - () => assertProductReleaseAttestationSubject(bundle, 'Maka-1.2.3-win-x64.exe', digest), - /does not identify/u, - ); - assert.throws( - () => assertProductReleaseAttestationSubject(bundle, 'Maka-1.2.3-mac-arm64.zip', 'b'.repeat(64)), - /does not identify/u, - ); -}); - -test('download verification checks cryptography before accepting the exact artifact subject', async (t) => { +test('download verification accepts only a trusted exact artifact subject', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'maka-update-attestation-')); t.after(() => rm(directory, { recursive: true, force: true })); const artifact = join(directory, 'cached-update.zip'); @@ -89,37 +59,48 @@ test('download verification checks cryptography before accepting the exact artif await writeFile(artifact, bytes); const digest = createHash('sha256').update(bytes).digest('hex'); const bundle = provenanceBundle('Maka-1.2.3-mac-arm64.zip', digest); - let cryptographicVerifications = 0; - - await verifyDownloadedUpdateAttestation({ + const bundleBytes = Buffer.from(JSON.stringify({ + mediaType: bundle.mediaType, + verificationMaterial: { + certificate: { rawBytes: Buffer.from('fixture certificate').toString('base64') }, + tlogEntries: [], + }, + dsseEnvelope: { + payloadType: bundle.content.$case === 'dsseEnvelope' + ? bundle.content.dsseEnvelope.payloadType + : '', + payload: bundle.content.$case === 'dsseEnvelope' + ? Buffer.from(bundle.content.dsseEnvelope.payload).toString('base64') + : '', + signatures: [{ sig: Buffer.from('fixture signature').toString('base64') }], + }, + })); + const options = { downloadedFile: artifact, version: '1.2.3', - platform: 'darwin', + platform: 'darwin' as const, arch: 'arm64', trustRootCacheDirectory: join(directory, 'trust'), - fetchBundle: async (url) => { - assert.equal(url, productReleaseAttestationUrl('1.2.3')); - return Buffer.from(JSON.stringify({ - mediaType: bundle.mediaType, - verificationMaterial: { - certificate: { rawBytes: Buffer.from('fixture certificate').toString('base64') }, - tlogEntries: [], - }, - dsseEnvelope: { - payloadType: bundle.content.$case === 'dsseEnvelope' - ? bundle.content.dsseEnvelope.payloadType - : '', - payload: bundle.content.$case === 'dsseEnvelope' - ? Buffer.from(bundle.content.dsseEnvelope.payload).toString('base64') - : '', - signatures: [{ sig: Buffer.from('fixture signature').toString('base64') }], - }, - })); - }, - verifyBundle: async () => { - cryptographicVerifications += 1; - }, - }); + fetchBundle: async () => bundleBytes, + verifyBundle: async () => {}, + }; - assert.equal(cryptographicVerifications, 1); + await verifyDownloadedUpdateAttestation(options); + await assert.rejects( + verifyDownloadedUpdateAttestation({ ...options, platform: 'win32', arch: 'x64' }), + /does not identify/u, + ); + + await writeFile(artifact, 'different update bytes'); + await assert.rejects(verifyDownloadedUpdateAttestation(options), /does not identify/u); + + await assert.rejects( + verifyDownloadedUpdateAttestation({ + ...options, + verifyBundle: () => { + throw new Error('untrusted workflow identity'); + }, + }), + /untrusted workflow identity/u, + ); }); diff --git a/apps/desktop/src/main/app-update-attestation.ts b/apps/desktop/src/main/app-update-attestation.ts index 5855f49a3e..ea0e550dbc 100644 --- a/apps/desktop/src/main/app-update-attestation.ts +++ b/apps/desktop/src/main/app-update-attestation.ts @@ -73,14 +73,14 @@ function exactDesktopUpdateArtifactName( throw new Error(`Automatic updates are unsupported on ${platform}/${arch}`); } -export function productReleaseAttestationName(version: string): string { +function productReleaseAttestationName(version: string): string { if (!/^[0-9A-Za-z][0-9A-Za-z.+-]*$/u.test(version)) { throw new Error('Update version cannot identify a product attestation'); } return `Maka-${version}-attestation.sigstore.json`; } -export function productReleaseAttestationUrl(version: string): string { +function productReleaseAttestationUrl(version: string): string { const tag = `v${version}`; const name = productReleaseAttestationName(version); return `https://github.com/${PRODUCT_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; @@ -163,7 +163,7 @@ function statementFromBundle(bundle: Bundle): AttestationStatement { } } -export function assertProductReleaseAttestationSubject( +function assertProductReleaseAttestationSubject( bundle: Bundle, expectedName: string, expectedSha256: string, diff --git a/package.json b/package.json index fe2831a6ac..728efb5325 100644 --- a/package.json +++ b/package.json @@ -62,14 +62,14 @@ "check:product-release-identity": "node scripts/product-release-identity.mjs", "package:cli:macos-arm64": "node scripts/package-macos-arm64-cli.mjs", "verify:cli:macos-arm64": "node scripts/verify-macos-arm64-cli.mjs", - "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs", + "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-authority.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", "generate:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs", "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/desktop-update-contract.test.mjs scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/desktop-update-contract.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:macos-autoupdate-next": "node scripts/package-macos-autoupdate-next.mjs", diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index e0d52f99ad..1eb8cb0e38 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -144,7 +144,6 @@ test('release authority changes select their dedicated contract gate', () => { 'scripts/package-windows-x64.mjs', 'scripts/prepare-windows-upgrade-baseline.mjs', 'scripts/product-release-artifacts.mjs', - 'scripts/product-release-artifacts.test.mjs', 'scripts/product-release-authority.mjs', 'scripts/product-release-authority.test.mjs', 'scripts/product-release-identity.mjs', diff --git a/scripts/desktop-update-contract.test.mjs b/scripts/desktop-update-contract.test.mjs index 1ecc529cfc..4721413485 100644 --- a/scripts/desktop-update-contract.test.mjs +++ b/scripts/desktop-update-contract.test.mjs @@ -18,17 +18,8 @@ */ import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import test from 'node:test'; -import { - assertPackagedUpdateConfiguration, - bumpedAutoupdateVersion, - startDesktopUpdateFeed, - verifyDesktopUpdateArtifacts, -} from './desktop-update-contract.mjs'; +import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; test('packaged clients have one exact production update authority', async () => { const expected = @@ -47,65 +38,3 @@ test('packaged clients have one exact production update authority', async () => /expected/u, ); }); - -test('the successor version follows the shared product SemVer contract', () => { - assert.equal(bumpedAutoupdateVersion('1.2.3-beta.2'), '1.2.3'); - assert.equal(bumpedAutoupdateVersion('1.2.3'), '1.2.4'); -}); - -test('update metadata is bound to the only published payload bytes', async (t) => { - const directory = await mkdtemp(join(tmpdir(), 'maka-update-contract-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const artifactName = 'Maka-1.2.3-mac-arm64.zip'; - const bytes = Buffer.from('signed application zip'); - const sha512 = createHash('sha512').update(bytes).digest('base64'); - await Promise.all([ - writeFile(join(directory, artifactName), bytes), - writeFile(join(directory, `${artifactName}.blockmap`), 'blockmap'), - writeFile( - join(directory, 'latest-mac.yml'), - [ - 'version: 1.2.3', - 'files:', - ` - url: ${artifactName}`, - ` sha512: ${sha512}`, - ` size: ${bytes.length}`, - `path: ${artifactName}`, - `sha512: ${sha512}`, - '', - ].join('\n'), - ), - ]); - await verifyDesktopUpdateArtifacts({ - directory, - metadataName: 'latest-mac.yml', - version: '1.2.3', - artifactName, - }); - await writeFile(join(directory, artifactName), 'changed'); - await assert.rejects( - verifyDesktopUpdateArtifacts({ - directory, - metadataName: 'latest-mac.yml', - version: '1.2.3', - artifactName, - }), - /sha512 does not match/u, - ); -}); - -test('the shared update feed is exact and supports ranged transfers', async (t) => { - const directory = await mkdtemp(join(tmpdir(), 'maka-update-feed-')); - t.after(() => rm(directory, { recursive: true, force: true })); - await mkdir(join(directory, 'unused')); - const payload = join(directory, 'payload.zip'); - await writeFile(payload, '0123456789'); - const feed = await startDesktopUpdateFeed(new Map([['payload.zip', payload]])); - t.after(() => feed.close()); - - const ranged = await fetch(`${feed.url}/payload.zip`, { headers: { Range: 'bytes=2-5' } }); - assert.equal(ranged.status, 206); - assert.equal(await ranged.text(), '2345'); - assert.equal((await fetch(`${feed.url}/nested/payload.zip`)).status, 404); - assert.equal(feed.unexpectedCount(), 1); -}); diff --git a/scripts/product-release-artifacts.mjs b/scripts/product-release-artifacts.mjs index 196785fa98..5f2e645d2d 100644 --- a/scripts/product-release-artifacts.mjs +++ b/scripts/product-release-artifacts.mjs @@ -44,9 +44,13 @@ const PUBLICATION_RECORD_KEYS = [ 'assets', ]; +export function compareProductReleaseNames(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} + export function assertExactArtifactSet(actualNames, expectedNames) { - const actual = [...new Set(actualNames)].sort(); - const expected = [...new Set(expectedNames)].sort(); + const actual = [...new Set(actualNames)].sort(compareProductReleaseNames); + const expected = [...new Set(expectedNames)].sort(compareProductReleaseNames); const actualSet = new Set(actual); const expectedSet = new Set(expected); const missing = expected.filter((name) => !actualSet.has(name)); @@ -105,7 +109,7 @@ function digestFile(path, algorithm = 'sha256') { async function artifactRecords(directory, names) { return Promise.all( - [...names].sort().map(async (name) => { + [...names].sort(compareProductReleaseNames).map(async (name) => { const path = join(directory, name); const [details, digest] = await Promise.all([stat(path), digestFile(path)]); if (!details.isFile()) { @@ -227,7 +231,10 @@ export function assertProductReleasePublicationRecord(record, expected = {}) { } names.add(asset.name); } - if (JSON.stringify(record.assets.map(({ name }) => name)) !== JSON.stringify([...names].sort())) { + if ( + JSON.stringify(record.assets.map(({ name }) => name)) !== + JSON.stringify([...names].sort(compareProductReleaseNames)) + ) { throw new Error('Product release publication assets must be sorted'); } return record; diff --git a/scripts/product-release-artifacts.test.mjs b/scripts/product-release-artifacts.test.mjs deleted file mode 100644 index af8cf64f0a..0000000000 --- a/scripts/product-release-artifacts.test.mjs +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -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 test from 'node:test'; -import { - assertExactArtifactSet, - assertProductReleasePublicationRecord, - createProductReleasePublicationRecord, - stageProductReleaseArtifactGroup, - verifyProductReleaseArtifactIntegrity, - verifyProductReleasePublicationRecord, -} from './product-release-artifacts.mjs'; - -function updateMetadata(version, artifactName, bytes) { - const sha512 = createHash('sha512').update(bytes).digest('base64'); - return [ - `version: ${version}`, - 'files:', - ` - url: ${artifactName}`, - ` sha512: ${sha512}`, - ` size: ${bytes.length}`, - `path: ${artifactName}`, - `sha512: ${sha512}`, - '', - ].join('\n'); -} - -test('product release artifact validation rejects missing and unexpected files', () => { - assert.deepEqual(assertExactArtifactSet(['b.zip', 'a.dmg'], ['a.dmg', 'b.zip']), [ - 'a.dmg', - 'b.zip', - ]); - assert.throws(() => assertExactArtifactSet(['a.dmg'], ['a.dmg', 'b.zip']), /missing b\.zip/u); - assert.throws( - () => assertExactArtifactSet(['a.dmg', 'debug.log'], ['a.dmg']), - /unexpected debug\.log/u, - ); -}); - -test('artifact staging publishes exactly one manifest group', async (t) => { - const root = await mkdtemp(join(tmpdir(), 'maka-release-artifacts-')); - t.after(() => rm(root, { recursive: true, force: true })); - const sourceDirectory = join(root, 'source'); - const targetDirectory = join(root, 'target'); - await mkdir(sourceDirectory); - await Promise.all([ - writeFile(join(sourceDirectory, 'a.dmg'), 'dmg'), - writeFile(join(sourceDirectory, 'a.dmg.sha256'), 'checksum'), - ]); - - await stageProductReleaseArtifactGroup({ - sourceDirectory, - targetDirectory, - expectedNames: ['a.dmg', 'a.dmg.sha256'], - }); - - assert.equal(await readFile(join(targetDirectory, 'a.dmg'), 'utf8'), 'dmg'); - assert.equal(await readFile(join(targetDirectory, 'a.dmg.sha256'), 'utf8'), 'checksum'); -}); - -test('artifact staging refuses to replace an existing target directory', async (t) => { - const root = await mkdtemp(join(tmpdir(), 'maka-release-artifacts-existing-')); - t.after(() => rm(root, { recursive: true, force: true })); - const sourceDirectory = join(root, 'source'); - const targetDirectory = join(root, 'target'); - await Promise.all([mkdir(sourceDirectory), mkdir(targetDirectory)]); - await Promise.all([ - writeFile(join(sourceDirectory, 'a.dmg'), 'dmg'), - writeFile(join(targetDirectory, 'keep.txt'), 'keep'), - ]); - - await assert.rejects( - stageProductReleaseArtifactGroup({ - sourceDirectory, - targetDirectory, - expectedNames: ['a.dmg'], - }), - /target directory must be empty/u, - ); - assert.equal(await readFile(join(targetDirectory, 'keep.txt'), 'utf8'), 'keep'); -}); - -test('final artifact verification binds checksums and both update channels to exact bytes', async (t) => { - const directory = await mkdtemp(join(tmpdir(), 'maka-release-integrity-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const version = '1.2.3'; - const macZip = `Maka-${version}-mac-arm64.zip`; - const exe = `Maka-${version}-win-x64.exe`; - const manual = 'manual.txt'; - const macBytes = Buffer.from('mac application'); - const windowsBytes = Buffer.from('windows application'); - const manualBytes = Buffer.from('release notes'); - const names = [ - macZip, - `${macZip}.blockmap`, - 'latest-mac.yml', - exe, - `${exe}.blockmap`, - 'latest.yml', - manual, - `${manual}.sha256`, - ]; - await Promise.all([ - writeFile(join(directory, macZip), macBytes), - writeFile(join(directory, `${macZip}.blockmap`), 'mac blockmap'), - writeFile(join(directory, 'latest-mac.yml'), updateMetadata(version, macZip, macBytes)), - writeFile(join(directory, exe), windowsBytes), - writeFile(join(directory, `${exe}.blockmap`), 'windows blockmap'), - writeFile(join(directory, 'latest.yml'), updateMetadata(version, exe, windowsBytes)), - writeFile(join(directory, manual), manualBytes), - writeFile( - join(directory, `${manual}.sha256`), - `${createHash('sha256').update(manualBytes).digest('hex')} ${manual}\n`, - ), - ]); - const identity = { version, exe, artifacts: { test: names } }; - - assert.deepEqual(await verifyProductReleaseArtifactIntegrity(directory, identity), names); - await writeFile(join(directory, `${manual}.sha256`), `${'0'.repeat(64)} ${manual}\n`); - await assert.rejects( - verifyProductReleaseArtifactIntegrity(directory, identity), - /checksum does not match/u, - ); -}); - -test('publication record binds exact build identity and every artifact byte', async (t) => { - const directory = await mkdtemp(join(tmpdir(), 'maka-release-publication-record-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const version = '1.2.3'; - const macZip = `Maka-${version}-mac-arm64.zip`; - const exe = `Maka-${version}-win-x64.exe`; - const macBytes = Buffer.from('mac application'); - const windowsBytes = Buffer.from('windows application'); - const names = [ - macZip, - `${macZip}.blockmap`, - 'latest-mac.yml', - exe, - `${exe}.blockmap`, - 'latest.yml', - ]; - await Promise.all([ - writeFile(join(directory, macZip), macBytes), - writeFile(join(directory, `${macZip}.blockmap`), 'mac blockmap'), - writeFile(join(directory, 'latest-mac.yml'), updateMetadata(version, macZip, macBytes)), - writeFile(join(directory, exe), windowsBytes), - writeFile(join(directory, `${exe}.blockmap`), 'windows blockmap'), - writeFile(join(directory, 'latest.yml'), updateMetadata(version, exe, windowsBytes)), - ]); - const identity = { - version, - isPrerelease: false, - tag: `v${version}`, - sourceReferenceTag: `v${version}-incubating-rc1`, - sourceCommit: 'a'.repeat(40), - exe, - artifacts: { test: names }, - }; - const record = await createProductReleasePublicationRecord({ - artifactDirectory: directory, - identity, - repository: 'apache/maka', - runId: '123', - runAttempt: '2', - }); - - assert.equal(record.workflow, '.github/workflows/release.yml'); - assert.deepEqual( - record.assets.map(({ name }) => name), - [...names].sort(), - ); - assert.equal( - assertProductReleasePublicationRecord(record, { - repository: 'apache/maka', - tag: 'v1.2.3', - sourceCommit: 'a'.repeat(40), - sourceReferenceTag: 'v1.2.3-incubating-rc1', - runId: '123', - runAttempt: '2', - }), - record, - ); - await verifyProductReleasePublicationRecord({ - artifactDirectory: directory, - record, - expected: { runId: '123', runAttempt: '2' }, - }); - assert.throws( - () => assertProductReleasePublicationRecord(record, { runAttempt: '3' }), - /runAttempt does not match/u, - ); - - await writeFile(join(directory, macZip), 'tampered mac application'); - await assert.rejects( - verifyProductReleasePublicationRecord({ artifactDirectory: directory, record }), - /do not match the immutable publication record/u, - ); -}); diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs index d7fe78e276..5b1de049c6 100644 --- a/scripts/product-release-authority.mjs +++ b/scripts/product-release-authority.mjs @@ -25,6 +25,7 @@ import { basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { + compareProductReleaseNames, readProductReleasePublicationRecord, verifyProductReleasePublicationRecord, } from './product-release-artifacts.mjs'; @@ -84,7 +85,7 @@ function releaseSnapshotFromGhView(value) { prerelease: value?.isPrerelease, assets: (value?.assets ?? []) .map(({ name, size, digest }) => ({ name, size, digest })) - .sort((left, right) => left.name.localeCompare(right.name)), + .sort((left, right) => compareProductReleaseNames(left.name, right.name)), }; } @@ -96,7 +97,7 @@ function releaseSnapshotFromRest(value) { prerelease: value?.prerelease, assets: (value?.assets ?? []) .map(({ name, size, digest }) => ({ name, size, digest })) - .sort((left, right) => left.name.localeCompare(right.name)), + .sort((left, right) => compareProductReleaseNames(left.name, right.name)), }; } @@ -232,7 +233,7 @@ export async function publishDraftProductRelease({ const draft = await verifyDraftProductRelease({ tag, sourceCommit, repository, cwd, run }); const remoteAssets = (draft.assets ?? []) .map(({ name, size, digest }) => ({ name, size, digest })) - .sort((left, right) => left.name.localeCompare(right.name)); + .sort((left, right) => compareProductReleaseNames(left.name, right.name)); const allowedDraftAssets = remoteAssets.filter(({ name }) => name !== attestation.name); const unexpectedAttestations = remoteAssets.filter( ({ name }) => name.endsWith('-attestation.sigstore.json') && name !== attestation.name, @@ -257,7 +258,7 @@ export async function publishDraftProductRelease({ run, }); const expectedAssets = [...evidence.assets, attestation].sort((left, right) => - left.name.localeCompare(right.name), + compareProductReleaseNames(left.name, right.name), ); if (JSON.stringify(attestedDraft.assets) !== JSON.stringify(expectedAssets)) { throw new Error('Draft GitHub Release does not contain the exact attestation bundle'); diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs index 6dd19cf9d7..7e9639d916 100644 --- a/scripts/product-release-authority.test.mjs +++ b/scripts/product-release-authority.test.mjs @@ -24,68 +24,24 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; import { - assertDraftProductRelease, assertProductReleaseWorkflowRun, - assertPublishedProductRelease, publishDraftProductRelease, - verifyDraftProductRelease, } from './product-release-authority.mjs'; +import { createProductReleasePublicationRecord } from './product-release-artifacts.mjs'; -test('Draft state and prerelease classification are one product Release contract', () => { - for (const [tag, isPrerelease] of [ - ['v1.2.3', false], - ['v1.2.3-beta.1', true], - ]) { - assert.equal( - assertDraftProductRelease({ id: 42, tag, draft: true, prerelease: isPrerelease }, tag).tag, - tag, - ); - } - assert.throws( - () => - assertDraftProductRelease( - { id: 42, tag: 'v1.2.3', draft: false, prerelease: false }, - 'v1.2.3', - ), - /must remain a Draft/u, - ); - assert.throws( - () => - assertDraftProductRelease( - { id: 42, tag: 'v1.2.3-beta.1', draft: true, prerelease: false }, - 'v1.2.3-beta.1', - ), - /prerelease state must be true/u, - ); -}); - -test('published state keeps stable and prerelease classification exact', () => { - assert.equal( - assertPublishedProductRelease( - { id: 42, tag: 'v1.2.3', draft: false, prerelease: false, assets: [] }, - 'v1.2.3', - 42, - [], - ).tag, - 'v1.2.3', - ); - assert.throws( - () => - assertPublishedProductRelease( - { - id: 42, - tag: 'v1.2.3-beta.1', - draft: false, - prerelease: false, - assets: [], - }, - 'v1.2.3-beta.1', - 42, - [], - ), - /prerelease state/u, - ); -}); +function updateMetadata(version, artifactName, bytes) { + const sha512 = createHash('sha512').update(bytes).digest('base64'); + return [ + `version: ${version}`, + 'files:', + ` - url: ${artifactName}`, + ` sha512: ${sha512}`, + ` size: ${bytes.length}`, + `path: ${artifactName}`, + `sha512: ${sha512}`, + '', + ].join('\n'); +} test('Release workflow evidence binds an exact successful attempt to approved source', () => { const sourceCommit = 'a'.repeat(40); @@ -137,75 +93,48 @@ test('Release workflow evidence binds an exact successful attempt to approved so ); }); -test('the live authority verifier binds the tag, main ancestry, and Draft Release', async () => { - const sourceCommit = 'a'.repeat(40); - const calls = []; - const run = async (command, args) => { - calls.push([command, args]); - if (args[0] === 'ls-remote') { - return { stdout: `${sourceCommit}\trefs/tags/v1.2.3\n` }; - } - if (command === 'gh') { - return { - stdout: JSON.stringify({ - databaseId: 42, - tagName: 'v1.2.3', - isDraft: true, - isPrerelease: false, - assets: [], - }), - }; - } - return { stdout: '' }; - }; - - await verifyDraftProductRelease({ - tag: 'v1.2.3', - sourceCommit, - repository: 'apache/maka', - run, - }); - - assert.deepEqual( - calls.map(([command, args]) => [command, args[0]]), - [ - ['git', 'ls-remote'], - ['git', 'fetch'], - ['git', 'merge-base'], - ['gh', 'release'], - ], - ); - assert.deepEqual(calls.at(-1)[1].slice(-2), [ - '--json', - 'databaseId,tagName,isDraft,isPrerelease,assets', - ]); -}); - -test('the live authority verifier rejects product tag drift before later checks', async () => { - const calls = []; - await assert.rejects( - verifyDraftProductRelease({ - tag: 'v1.2.3', - sourceCommit: 'a'.repeat(40), - repository: 'apache/maka', - run: async (command, args) => { - calls.push([command, args]); - return { stdout: `${'b'.repeat(40)}\trefs/tags/v1.2.3\n` }; - }, - }), - /points to .* instead of/u, - ); - assert.equal(calls.length, 1); -}); - test('publication verifies live asset digests before one Stable/Latest mutation', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'maka-publish-authority-')); const recordDirectory = await mkdtemp(join(tmpdir(), 'maka-publish-record-')); t.after(() => rm(directory, { recursive: true, force: true })); t.after(() => rm(recordDirectory, { recursive: true, force: true })); - const contents = Buffer.from('verified artifact'); - await writeFile(join(directory, 'artifact.zip'), contents); - const digest = `sha256:${createHash('sha256').update(contents).digest('hex')}`; + const version = '1.2.3'; + const macZip = `Maka-${version}-mac-arm64.zip`; + const exe = `Maka-${version}-win-x64.exe`; + const macBytes = Buffer.from('mac application'); + const windowsBytes = Buffer.from('windows application'); + const names = [ + macZip, + `${macZip}.blockmap`, + 'latest-mac.yml', + exe, + `${exe}.blockmap`, + 'latest.yml', + ]; + await Promise.all([ + writeFile(join(directory, macZip), macBytes), + writeFile(join(directory, `${macZip}.blockmap`), 'mac blockmap'), + writeFile(join(directory, 'latest-mac.yml'), updateMetadata(version, macZip, macBytes)), + writeFile(join(directory, exe), windowsBytes), + writeFile(join(directory, `${exe}.blockmap`), 'windows blockmap'), + writeFile(join(directory, 'latest.yml'), updateMetadata(version, exe, windowsBytes)), + ]); + const sourceCommit = 'a'.repeat(40); + const record = await createProductReleasePublicationRecord({ + artifactDirectory: directory, + identity: { + version, + isPrerelease: false, + tag: `v${version}`, + sourceReferenceTag: `v${version}-incubating-rc1`, + sourceCommit, + exe, + artifacts: { test: names }, + }, + repository: 'apache/maka', + runId: '123', + runAttempt: '2', + }); const attestationContents = Buffer.from('sigstore bundle'); const attestationBundlePath = join(recordDirectory, 'Maka-1.2.3-attestation.sigstore.json'); await writeFile(attestationBundlePath, attestationContents); @@ -215,23 +144,7 @@ test('publication verifies live asset digests before one Stable/Latest mutation' digest: `sha256:${createHash('sha256').update(attestationContents).digest('hex')}`, }; const publicationRecordPath = join(recordDirectory, 'product-release.json'); - const sourceCommit = 'a'.repeat(40); - await writeFile( - publicationRecordPath, - `${JSON.stringify({ - schemaVersion: 1, - repository: 'apache/maka', - workflow: '.github/workflows/release.yml', - runId: '123', - runAttempt: '2', - sourceReferenceTag: 'v1.2.3-incubating-rc1', - sourceCommit, - tag: 'v1.2.3', - version: '1.2.3', - prerelease: false, - assets: [{ name: 'artifact.zip', size: contents.length, digest }], - })}\n`, - ); + await writeFile(publicationRecordPath, `${JSON.stringify(record)}\n`); const calls = []; let latestReads = 0; let attestationUploaded = false; @@ -251,10 +164,7 @@ test('publication verifies live asset digests before one Stable/Latest mutation' tagName: 'v1.2.3', isDraft: true, isPrerelease: false, - assets: [ - ...(attestationUploaded ? [attestation] : []), - { name: 'artifact.zip', size: contents.length, digest }, - ], + assets: [...record.assets, ...(attestationUploaded ? [attestation] : [])], }), }; } @@ -265,7 +175,7 @@ test('publication verifies live asset digests before one Stable/Latest mutation' tag_name: 'v1.2.3', draft: false, prerelease: false, - assets: [attestation, { name: 'artifact.zip', size: contents.length, digest }], + assets: [...record.assets, attestation], }), }; } @@ -276,7 +186,7 @@ test('publication verifies live asset digests before one Stable/Latest mutation' return { stdout: '' }; }; - await publishDraftProductRelease({ + const publication = { tag: 'v1.2.3', sourceCommit, repository: 'apache/maka', @@ -286,8 +196,23 @@ test('publication verifies live asset digests before one Stable/Latest mutation' releaseRunId: '123', releaseRunAttempt: '2', attestationBundlePath, - run, pause: async () => {}, + }; + await writeFile(join(directory, macZip), 'tampered mac application'); + await assert.rejects( + publishDraftProductRelease({ + ...publication, + run: async () => { + throw new Error('network must not be reached for invalid local evidence'); + }, + }), + /do not match the immutable publication record/u, + ); + await writeFile(join(directory, macZip), macBytes); + + await publishDraftProductRelease({ + ...publication, + run, }); const patchCall = calls.find(([, args]) => args.includes('PATCH')); @@ -295,11 +220,8 @@ test('publication verifies live asset digests before one Stable/Latest mutation' assert.ok(patchCall[1].includes('draft=false')); assert.ok(patchCall[1].includes('prerelease=false')); assert.ok(patchCall[1].includes('make_latest=true')); - assert.ok(calls.every(([, args]) => !args.includes('repos/apache/maka/releases/tags/v1.2.3'))); const uploadCall = calls.find(([, args]) => args[0] === 'release' && args[1] === 'upload'); assert.ok(uploadCall); - assert.ok(uploadCall[1].includes('--clobber')); assert.ok(calls.indexOf(uploadCall) < calls.indexOf(patchCall)); - assert.ok(calls.every(([, args]) => !args.includes('repos/apache/maka/immutable-releases'))); assert.equal(latestReads, 2); }); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 2855c8e792..ba4e871bc8 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -697,42 +697,16 @@ test('one product workflow gates one draft release on every required artifact', String(step.uses).startsWith('actions/upload-artifact@'), ); assert.equal(upload.with.path, '${{ runner.temp }}/release-assets'); - assert.equal(upload.with['retention-days'], 30); - assert.match(upload.with.name, /release-.*\$\{\{ github\.run_attempt \}\}/u); } const verifyArtifacts = jobs.publish.steps.find( (step) => step.name === 'Verify the exact product artifact manifest', ).run; assert.match(verifyArtifacts, /product-release-artifacts\.mjs verify release-assets/u); - assert.doesNotMatch(verifyArtifacts, /sha256sum|find release-assets/u); assert.doesNotMatch(verifyArtifacts, /required=\(|Maka-\*|latest\*\.yml/u); - const installVerifier = jobs.publish.steps.findIndex( - (step) => step.name === 'Install the release verifier dependencies', - ); - const verifyManifest = jobs.publish.steps.findIndex( - (step) => step.name === 'Verify the exact product artifact manifest', - ); - assert.ok(installVerifier >= 0 && installVerifier < verifyManifest); const publicationRecord = jobs.publish.steps.find( (step) => step.name === 'Record the immutable publication evidence', ); assert.match(publicationRecord.run, /product-release-artifacts\.mjs record/u); - assert.equal( - publicationRecord.env.GITHUB_SHA, - '${{ needs.release-identity.outputs.source_commit }}', - ); - assert.equal( - publicationRecord.env.SOURCE_REFERENCE_TAG, - '${{ needs.release-identity.outputs.source_reference_tag }}', - ); - const publicationRecordUpload = jobs.publish.steps.find( - (step) => step.name === 'Upload the immutable publication evidence', - ); - assert.equal( - publicationRecordUpload.with.name, - 'product-release-record-${{ github.run_attempt }}', - ); - assert.equal(publicationRecordUpload.with['retention-days'], 30); const commands = Object.values(jobs) .flatMap((job) => job.steps ?? []) diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index ac800784f2..e849d3836a 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -89,10 +89,7 @@ test('stage builds the npm candidate from the exact product release commit', () test('finalize runs the current verifier from reviewed main against exact build evidence', () => { const workflow = readWorkflow('release-cli-finalize.yml'); - const productWorkflow = readWorkflow('release.yml'); const steps = workflowSteps(workflow); - assert.match(workflow, /concurrency:\n group: product-release/u); - assert.match(productWorkflow, /concurrency:\n group: product-release/u); assert.match(workflow, /stage_run_attempt:[\s\S]*?required: true/u); const loadIndex = workflow.indexOf('name: Load the exact stage workflow run'); const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); @@ -104,14 +101,6 @@ test('finalize runs the current verifier from reviewed main against exact build assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); const requireMain = namedStep(steps, 'Require main'); assert.match(requireMain, /refs\/heads\/main/u); - const recordDownloads = steps.filter((step) => - step.includes('name: Download the publication record'), - ); - assert.equal(recordDownloads.length, 2); - for (const download of recordDownloads) { - assert.match(download, /product-release-record-\$\{\{ inputs\.release_run_attempt \}\}/u); - assert.match(download, /run-id: \$\{\{ inputs\.release_run_id \}\}/u); - } }); test('finalize revalidates the live product release before trusting public npm bytes', () => { @@ -141,21 +130,12 @@ test('finalize preserves npm evidence and owns the single product publication bo assert.match(workflow, /product-release-authority\.mjs publish-draft/u); assert.match(workflow, /actions\/attest@[0-9a-f]{40}/u); assert.match(workflow, /subject-path: \$\{\{ runner\.temp \}\}\/product-release\/\*/u); - assert.match(workflow, /Maka-\$\{PRODUCT_VERSION\}-attestation\.sigstore\.json/u); - assert.doesNotMatch( - workflow, - /create-github-app-token|permission-administration|RELEASE_POLICY/u, - ); const artifacts = namedStep( workflowSteps(workflow), 'Download the exact verified Release run artifacts', ); assert.match(artifacts, /run-id: \$\{\{ inputs\.release_run_id \}\}/u); assert.match(artifacts, /release-\*-\$\{\{ inputs\.release_run_attempt \}\}/u); - assert.doesNotMatch(workflow, /ref: \$\{\{ needs\.inspect\.outputs\.source_commit \}\}/u); - assert.doesNotMatch(workflow, /product-release-artifacts\.mjs verify/u); - assert.doesNotMatch(workflow, /gh release download/u); - assert.doesNotMatch(workflow, /gh release edit|gh release create/u); }); test('release workflows select npm from the root packageManager authority', () => { From 26a9b27aeb0cb940bb97d87995bc4aef1d5d7808 Mon Sep 17 00:00:00 2001 From: Wang Date: Thu, 27 Aug 2026 23:06:57 +0800 Subject: [PATCH 6/7] test(release): keep only boundary evidence --- .../main/__tests__/app-update-service.test.ts | 55 +---- apps/desktop/src/main/app-update-service.ts | 2 - .../src/main/app-update-test-context.ts | 8 +- package.json | 3 +- scripts/ci-test-plan.mjs | 2 - scripts/ci-test-plan.test.mjs | 2 - scripts/desktop-update-contract.test.mjs | 40 ---- scripts/experiment-macos-autoupdate.mjs | 196 ------------------ scripts/product-release-authority.test.mjs | 55 +---- scripts/product-release.test.mjs | 9 - scripts/release-cli-workflow-policy.test.mjs | 1 - 11 files changed, 5 insertions(+), 368 deletions(-) delete mode 100644 scripts/desktop-update-contract.test.mjs delete mode 100644 scripts/experiment-macos-autoupdate.mjs diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index 8981f98ad7..3206ca6608 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -21,10 +21,9 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { describe, test } from 'node:test'; import type { AppUpdater } from 'electron-updater'; -import { resolveUpdateTestUserDataDirectory } from '../app-update-test-context.js'; +import { resolveUpdateFeedOverride } from '../app-update-test-context.js'; import { createAppUpdateService, - resolveUpdateFeedOverride, type AppUpdateInstallRequest, type AppUpdateStatus, } from '../app-update-service.js'; @@ -237,36 +236,6 @@ describe('AppUpdateService', () => { } }); - test('the macOS update harness keeps candidate and relaunched successor in one profile', () => { - const explicit = resolveUpdateTestUserDataDirectory({ - feedUrl: 'http://127.0.0.1:1234', - explicitDirectory: '/tmp/update/candidate/.maka-update-test-user-data', - isPackaged: true, - appPath: '/tmp/update/candidate/Maka.app/Contents/Resources/app.asar', - executablePath: '/tmp/update/candidate/Maka.app/Contents/MacOS/Maka', - platform: 'darwin', - }); - const relaunched = resolveUpdateTestUserDataDirectory({ - isPackaged: true, - appPath: '/tmp/update/candidate/Maka.app/Contents/Resources/app.asar', - executablePath: '/tmp/update/candidate/Maka.app/Contents/MacOS/Maka', - platform: 'darwin', - read: () => JSON.stringify({ makaUpdateTestProfile: true }), - }); - assert.equal(relaunched, explicit); - assert.throws( - () => - resolveUpdateTestUserDataDirectory({ - explicitDirectory: '/tmp/update/profile', - isPackaged: true, - appPath: '', - executablePath: '', - platform: 'darwin', - }), - /requires MAKA_UPDATE_TEST_FEED/u, - ); - }); - test('does not overlap checks and cannot re-arm after disposal', async () => { const updater = new FakeUpdater(); let settleCheck!: (value: unknown) => void; @@ -349,28 +318,6 @@ describe('AppUpdateService', () => { }); }); - test('does not expose an update for installation until provenance verification succeeds', async () => { - let finishVerification!: () => void; - const { service, updater } = createHarness({ - verifyDownloadedUpdate: () => new Promise((resolve) => { - finishVerification = resolve; - }), - }); - updater.emit('update-downloaded', { - ...updateInfo('1.1.0'), - downloadedFile: '/tmp/maka-update.zip', - }); - - assert.equal(service.getStatus().state, 'verifying'); - assert.deepEqual(await service.installUpdate({ allowInterruptActiveTasks: false }), { - ok: false, - reason: 'not_downloaded', - }); - finishVerification(); - await settleUpdateVerification(); - assert.equal(service.getStatus().state, 'downloaded'); - }); - test('fails closed when downloaded update provenance cannot be verified', async () => { const { service, updater } = createHarness({ verifyDownloadedUpdate: async () => { diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index ff1cbecc72..ade195f335 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -23,8 +23,6 @@ import type { ProgressInfo, UpdateInfo } from 'electron-updater'; import type { DownloadedUpdateAttestationVerifier } from './app-update-attestation.js'; import { resolveUpdateFeedOverride } from './app-update-test-context.js'; -export { resolveUpdateFeedOverride } from './app-update-test-context.js'; - export type AppUpdateProgress = { percent: number; bytesPerSecond?: number; diff --git a/apps/desktop/src/main/app-update-test-context.ts b/apps/desktop/src/main/app-update-test-context.ts index f2a705744b..6700140002 100644 --- a/apps/desktop/src/main/app-update-test-context.ts +++ b/apps/desktop/src/main/app-update-test-context.ts @@ -62,16 +62,12 @@ export function resolveUpdateTestUserDataDirectory({ isPackaged, appPath, executablePath, - platform = process.platform, - read = (path, encoding) => readFileSync(path, encoding), }: { feedUrl?: string; explicitDirectory?: string; isPackaged: boolean; appPath: string; executablePath: string; - platform?: NodeJS.Platform; - read?: (path: string, encoding: 'utf8') => string; }): string | undefined { if (explicitDirectory) { if (!resolveUpdateFeedOverride(feedUrl)) { @@ -82,11 +78,11 @@ export function resolveUpdateTestUserDataDirectory({ } return explicitDirectory; } - if (!isPackaged || platform !== 'darwin') return undefined; + if (!isPackaged || process.platform !== 'darwin') return undefined; let manifest: unknown; try { - manifest = JSON.parse(read(join(appPath, 'package.json'), 'utf8')); + manifest = JSON.parse(readFileSync(join(appPath, 'package.json'), 'utf8')); } catch { return undefined; } diff --git a/package.json b/package.json index 728efb5325..3d960adda0 100644 --- a/package.json +++ b/package.json @@ -69,12 +69,11 @@ "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/desktop-update-contract.test.mjs scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:macos-autoupdate-next": "node scripts/package-macos-autoupdate-next.mjs", "verify:macos-autoupdate": "node scripts/verify-macos-autoupdate.mjs", - "experiment:macos-autoupdate": "node scripts/experiment-macos-autoupdate.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", "verify:windows-x64": "node scripts/verify-windows-x64.mjs", "verify:windows-installer": "node scripts/verify-windows-installer-lifecycle.mjs", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 1032132b36..9e4f034f5b 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -62,8 +62,6 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/verify-macos-arm64-dmg.mjs', 'scripts/verify-macos-autoupdate.mjs', 'scripts/desktop-update-contract.mjs', - 'scripts/desktop-update-contract.test.mjs', - 'scripts/experiment-macos-autoupdate.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-windows-autoupdate.mjs', 'scripts/verify-windows-installer-lifecycle.mjs', diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 1eb8cb0e38..b082b88ff4 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -156,8 +156,6 @@ test('release authority changes select their dedicated contract gate', () => { 'scripts/verify-macos-arm64-dmg.mjs', 'scripts/verify-macos-autoupdate.mjs', 'scripts/desktop-update-contract.mjs', - 'scripts/desktop-update-contract.test.mjs', - 'scripts/experiment-macos-autoupdate.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-windows-x64.mjs', 'scripts/windows-upgrade-baseline.json', diff --git a/scripts/desktop-update-contract.test.mjs b/scripts/desktop-update-contract.test.mjs deleted file mode 100644 index 4721413485..0000000000 --- a/scripts/desktop-update-contract.test.mjs +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; - -test('packaged clients have one exact production update authority', async () => { - const expected = - 'owner: apache\nrepo: maka\nprovider: github\nupdaterCacheDirName: "@makadesktop-updater"\n'; - await assertPackagedUpdateConfiguration('resources', { read: async () => expected }); - await assert.rejects( - assertPackagedUpdateConfiguration('resources', { - read: async () => expected.replace('owner: apache', 'owner: personal-fork'), - }), - /expected.*apache/u, - ); - await assert.rejects( - assertPackagedUpdateConfiguration('resources', { - read: async () => `${expected}extra: rejected\n`, - }), - /expected/u, - ); -}); diff --git a/scripts/experiment-macos-autoupdate.mjs b/scripts/experiment-macos-autoupdate.mjs deleted file mode 100644 index 168b85ccf1..0000000000 --- a/scripts/experiment-macos-autoupdate.mjs +++ /dev/null @@ -1,196 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { spawn } from 'node:child_process'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; -import { packageMacosAutoupdateNext } from './package-macos-autoupdate-next.mjs'; -import { verifyMacosAutoupdate } from './verify-macos-autoupdate.mjs'; - -const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const desktopRoot = join(repoRoot, 'apps', 'desktop'); - -function run(command, args, { env = process.env } = {}) { - return new Promise((resolvePromise, reject) => { - const child = spawn(command, args, { cwd: repoRoot, env, stdio: 'inherit' }); - child.once('error', reject); - child.once('exit', (code, signal) => { - if (code === 0) resolvePromise({ stdout: '', stderr: '' }); - else - reject(new Error(`${command} failed with ${signal ? `signal ${signal}` : `exit ${code}`}`)); - }); - }); -} - -export async function experimentMacosAutoupdate({ - platform = process.platform, - arch = process.arch, -} = {}) { - if (platform !== 'darwin' || arch !== 'arm64') { - throw new Error('The local macOS auto-update experiment requires Apple Silicon macOS.'); - } - const temporaryDirectory = await mkdtemp(join(tmpdir(), 'maka-update-signing-')); - const password = 'maka-local-update-test'; - const privateKey = join(temporaryDirectory, 'identity.key'); - const certificate = join(temporaryDirectory, 'identity.crt'); - const identity = join(temporaryDirectory, 'identity.p12'); - const keychain = join(temporaryDirectory, 'local-signing.keychain-db'); - const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); - const candidateZipName = `Maka-${manifest.version}-mac-arm64.zip`; - const candidateZip = join(desktopRoot, 'release', candidateZipName); - - try { - await run('openssl', [ - 'req', - '-x509', - '-newkey', - 'rsa:2048', - '-sha256', - '-days', - '1', - '-nodes', - '-subj', - '/CN=Maka Local Update Test', - '-addext', - 'keyUsage=digitalSignature', - '-addext', - 'extendedKeyUsage=codeSigning', - '-keyout', - privateKey, - '-out', - certificate, - ]); - await run('openssl', [ - 'pkcs12', - '-export', - // macOS Security still imports PKCS#12 through its legacy-compatible - // cipher path; OpenSSL 3's defaults otherwise fail as a wrong password. - '-legacy', - '-inkey', - privateKey, - '-in', - certificate, - '-name', - 'Maka Local Update Test', - '-passout', - `pass:${password}`, - '-out', - identity, - ]); - await run('security', ['create-keychain', '-p', password, keychain]); - await run('security', ['unlock-keychain', '-p', password, keychain]); - await run('security', ['set-keychain-settings', '-lut', '21600', keychain]); - await run('security', [ - 'import', - identity, - '-k', - keychain, - '-P', - password, - '-T', - '/usr/bin/codesign', - '-T', - '/usr/bin/productbuild', - ]); - await run('security', [ - 'set-key-partition-list', - '-S', - 'apple-tool:,apple:', - '-s', - '-k', - password, - keychain, - ]); - await run('security', [ - 'add-trusted-cert', - '-r', - 'trustRoot', - '-p', - 'codeSign', - '-k', - keychain, - certificate, - ]); - await run('security', ['find-identity', '-v', '-p', 'codesigning', keychain]); - const env = { - ...process.env, - CSC_KEYCHAIN: keychain, - MAKA_LOCAL_UPDATE_SIGNING_IDENTITY: 'Maka Local Update Test', - }; - delete env.CSC_LINK; - delete env.CSC_KEY_PASSWORD; - - console.log('[experiment-macos-autoupdate] building the application once'); - await run('npm', ['run', 'clean'], { env }); - await run('npm', ['run', 'build'], { env }); - await run('npm', ['run', 'build:runtime-host-peer'], { env }); - await run('npm', ['run', 'check:runtime-host-peer-notices'], { env }); - await rm(join(desktopRoot, 'release'), { recursive: true, force: true }); - - console.log(`[experiment-macos-autoupdate] packaging signed candidate ${manifest.version}`); - await run( - 'npm', - [ - '--workspace', - '@maka/desktop', - 'exec', - '--', - 'electron-builder', - '--config', - 'electron-builder.config.mjs', - '--mac', - 'zip', - '--arm64', - '--publish', - 'never', - '-c.mac.notarize=false', - '-c.mac.identity=Maka Local Update Test', - '-c.mac.timestamp=none', - // A self-signed identity has no Apple Team ID, so macOS library - // validation cannot relate Electron's nested signatures to the app. - // Production and release E2E builds keep hardenedRuntime enabled and - // use the real Developer ID identity. - '-c.mac.hardenedRuntime=false', - ], - { env }, - ); - await verifyDesktopUpdateArtifacts({ - directory: join(desktopRoot, 'release'), - metadataName: 'latest-mac.yml', - version: manifest.version, - artifactName: candidateZipName, - }); - - console.log('[experiment-macos-autoupdate] packaging the signed successor'); - await packageMacosAutoupdateNext({ run, env }); - return await verifyMacosAutoupdate(candidateZip, join(desktopRoot, 'release-autoupdate-next')); - } finally { - await run('security', ['remove-trusted-cert', certificate]).catch(() => {}); - await run('security', ['delete-keychain', keychain]).catch(() => {}); - await rm(temporaryDirectory, { recursive: true, force: true }); - } -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const result = await experimentMacosAutoupdate(); - console.log(JSON.stringify(result, null, 2)); -} diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs index 7e9639d916..f8bf6d7070 100644 --- a/scripts/product-release-authority.test.mjs +++ b/scripts/product-release-authority.test.mjs @@ -23,10 +23,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { - assertProductReleaseWorkflowRun, - publishDraftProductRelease, -} from './product-release-authority.mjs'; +import { publishDraftProductRelease } from './product-release-authority.mjs'; import { createProductReleasePublicationRecord } from './product-release-artifacts.mjs'; function updateMetadata(version, artifactName, bytes) { @@ -43,56 +40,6 @@ function updateMetadata(version, artifactName, bytes) { ].join('\n'); } -test('Release workflow evidence binds an exact successful attempt to approved source', () => { - const sourceCommit = 'a'.repeat(40); - const run = { - id: 123, - run_attempt: 2, - path: '.github/workflows/release.yml', - event: 'workflow_dispatch', - status: 'completed', - conclusion: 'success', - head_sha: sourceCommit, - head_branch: 'v1.2.3-incubating-rc1', - head_repository: { full_name: 'apache/maka' }, - }; - assert.equal( - assertProductReleaseWorkflowRun({ - run, - tag: 'v1.2.3', - sourceCommit, - repository: 'apache/maka', - runId: '123', - runAttempt: '2', - }), - run, - ); - assert.throws( - () => - assertProductReleaseWorkflowRun({ - run: { ...run, head_sha: 'b'.repeat(40) }, - tag: 'v1.2.3', - sourceCommit, - repository: 'apache/maka', - runId: '123', - runAttempt: '2', - }), - /does not match the approved product source/u, - ); - assert.throws( - () => - assertProductReleaseWorkflowRun({ - run, - tag: 'v1.2.3', - sourceCommit, - repository: 'apache/maka', - runId: '0', - runAttempt: '2', - }), - /must be positive integers/u, - ); -}); - test('publication verifies live asset digests before one Stable/Latest mutation', async (t) => { const directory = await mkdtemp(join(tmpdir(), 'maka-publish-authority-')); const recordDirectory = await mkdtemp(join(tmpdir(), 'maka-publish-record-')); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index ba4e871bc8..5252bbca7a 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -677,9 +677,7 @@ test('one product workflow gates one draft release on every required artifact', assert.ok(uploadIndex >= 0); for (const verifier of [ 'Verify the final DMG', - 'Verify macOS automatic update end to end', 'Verify the Windows release', - 'Verify automatic update end to end', 'Prove deterministic mid-install failure rollback', ]) { const verifierIndex = desktopStepNames.indexOf(verifier); @@ -703,11 +701,6 @@ test('one product workflow gates one draft release on every required artifact', ).run; assert.match(verifyArtifacts, /product-release-artifacts\.mjs verify release-assets/u); assert.doesNotMatch(verifyArtifacts, /required=\(|Maka-\*|latest\*\.yml/u); - const publicationRecord = jobs.publish.steps.find( - (step) => step.name === 'Record the immutable publication evidence', - ); - assert.match(publicationRecord.run, /product-release-artifacts\.mjs record/u); - const commands = Object.values(jobs) .flatMap((job) => job.steps ?? []) .map((step) => step.run) @@ -717,8 +710,6 @@ test('one product workflow gates one draft release on every required artifact', assert.equal(jobs.desktop['timeout-minutes'], 75); assert.match(commands, /npm run package:windows-autoupdate-next/u); assert.match(commands, /npm run verify:windows-autoupdate/u); - assert.match(commands, /npm run package:macos-autoupdate-next/u); - assert.match(commands, /npm run verify:macos-autoupdate/u); assert.match(commands, /npm run verify:windows-installer-rollback/u); assert.match(commands, /product-release-tag\.mjs ensure/u); assert.doesNotMatch(commands, /RECOVERY_SOURCE|inputs\.source_commit/u); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index e849d3836a..e561a4314f 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -129,7 +129,6 @@ test('finalize preserves npm evidence and owns the single product publication bo assert.match(workflow, /attestations: write/u); assert.match(workflow, /product-release-authority\.mjs publish-draft/u); assert.match(workflow, /actions\/attest@[0-9a-f]{40}/u); - assert.match(workflow, /subject-path: \$\{\{ runner\.temp \}\}\/product-release\/\*/u); const artifacts = namedStep( workflowSteps(workflow), 'Download the exact verified Release run artifacts', From 31ff9805c0c17615596ac7e560037e1936fddf77 Mon Sep 17 00:00:00 2001 From: Wang Date: Thu, 27 Aug 2026 23:30:05 +0800 Subject: [PATCH 7/7] fix(release): verify provenance before publication --- .github/workflows/release-cli-finalize.yml | 74 ++++++++++-- apps/desktop/src/main/app-update-service.ts | 5 +- apps/desktop/src/main/runtime-host-boot.ts | 3 +- docs/cli-npm-release.md | 3 +- docs/cli-npm-release.zh-CN.md | 3 +- scripts/product-release-authority.mjs | 115 ++++++++++++++----- scripts/release-cli-publication.mjs | 1 + scripts/release-cli-publication.test.mjs | 1 + scripts/release-cli-workflow-policy.test.mjs | 21 +++- 9 files changed, 172 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index dd241bb41c..49c7667a1d 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -56,6 +56,9 @@ jobs: timeout-minutes: 20 outputs: product_tag: ${{ steps.release.outputs.product_tag }} + product_version: ${{ steps.release.outputs.version }} + release_run_id: ${{ steps.release-run.outputs.run_id }} + release_run_attempt: ${{ steps.release-run.outputs.run_attempt }} source_commit: ${{ steps.release.outputs.source_commit }} source_reference_tag: ${{ steps.authority.outputs.source_reference_tag }} steps: @@ -85,6 +88,7 @@ jobs: gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID/attempts/$STAGE_RUN_ATTEMPT" > "$RUNNER_TEMP/stage-run.json" - name: Load the exact Release workflow run + id: release-run env: GH_TOKEN: ${{ github.token }} RELEASE_RUN_ID: ${{ inputs.release_run_id }} @@ -99,6 +103,8 @@ jobs: exit 1 fi gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID/attempts/$RELEASE_RUN_ATTEMPT" > "$RUNNER_TEMP/release-run.json" + echo "run_id=$RELEASE_RUN_ID" >> "$GITHUB_OUTPUT" + echo "run_attempt=$RELEASE_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -150,14 +156,16 @@ jobs: GH_TOKEN: ${{ github.token }} PRODUCT_SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} PRODUCT_TAG: ${{ steps.release.outputs.product_tag }} + RELEASE_RUN_ATTEMPT: ${{ steps.release-run.outputs.run_attempt }} + RELEASE_RUN_ID: ${{ steps.release-run.outputs.run_id }} run: | node scripts/product-release-authority.mjs verify-build-run \ "$RUNNER_TEMP/release-run.json" \ "$PRODUCT_TAG" \ "$PRODUCT_SOURCE_COMMIT" \ "$GITHUB_REPOSITORY" \ - "${{ inputs.release_run_id }}" \ - "${{ inputs.release_run_attempt }}" + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" source_reference_tag="$(jq -r .head_branch "$RUNNER_TEMP/release-run.json")" node scripts/product-release-artifacts.mjs inspect-record \ "$RUNNER_TEMP/product-release-record/product-release.json" \ @@ -165,8 +173,8 @@ jobs: "$PRODUCT_TAG" \ "$PRODUCT_SOURCE_COMMIT" \ "$source_reference_tag" \ - "${{ inputs.release_run_id }}" \ - "${{ inputs.release_run_attempt }}" + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" echo "source_reference_tag=$source_reference_tag" >> "$GITHUB_OUTPUT" @@ -227,21 +235,40 @@ jobs: - name: Download the exact verified Release run artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: release-*-${{ inputs.release_run_attempt }} + pattern: release-*-${{ needs.inspect.outputs.release_run_attempt }} path: ${{ runner.temp }}/product-release merge-multiple: true github-token: ${{ github.token }} repository: ${{ github.repository }} - run-id: ${{ inputs.release_run_id }} + run-id: ${{ needs.inspect.outputs.release_run_id }} - name: Download the publication record uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: product-release-record-${{ inputs.release_run_attempt }} + name: product-release-record-${{ needs.inspect.outputs.release_run_attempt }} path: ${{ runner.temp }}/product-release-record github-token: ${{ github.token }} repository: ${{ github.repository }} - run-id: ${{ inputs.release_run_id }} + run-id: ${{ needs.inspect.outputs.release_run_id }} + + - name: Verify the exact publication input + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} + PRODUCT_SOURCE_REFERENCE_TAG: ${{ needs.inspect.outputs.source_reference_tag }} + PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} + RELEASE_RUN_ATTEMPT: ${{ needs.inspect.outputs.release_run_attempt }} + RELEASE_RUN_ID: ${{ needs.inspect.outputs.release_run_id }} + run: | + node scripts/product-release-authority.mjs verify-publication \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "$RUNNER_TEMP/product-release" \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$PRODUCT_SOURCE_REFERENCE_TAG" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" - name: Attest the verified convenience artifacts id: attest @@ -249,10 +276,30 @@ jobs: with: subject-path: ${{ runner.temp }}/product-release/* + - name: Verify the issued provenance + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/release-cli-finalize.yml@refs/heads/main + GH_TOKEN: ${{ github.token }} + run: | + verified=0 + while IFS= read -r -d '' artifact; do + gh attestation verify "$artifact" \ + --bundle "$ATTESTATION_BUNDLE" \ + --repo "$GITHUB_REPOSITORY" \ + --cert-identity "$CERTIFICATE_IDENTITY" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com + verified=$((verified + 1)) + done < <(find "$RUNNER_TEMP/product-release" -maxdepth 1 -type f -print0) + if (( verified == 0 )); then + echo "No product release artifacts were verified" >&2 + exit 1 + fi + - name: Name the offline verification bundle env: ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} - PRODUCT_VERSION: ${{ inputs.version }} + PRODUCT_VERSION: ${{ needs.inspect.outputs.product_version }} run: >- cp -- "$ATTESTATION_BUNDLE" "$RUNNER_TEMP/Maka-${PRODUCT_VERSION}-attestation.sigstore.json" @@ -263,6 +310,9 @@ jobs: PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} PRODUCT_SOURCE_REFERENCE_TAG: ${{ needs.inspect.outputs.source_reference_tag }} PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} + PRODUCT_VERSION: ${{ needs.inspect.outputs.product_version }} + RELEASE_RUN_ATTEMPT: ${{ needs.inspect.outputs.release_run_attempt }} + RELEASE_RUN_ID: ${{ needs.inspect.outputs.release_run_id }} run: | node scripts/product-release-authority.mjs publish-draft \ "$PRODUCT_TAG" \ @@ -271,6 +321,6 @@ jobs: "$RUNNER_TEMP/product-release" \ "$RUNNER_TEMP/product-release-record/product-release.json" \ "$PRODUCT_SOURCE_REFERENCE_TAG" \ - "${{ inputs.release_run_id }}" \ - "${{ inputs.release_run_attempt }}" \ - "$RUNNER_TEMP/Maka-${{ inputs.version }}-attestation.sigstore.json" + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" \ + "$RUNNER_TEMP/Maka-${PRODUCT_VERSION}-attestation.sigstore.json" diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index ade195f335..2cf9c93743 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -140,9 +140,8 @@ const UPDATE_CHECK_ON_FOCUS_MIN_INTERVAL_MS = 15 * 60 * 1000; * capability across any privilege boundary. Loopback-only keeps even that * same-user surface minimal: the feed must be a process listening on this * machine. With the variable unset the feed configuration is byte-identical - * to production, and update signature verification (once a certificate - * exists) applies to overridden feeds exactly as it does to the GitHub feed — - * nothing here relaxes it. + * to production. The application composition root deliberately bypasses + * release provenance only for this synthetic-byte transport harness. */ function normalizeVersion(version: string): string { return version.trim().replace(/^v/i, ''); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 04f3cf4ec9..2132d23afb 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -667,7 +667,8 @@ const updateService = createAppUpdateService({ onStatusChange: (status) => mainWindowController.send("app:updateStatusChanged", status), // The loopback-only upgrade harness owns synthetic bytes that cannot carry - // a GitHub Actions identity. Production has no override and always reaches + // a GitHub Actions identity, so it tests updater mechanics rather than + // provenance. Ordinary packaged launches have no override and always reach // the Sigstore verifier below. verifyDownloadedUpdate: updateTestFeed ? async () => {} diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index c5a2b7a0ad..6322344b85 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -40,7 +40,8 @@ retaining Maka's stronger protected-Environment, staged-publishing, 2FA, and Fin - Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to a version older than `latest`; when no newer prerelease exists, both tags point to the stable version. -- Do not create an npm-specific Git tag or GitHub Release. The product `v` tag and GitHub Release are owned only by the `Release` workflow, and must already exist before npm staging. +- Do not create an npm-specific Git tag or GitHub Release. The `Release` workflow creates the + product `v` tag and Draft before npm staging; Finalize is the sole publisher of that Draft. - Keep that GitHub Release in Draft until npm Finalize and Desktop remote Runtime Host acceptance succeed. The Draft supplies npm's product identity; its publication is the final product action. - Do not run `npm publish`. GitHub Actions may only run `npm stage publish`; a human package diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index fbef8f9162..76cefee8f4 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -33,7 +33,8 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 - 产品 Release workflow 只能从已批准的 ASF source candidate tag dispatch;npm Stage 只能从随后创建的产品 `v` tag dispatch,npm Finalize 只能从 `main` dispatch; - 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 更新的预发布版本时,两个 tag 都指向稳定版; -- 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理,并且必须先于 npm staging 存在; +- 不创建 npm 专属 Git tag 或 GitHub Release;`Release` workflow 在 npm staging 前创建产品 + `v` tag 与 Draft,Finalize 是该 Draft 唯一的发布者; - 在 npm Finalize 与 Desktop 远程 Runtime Host 验收成功前,GitHub Release 必须保持 Draft; Draft 为 npm 提供产品身份,发布 Draft 是最终的产品发布动作; - 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs index 5b1de049c6..52b2dde783 100644 --- a/scripts/product-release-authority.mjs +++ b/scripts/product-release-authority.mjs @@ -210,40 +210,18 @@ export async function publishDraftProductRelease({ if (attestation.name !== releaseIdentity.attestationName) { throw new Error(`Product release attestation must be named ${releaseIdentity.attestationName}`); } - const evidence = await readProductReleasePublicationRecord(publicationRecordPath, { - repository, + const { draft, evidence } = await verifyDraftProductReleasePublication({ tag, sourceCommit, - sourceReferenceTag, - runId: releaseRunId, - runAttempt: releaseRunAttempt, - }); - await verifyProductReleasePublicationRecord({ + repository, artifactDirectory, - record: evidence, - expected: { - repository, - tag, - sourceCommit, - sourceReferenceTag, - runId: releaseRunId, - runAttempt: releaseRunAttempt, - }, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + cwd, + run, }); - const draft = await verifyDraftProductRelease({ tag, sourceCommit, repository, cwd, run }); - const remoteAssets = (draft.assets ?? []) - .map(({ name, size, digest }) => ({ name, size, digest })) - .sort((left, right) => compareProductReleaseNames(left.name, right.name)); - const allowedDraftAssets = remoteAssets.filter(({ name }) => name !== attestation.name); - const unexpectedAttestations = remoteAssets.filter( - ({ name }) => name.endsWith('-attestation.sigstore.json') && name !== attestation.name, - ); - if ( - unexpectedAttestations.length > 0 || - JSON.stringify(allowedDraftAssets) !== JSON.stringify(evidence.assets) - ) { - throw new Error('Draft GitHub Release assets do not match the verified Release run artifacts'); - } await run( 'gh', @@ -312,10 +290,63 @@ export async function publishDraftProductRelease({ return record; } +export async function verifyDraftProductReleasePublication({ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + cwd = process.cwd(), + run = execFileAsync, +}) { + const releaseIdentity = expectedReleaseIdentity(tag); + const evidence = await readProductReleasePublicationRecord(publicationRecordPath, { + repository, + tag, + sourceCommit, + sourceReferenceTag, + runId: releaseRunId, + runAttempt: releaseRunAttempt, + }); + await verifyProductReleasePublicationRecord({ + artifactDirectory, + record: evidence, + expected: { + repository, + tag, + sourceCommit, + sourceReferenceTag, + runId: releaseRunId, + runAttempt: releaseRunAttempt, + }, + }); + const draft = await verifyDraftProductRelease({ tag, sourceCommit, repository, cwd, run }); + const remoteAssets = (draft.assets ?? []) + .map(({ name, size, digest }) => ({ name, size, digest })) + .sort((left, right) => compareProductReleaseNames(left.name, right.name)); + const allowedDraftAssets = remoteAssets.filter( + ({ name }) => name !== releaseIdentity.attestationName, + ); + const unexpectedAttestations = remoteAssets.filter( + ({ name }) => + name.endsWith('-attestation.sigstore.json') && name !== releaseIdentity.attestationName, + ); + if ( + unexpectedAttestations.length > 0 || + JSON.stringify(allowedDraftAssets) !== JSON.stringify(evidence.assets) + ) { + throw new Error('Draft GitHub Release assets do not match the verified Release run artifacts'); + } + return { draft, evidence }; +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [command, ...args] = process.argv.slice(2); const usage = - 'usage: product-release-authority.mjs verify-build-run | verify-draft | publish-draft '; + 'usage: product-release-authority.mjs verify-build-run | verify-draft | verify-publication | publish-draft '; if (command === 'verify-build-run' && args.length === 6) { const [runPath, tag, sourceCommit, repository, runId, runAttempt] = args; const run = JSON.parse(await readFile(runPath, 'utf8')); @@ -325,6 +356,28 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const [tag, sourceCommit, repository] = args; await verifyDraftProductRelease({ tag, sourceCommit, repository }); console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); + } else if (command === 'verify-publication' && args.length === 8) { + const [ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + ] = args; + await verifyDraftProductReleasePublication({ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + }); + console.log(`Verified exact publication input for ${tag}`); } else if (command === 'publish-draft' && args.length === 9) { const [ tag, diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index d694d8ed0d..d1ae439f6e 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -515,6 +515,7 @@ async function main() { appendOutputs(output, { product_tag: record.productTag, source_commit: record.source.commit, + version: record.version, }); } return; diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 917a5be3e9..0600065dd1 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -484,6 +484,7 @@ test('validate-stage-run CLI accepts the canonical staged release identity', () assert.deepEqual(readFileSync(output, 'utf8').trim().split('\n'), [ `product_tag=${PRODUCT_TAG}`, `source_commit=${SOURCE_SHA}`, + `version=${fixture.version}`, ]); }); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index e561a4314f..0bd25c8f2a 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -120,6 +120,7 @@ test('finalize revalidates the live product release before trusting public npm b test('finalize preserves npm evidence and owns the single product publication boundary', () => { const workflow = readWorkflow('release-cli-finalize.yml'); + const steps = workflowSteps(workflow); assert.match(workflow, /name: Preserve the verified public npm package/u); assert.match(workflow, /path: \$\{\{ runner\.temp \}\}\/registry-release/u); assert.doesNotMatch(workflow, /cli-v/u); @@ -129,12 +130,22 @@ test('finalize preserves npm evidence and owns the single product publication bo assert.match(workflow, /attestations: write/u); assert.match(workflow, /product-release-authority\.mjs publish-draft/u); assert.match(workflow, /actions\/attest@[0-9a-f]{40}/u); - const artifacts = namedStep( - workflowSteps(workflow), - 'Download the exact verified Release run artifacts', + const artifacts = namedStep(steps, 'Download the exact verified Release run artifacts'); + assert.match(artifacts, /run-id: \$\{\{ needs\.inspect\.outputs\.release_run_id \}\}/u); + const preflight = namedStep(steps, 'Verify the exact publication input'); + const attest = steps.find((step) => step.includes('uses: actions/attest@')); + const verify = namedStep(steps, 'Verify the issued provenance'); + const publish = namedStep(steps, 'Publish the verified convenience release'); + assert.ok(attest); + assert.ok( + workflow.indexOf(preflight) < workflow.indexOf(attest) && + workflow.indexOf(attest) < workflow.indexOf(verify) && + workflow.indexOf(verify) < workflow.indexOf(publish), ); - assert.match(artifacts, /run-id: \$\{\{ inputs\.release_run_id \}\}/u); - assert.match(artifacts, /release-\*-\$\{\{ inputs\.release_run_attempt \}\}/u); + assert.match(preflight, /product-release-authority\.mjs verify-publication/u); + assert.match(verify, /gh attestation verify/u); + assert.match(verify, /@refs\/heads\/main/u); + assert.doesNotMatch(workflow.slice(workflow.indexOf('\n publish:')), /\$\{\{ inputs\./u); }); test('release workflows select npm from the root packageManager authority', () => {