From a6a7ce986e752f83b30815579c73aa38e7d5a0d3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 02:21:37 +0800 Subject: [PATCH 1/4] feat: add Desktop Nightly update channel Build signed developer snapshots from the exact main commit, publish immutable payloads and verified provenance to Apache Nightlies, and keep packaged clients pinned to their declared update authority. Document the one-time Infra and GitHub Environment setup while leaving the workflow disabled until those external credentials exist. Generated-by: Codex --- .github/DESKTOP_NIGHTLY.md | 37 +++ .github/workflows/desktop-nightly.yml | 282 ++++++++++++++++++ apps/desktop/electron-builder.config.mjs | 28 +- .../__tests__/app-update-attestation.test.ts | 61 +++- .../main/__tests__/app-update-service.test.ts | 18 +- .../src/main/app-update-attestation.ts | 45 ++- apps/desktop/src/main/app-update-service.ts | 3 + apps/desktop/src/main/runtime-host-boot.ts | 13 +- scripts/desktop-nightly-stage.test.mjs | 88 ++++++ .../desktop-nightly-workflow-policy.test.mjs | 46 +++ scripts/desktop-nightly.mjs | 273 +++++++++++++++++ scripts/desktop-nightly.test.mjs | 64 ++++ scripts/desktop-update-contract.mjs | 14 +- scripts/package-macos-arm64.mjs | 6 +- scripts/package-windows-x64.mjs | 7 +- scripts/product-release.test.mjs | 3 +- scripts/verify-macos-arm64-dmg.mjs | 11 +- scripts/verify-windows-x64.mjs | 11 +- 18 files changed, 977 insertions(+), 33 deletions(-) create mode 100644 .github/DESKTOP_NIGHTLY.md create mode 100644 .github/workflows/desktop-nightly.yml create mode 100644 scripts/desktop-nightly-stage.test.mjs create mode 100644 scripts/desktop-nightly-workflow-policy.test.mjs create mode 100644 scripts/desktop-nightly.mjs create mode 100644 scripts/desktop-nightly.test.mjs diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md new file mode 100644 index 0000000000..4d206918eb --- /dev/null +++ b/.github/DESKTOP_NIGHTLY.md @@ -0,0 +1,37 @@ + + +# Desktop Nightly + +Desktop Nightly is an ephemeral developer snapshot, not an Apache release. It builds the current `main` commit every day so contributors can try recent Desktop changes and report problems without waiting for an ASF source-release vote. + +The workflow gives each snapshot an immutable version such as `0.2.0-dev.20260829.42` and records its exact source commit in `nightly.json`. A packaged Nightly accepts updates only from `https://nightlies.apache.org/maka/desktop/`, accepts only newer `dev` versions, and verifies that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build continues to use the GitHub Release feed and the formal product-release attestation identity. + +Nightly currently uses the same application identity as the formal Desktop. Installing it replaces the existing Maka installation rather than creating a second side-by-side app. Its user data remains in the same location. Testers who need the formal build should reinstall that build before returning to the formal channel. + +## One-time setup + +1. Ask Apache Infra to allow `apache/maka` to publish GitHub Actions output to `nightlies.apache.org`. The resulting repository secrets are `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_PORT`, `NIGHTLIES_RSYNC_USER`, and `NIGHTLIES_RSYNC_KEY`. +2. Create a GitHub Environment named `nightly` that permits only `main`. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to pull-request workflows. +3. Merge the Nightly workflow while it is disabled. After Infra publishing and the Environment secrets are ready, set the repository variable `DESKTOP_NIGHTLY_ENABLED` to `true` and run `Desktop Nightly` manually once. +4. Verify the download page, `nightly.json`, `latest-mac.yml`, and `latest.yml` under `https://nightlies.apache.org/maka/desktop/`, install both platform artifacts on clean machines, and confirm one automatic update before sharing the channel with testers. + +The scheduled run starts at 18:17 UTC. It audits the shipped dependency closure, builds and verifies macOS arm64 and Windows x64 artifacts, issues and locally verifies Sigstore provenance, replaces the previous versioned payloads, and advances the mutable update metadata last. A failed build leaves the previous Nightly feed untouched. Apache Nightlies storage is temporary; it must not be used as a formal release archive. + +Remote Runtime Host setup still follows the package identity embedded in the repository manifests. A Nightly does not publish a matching npm package, so clean remote setup is outside this channel until that dependency has its own reviewed snapshot distribution contract. diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml new file mode 100644 index 0000000000..204a6dbec1 --- /dev/null +++ b/.github/workflows/desktop-nightly.yml @@ -0,0 +1,282 @@ +# 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. + +name: Desktop Nightly + +on: + schedule: + - cron: '17 18 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: desktop-nightly + cancel-in-progress: false + +jobs: + identity: + if: vars.DESKTOP_NIGHTLY_ENABLED == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + source_commit: ${{ steps.identity.outputs.source_commit }} + version: ${{ steps.identity.outputs.version }} + steps: + - name: Check out the scheduled main commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require the Apache main branch + run: | + test "$GITHUB_REPOSITORY" = apache/maka + test "$GITHUB_REF" = refs/heads/main + + - name: Resolve the immutable Nightly identity + id: identity + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_RUN_NUMBER: ${{ github.run_number }} + run: node scripts/desktop-nightly.mjs identity + + desktop: + needs: identity + strategy: + fail-fast: false + matrix: + include: + - platform: macos + runner: macos-15 + - platform: windows + runner: windows-2025 + runs-on: ${{ matrix.runner }} + environment: nightly + timeout-minutes: 75 + defaults: + run: + shell: bash + env: + MAKA_DESKTOP_NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + steps: + - name: Check out the exact Nightly source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.source_commit }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Audit the shipped Desktop dependency closure + run: | + npm audit --omit=dev --audit-level=moderate + node scripts/audit-shipped-dependencies.mjs + + - name: Update stable Rust for native Desktop artifacts + run: rustup update stable --no-self-update + + - name: Write the App Store Connect API key + if: matrix.platform == 'macos' + env: + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_PATH: ${{ runner.temp }}/AuthKey_Maka.p8 + run: | + umask 077 + printf '%s' "$APPLE_API_KEY_CONTENT" > "$APPLE_API_KEY_PATH" + + - name: Package the signed and notarized macOS Nightly + if: matrix.platform == 'macos' + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: npm run package:macos-arm64 + + - name: Notarize and verify the macOS Nightly + if: matrix.platform == 'macos' + env: + APPLE_API_KEY: ${{ runner.temp }}/AuthKey_Maka.p8 + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + DMG_PATH: apps/desktop/release/Maka-${{ needs.identity.outputs.version }}-mac-arm64.dmg + run: | + xcrun notarytool submit "$DMG_PATH" \ + --key "$APPLE_API_KEY" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER" \ + --wait + xcrun stapler staple "$DMG_PATH" + npm run verify:macos-arm64 -- "$DMG_PATH" + + - name: Package the Windows Nightly + if: matrix.platform == 'windows' + run: npm run package:windows-x64 + + - name: Verify the Windows Nightly + if: matrix.platform == 'windows' + run: >- + npm run verify:windows-x64 -- + "apps/desktop/release/Maka-${{ needs.identity.outputs.version }}-win-x64.exe" + + - name: Stage the exact Nightly artifacts + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + STAGE_DIRECTORY: ${{ runner.temp }}/desktop-nightly + run: | + mkdir -p "$STAGE_DIRECTORY" + if [[ "${{ matrix.platform }}" = macos ]]; then + cp -- \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.dmg" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.zip" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-mac-arm64.zip.blockmap" \ + apps/desktop/release/latest-mac.yml \ + "$STAGE_DIRECTORY/" + else + cp -- \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.exe" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.exe.blockmap" \ + "apps/desktop/release/Maka-$NIGHTLY_VERSION-win-x64.zip" \ + apps/desktop/release/latest.yml \ + "$STAGE_DIRECTORY/" + fi + + - name: Preserve the verified Nightly artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-nightly-${{ matrix.platform }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/desktop-nightly + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + publish: + needs: [identity, desktop] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + artifact-metadata: write + attestations: write + contents: read + id-token: write + steps: + - name: Check out the Nightly publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.source_commit }} + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install publisher dependencies + run: npm ci --ignore-scripts + + - name: Download both verified Desktop builds + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-nightly-*-${{ github.run_attempt }} + path: ${{ runner.temp }}/desktop-nightly-input + merge-multiple: true + + - name: Stage the versioned Nightly site + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }} + run: | + node scripts/desktop-nightly.mjs stage \ + "$RUNNER_TEMP/desktop-nightly-input" \ + "$RUNNER_TEMP/desktop-nightly-site" \ + "$NIGHTLY_VERSION" \ + "$SOURCE_COMMIT" + mkdir "$RUNNER_TEMP/desktop-nightly-feed" + cp -- \ + "$RUNNER_TEMP/desktop-nightly-site/latest-mac.yml" \ + "$RUNNER_TEMP/desktop-nightly-site/latest.yml" \ + "$RUNNER_TEMP/desktop-nightly-site/nightly.json" \ + "$RUNNER_TEMP/desktop-nightly-site/index.html" \ + "$RUNNER_TEMP/desktop-nightly-feed/" + + - name: Attest the exact Nightly payloads + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 + with: + subject-path: ${{ runner.temp }}/desktop-nightly-site/versions/${{ needs.identity.outputs.version }}/* + + - name: Verify the issued Nightly provenance + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main + GH_TOKEN: ${{ github.token }} + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + 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/desktop-nightly-site/versions/$NIGHTLY_VERSION" -maxdepth 1 -type f -print0) + if (( verified == 0 )); then + echo "No Desktop Nightly artifacts were verified" >&2 + exit 1 + fi + + - name: Add the offline provenance bundle + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + run: >- + cp -- "$ATTESTATION_BUNDLE" + "$RUNNER_TEMP/desktop-nightly-site/versions/$NIGHTLY_VERSION/Maka-$NIGHTLY_VERSION-attestation.sigstore.json" + + - name: Publish immutable Nightly payloads + uses: burnett01/rsync-deployments@53581dff6de0d8511d74864451aacee4846bdf39 # v8.0.2 + with: + switches: -rlptDvz --delete + path: ${{ runner.temp }}/desktop-nightly-site/versions/ + remote_path: ${{ secrets.NIGHTLIES_RSYNC_PATH }}/maka/desktop/versions + remote_host: ${{ secrets.NIGHTLIES_RSYNC_HOST }} + remote_port: ${{ secrets.NIGHTLIES_RSYNC_PORT }} + remote_user: ${{ secrets.NIGHTLIES_RSYNC_USER }} + remote_key: ${{ secrets.NIGHTLIES_RSYNC_KEY }} + + - name: Advance the Nightly update feed last + uses: burnett01/rsync-deployments@53581dff6de0d8511d74864451aacee4846bdf39 # v8.0.2 + with: + switches: -rlptDvz + path: ${{ runner.temp }}/desktop-nightly-feed/* + remote_path: ${{ secrets.NIGHTLIES_RSYNC_PATH }}/maka/desktop + remote_host: ${{ secrets.NIGHTLIES_RSYNC_HOST }} + remote_port: ${{ secrets.NIGHTLIES_RSYNC_PORT }} + remote_user: ${{ secrets.NIGHTLIES_RSYNC_USER }} + remote_key: ${{ secrets.NIGHTLIES_RSYNC_KEY }} diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 3235bedac6..7893be54d9 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -18,24 +18,29 @@ */ import { readFileSync } from 'node:fs'; +import { + DESKTOP_NIGHTLY_FEED_URL, + resolveDesktopBuildVersion, +} from '../../scripts/desktop-nightly.mjs'; import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; function readManifest(relativePath) { return JSON.parse(readFileSync(new URL(relativePath, import.meta.url), 'utf8')); } +const rootManifest = readManifest('../../package.json'); const { runtimeHostSetupPackage } = resolveProductManifestIdentity({ - rootManifest: readManifest('../../package.json'), + rootManifest, desktopManifest: readManifest('./package.json'), cliManifest: readManifest('../../packages/cli/package.json'), }); -export default { +const baseDesktopBuilderConfig = { appId: 'com.maka.desktop', productName: 'Maka', artifactName: 'Maka-${version}-mac-${arch}.${ext}', asar: true, - extraMetadata: { runtimeHostSetupPackage }, + extraMetadata: { runtimeHostSetupPackage, makaUpdateChannel: 'release' }, directories: { output: 'release', }, @@ -249,3 +254,20 @@ export default { }, ], }; + +export function resolveDesktopBuilderConfig(environment = process.env) { + const nightlyVersion = environment.MAKA_DESKTOP_NIGHTLY_VERSION?.trim(); + if (!nightlyVersion) return baseDesktopBuilderConfig; + const version = resolveDesktopBuildVersion(rootManifest.version, environment); + return { + ...baseDesktopBuilderConfig, + extraMetadata: { + ...baseDesktopBuilderConfig.extraMetadata, + version, + makaUpdateChannel: 'nightly', + }, + publish: [{ provider: 'generic', url: DESKTOP_NIGHTLY_FEED_URL }], + }; +} + +export default resolveDesktopBuilderConfig(); 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 fae7d8d79a..0cf9e7c523 100644 --- a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts @@ -24,7 +24,10 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; -import { verifyDownloadedUpdateAttestation } from '../app-update-attestation.js'; +import { + desktopUpdateChannelFromManifest, + verifyDownloadedUpdateAttestation, +} from '../app-update-attestation.js'; function provenanceBundle(name: string, sha256: string): Bundle { const statement = { @@ -104,3 +107,59 @@ test('download verification accepts only a trusted exact artifact subject', asyn /untrusted workflow identity/u, ); }); + +test('nightly verification fetches provenance from the versioned Nightlies path', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-nightly-attestation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const artifact = join(directory, 'cached-update.zip'); + const bytes = Buffer.from('nightly update bytes'); + await writeFile(artifact, bytes); + const version = '0.2.0-dev.20260829.42'; + const name = `Maka-${version}-mac-arm64.zip`; + const digest = createHash('sha256').update(bytes).digest('hex'); + const bundle = provenanceBundle(name, digest); + let fetchedUrl = ''; + + await verifyDownloadedUpdateAttestation({ + channel: 'nightly', + downloadedFile: artifact, + version, + platform: 'darwin', + arch: 'arm64', + trustRootCacheDirectory: join(directory, 'trust'), + fetchBundle: async (url) => { + fetchedUrl = url; + 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 () => {}, + }); + + assert.equal( + fetchedUrl, + `https://nightlies.apache.org/maka/desktop/versions/${version}/Maka-${version}-attestation.sigstore.json`, + ); +}); + +test('packaged update trust accepts only an explicit release or nightly channel', () => { + assert.equal(desktopUpdateChannelFromManifest({ makaUpdateChannel: 'release' }), 'release'); + assert.equal(desktopUpdateChannelFromManifest({ makaUpdateChannel: 'nightly' }), 'nightly'); + assert.throws( + () => desktopUpdateChannelFromManifest({ makaUpdateChannel: 'preview' }), + /does not declare a trusted update channel/u, + ); +}); 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 3206ca6608..bae3519c73 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -130,6 +130,7 @@ function createHarness(input: { mockLatestVersion?: string; mockState?: 'available' | 'downloading' | 'downloaded'; testFeedUrl?: string; + updateChannel?: 'release' | 'nightly'; verifyDownloadedUpdate?: DownloadedUpdateAttestationVerifier; } = {}) { const updater = input.updater ?? new FakeUpdater(); @@ -137,6 +138,7 @@ function createHarness(input: { const service = createAppUpdateService({ currentVersion: '1.0.0', isPackaged: input.isPackaged ?? true, + updateChannel: input.updateChannel ?? 'release', updater: updater as unknown as AppUpdater, clock, onStatusChange: input.onStatusChange, @@ -176,13 +178,15 @@ describe('AppUpdateService', () => { assert.equal(clock.pending().length, 0); }); - test('preserves electron-updater channel policy derived from the app version', () => { - for (const allowPrerelease of [false, true]) { - const updater = new FakeUpdater(); - updater.allowPrerelease = allowPrerelease; - createHarness({ updater }); - assert.equal(updater.allowPrerelease, allowPrerelease); - } + test('accepts dev updates only in packaged Nightly builds', () => { + const releaseUpdater = new FakeUpdater(); + const nightlyUpdater = new FakeUpdater(); + + createHarness({ updater: releaseUpdater, updateChannel: 'release' }); + createHarness({ updater: nightlyUpdater, updateChannel: 'nightly' }); + + assert.equal(releaseUpdater.allowPrerelease, false); + assert.equal(nightlyUpdater.allowPrerelease, true); }); test('routes the feed to a loopback generic provider when the test override is set', () => { diff --git a/apps/desktop/src/main/app-update-attestation.ts b/apps/desktop/src/main/app-update-attestation.ts index ea0e550dbc..d92c3f05de 100644 --- a/apps/desktop/src/main/app-update-attestation.ts +++ b/apps/desktop/src/main/app-update-attestation.ts @@ -25,11 +25,8 @@ 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 PRODUCT_NIGHTLY_WORKFLOW = '.github/workflows/desktop-nightly.yml'; +const PRODUCT_NIGHTLY_BASE_URL = 'https://nightlies.apache.org/maka/desktop'; 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'; @@ -56,6 +53,7 @@ export type DownloadedUpdateAttestationVerifier = ( ) => Promise; type VerifyDownloadedUpdateAttestationOptions = DownloadedUpdateAttestationInput & { + readonly channel?: DesktopUpdateChannel; readonly trustRootCacheDirectory: string; readonly platform?: NodeJS.Platform; readonly arch?: string; @@ -63,6 +61,28 @@ type VerifyDownloadedUpdateAttestationOptions = DownloadedUpdateAttestationInput readonly verifyBundle?: (bundle: Bundle) => Promise; }; +export type DesktopUpdateChannel = 'release' | 'nightly'; + +export function desktopUpdateChannelFromManifest(manifest: unknown): DesktopUpdateChannel { + const channel = + manifest && typeof manifest === 'object' + ? (manifest as { makaUpdateChannel?: unknown }).makaUpdateChannel + : undefined; + if (channel !== 'release' && channel !== 'nightly') { + throw new Error('Packaged Desktop does not declare a trusted update channel'); + } + return channel; +} + +function productWorkflowSigner(channel: DesktopUpdateChannel): RegExp { + const workflow = channel === 'nightly' ? PRODUCT_NIGHTLY_WORKFLOW : PRODUCT_RELEASE_WORKFLOW; + return new RegExp( + `^https://github\\.com/${PRODUCT_REPOSITORY.replace('/', '\\/')}/${workflow.replaceAll('.', '\\.')}` + + '@refs/heads/main$', + 'u', + ); +} + function exactDesktopUpdateArtifactName( version: string, platform: NodeJS.Platform, @@ -80,9 +100,15 @@ function productReleaseAttestationName(version: string): string { return `Maka-${version}-attestation.sigstore.json`; } -function productReleaseAttestationUrl(version: string): string { - const tag = `v${version}`; +function productReleaseAttestationUrl( + version: string, + channel: DesktopUpdateChannel, +): string { const name = productReleaseAttestationName(version); + if (channel === 'nightly') { + return `${PRODUCT_NIGHTLY_BASE_URL}/versions/${encodeURIComponent(version)}/${encodeURIComponent(name)}`; + } + const tag = `v${version}`; return `https://github.com/${PRODUCT_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; } @@ -193,9 +219,10 @@ export async function verifyDownloadedUpdateAttestation( options.platform ?? process.platform, options.arch ?? process.arch, ); + const channel = options.channel ?? 'release'; const [artifactSha256, bundleBytes] = await Promise.all([ sha256File(options.downloadedFile), - (options.fetchBundle ?? fetchBytesCapped)(productReleaseAttestationUrl(version)), + (options.fetchBundle ?? fetchBytesCapped)(productReleaseAttestationUrl(version, channel)), ]); const bundle = parseBundle(bundleBytes); @@ -208,7 +235,7 @@ export async function verifyDownloadedUpdateAttestation( }); const verifier = new Verifier(toTrustMaterial(trustedRoot)); verifier.verify(toSignedEntity(bundle), { - subjectAlternativeName: PRODUCT_RELEASE_SIGNER, + subjectAlternativeName: productWorkflowSigner(channel), extensions: { issuer: GITHUB_ACTIONS_OIDC_ISSUER }, }); } diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index 2cf9c93743..2ae2e4fabc 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -21,6 +21,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 type { DesktopUpdateChannel } from './app-update-attestation.js'; import { resolveUpdateFeedOverride } from './app-update-test-context.js'; export type AppUpdateProgress = { @@ -88,6 +89,7 @@ export interface AppUpdateService { interface AppUpdateServiceDeps { currentVersion: string; isPackaged: boolean; + updateChannel?: DesktopUpdateChannel; updater?: AppUpdater; /** * Harness-only feed override (`MAKA_UPDATE_TEST_FEED`); see @@ -290,6 +292,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer updater.autoDownload = true; updater.autoInstallOnAppQuit = false; + updater.allowPrerelease = deps.updateChannel === 'nightly'; updater.logger = null; const testFeed = resolveUpdateFeedOverride(deps.testFeedUrl); // Production reads electron-builder's packaged app-update.yml. Only the diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 3673f39b43..a90b5baf11 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -29,6 +29,7 @@ import { type MessageBoxReturnValue, } from "electron"; import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; import { basename, join } from "node:path"; import { type ConnectionEvent } from '@maka/core/connections'; import { type SessionChangedEvent, type SessionChangedReason } from '@maka/core/session'; @@ -66,7 +67,10 @@ 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 { + desktopUpdateChannelFromManifest, + 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"; @@ -710,9 +714,15 @@ const updateMockState = ? process.env.MAKA_UPDATE_MOCK_STATE : undefined; const updateTestFeed = process.env.MAKA_UPDATE_TEST_FEED; +const desktopUpdateChannel = app.isPackaged + ? desktopUpdateChannelFromManifest( + JSON.parse(readFileSync(join(app.getAppPath(), "package.json"), "utf8")), + ) + : "release"; const updateService = createAppUpdateService({ currentVersion: app.getVersion(), isPackaged: app.isPackaged, + updateChannel: desktopUpdateChannel, testFeedUrl: updateTestFeed, mockLatestVersion: process.env.MAKA_UPDATE_MOCK_VERSION, mockState: updateMockState, @@ -726,6 +736,7 @@ const updateService = createAppUpdateService({ ? async () => {} : ({ downloadedFile, version }) => verifyDownloadedUpdateAttestation({ + channel: desktopUpdateChannel, downloadedFile, version, trustRootCacheDirectory: join(userDataDir, "update-trust", "sigstore"), diff --git a/scripts/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs new file mode 100644 index 0000000000..4cba821b2c --- /dev/null +++ b/scripts/desktop-nightly-stage.test.mjs @@ -0,0 +1,88 @@ +/* + * 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, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { stringify } from 'yaml'; +import { stageDesktopNightly } from './desktop-nightly.mjs'; + +async function writeUpdateSet(directory, version, platform) { + const isMac = platform === 'mac'; + const artifact = isMac ? `Maka-${version}-mac-arm64.zip` : `Maka-${version}-win-x64.exe`; + const metadata = isMac ? 'latest-mac.yml' : 'latest.yml'; + const bytes = Buffer.from(`${platform} nightly bytes`); + const sha512 = createHash('sha512').update(bytes).digest('base64'); + await writeFile(join(directory, artifact), bytes); + await writeFile(join(directory, `${artifact}.blockmap`), `${platform} blockmap`); + await writeFile( + join(directory, metadata), + stringify({ + version, + files: [{ url: artifact, sha512, size: bytes.byteLength }], + path: artifact, + sha512, + releaseDate: '2026-08-29T18:17:00.000Z', + }), + ); +} + +test('staging publishes immutable payloads before versioned Nightly metadata', async (t) => { + const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-')); + t.after(() => rm(root, { recursive: true, force: true })); + const input = join(root, 'input'); + const output = join(root, 'output'); + const version = '0.2.0-dev.20260829.42'; + await mkdir(input); + await Promise.all([ + writeUpdateSet(input, version, 'mac'), + writeUpdateSet(input, version, 'win'), + writeFile(join(input, `Maka-${version}-mac-arm64.dmg`), 'dmg'), + writeFile(join(input, `Maka-${version}-win-x64.zip`), 'windows zip'), + ]); + + await stageDesktopNightly({ + inputDirectory: input, + outputDirectory: output, + version, + sourceCommit: 'a'.repeat(40), + }); + + const macMetadata = (await import('yaml')).parse( + await readFile(join(output, 'latest-mac.yml'), 'utf8'), + ); + const windowsMetadata = (await import('yaml')).parse( + await readFile(join(output, 'latest.yml'), 'utf8'), + ); + assert.equal(macMetadata.files[0].url, `versions/${version}/Maka-${version}-mac-arm64.zip`); + assert.equal(windowsMetadata.path, `versions/${version}/Maka-${version}-win-x64.exe`); + assert.equal( + JSON.parse(await readFile(join(output, 'nightly.json'), 'utf8')).sourceCommit, + 'a'.repeat(40), + ); + const index = await readFile(join(output, 'index.html'), 'utf8'); + assert.match(index, /Desktop Nightly is a developer snapshot, not an Apache release/u); + assert.match(index, new RegExp(`versions/${version}/Maka-${version}-mac-arm64\.dmg`, 'u')); + assert.match(index, new RegExp(`versions/${version}/Maka-${version}-win-x64\.exe`, 'u')); + await readFile(join(output, 'versions', version, `Maka-${version}-mac-arm64.zip`)); + await readFile(join(output, 'versions', version, `Maka-${version}-win-x64.exe`)); +}); diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs new file mode 100644 index 0000000000..b122ea0e45 --- /dev/null +++ b/scripts/desktop-nightly-workflow-policy.test.mjs @@ -0,0 +1,46 @@ +/* + * 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 { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +const workflowPath = new URL('../.github/workflows/desktop-nightly.yml', import.meta.url); + +test('Nightly stays disabled until its external publishing authority is configured', async () => { + const workflow = await readFile(workflowPath, 'utf8'); + assert.match(workflow, /if: vars\.DESKTOP_NIGHTLY_ENABLED == 'true'/u); + assert.match(workflow, /test "\$GITHUB_REPOSITORY" = apache\/maka/u); + assert.match(workflow, /test "\$GITHUB_REF" = refs\/heads\/main/u); + assert.doesNotMatch(workflow, /source_reference_tag|incubating|contents: write/u); +}); + +test('Nightly verifies provenance and advances mutable feeds only after payload upload', async () => { + const workflow = await readFile(workflowPath, 'utf8'); + const attest = workflow.indexOf('name: Attest the exact Nightly payloads'); + const verify = workflow.indexOf('name: Verify the issued Nightly provenance'); + const payloads = workflow.indexOf('name: Publish immutable Nightly payloads'); + const feed = workflow.indexOf('name: Advance the Nightly update feed last'); + + assert.ok(attest >= 0 && verify > attest && payloads > verify && feed > payloads); + assert.match(workflow, /\.github\/workflows\/desktop-nightly\.yml@refs\/heads\/main/u); + assert.match(workflow, /gh attestation verify/u); + assert.match(workflow, /NIGHTLIES_RSYNC_PATH/u); + assert.match(workflow, /switches: -rlptDvz --delete/u); +}); diff --git a/scripts/desktop-nightly.mjs b/scripts/desktop-nightly.mjs new file mode 100644 index 0000000000..c42cabc876 --- /dev/null +++ b/scripts/desktop-nightly.mjs @@ -0,0 +1,273 @@ +/* + * 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 { + appendFile, + copyFile, + mkdir, + readFile, + readdir, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse, stringify } from 'yaml'; +import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; +import { parseProductReleaseVersion } from './release-version.mjs'; + +export const DESKTOP_NIGHTLY_FEED_URL = 'https://nightlies.apache.org/maka/desktop/'; +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export function assertDesktopNightlyVersion(version, productVersion) { + const product = parseProductReleaseVersion(productVersion); + const nightly = parseProductReleaseVersion(version); + if (product.prerelease.length > 0) { + throw new Error('Desktop Nightly requires a stable checked-in product version'); + } + if ( + nightly.core.some((identifier, index) => identifier !== product.core[index]) || + nightly.prerelease.length !== 3 || + nightly.prerelease[0] !== 'dev' || + !/^\d{8}$/u.test(nightly.prerelease[1]) || + !/^[1-9]\d*$/u.test(nightly.prerelease[2]) + ) { + throw new Error(`Desktop Nightly version ${version} must be a dev build of ${productVersion}`); + } + return version; +} + +export function resolveDesktopBuildVersion(productVersion, environment = process.env) { + const nightlyVersion = environment.MAKA_DESKTOP_NIGHTLY_VERSION?.trim(); + return nightlyVersion + ? assertDesktopNightlyVersion(nightlyVersion, productVersion) + : productVersion; +} + +function assertStandaloneNightlyVersion(version) { + const parsed = parseProductReleaseVersion(version); + if ( + parsed.prerelease.length !== 3 || + parsed.prerelease[0] !== 'dev' || + !/^\d{8}$/u.test(parsed.prerelease[1]) || + !/^[1-9]\d*$/u.test(parsed.prerelease[2]) + ) { + throw new Error(`Desktop Nightly version is invalid: ${version}`); + } +} + +function nightlyArtifactNames(version) { + return { + macZip: `Maka-${version}-mac-arm64.zip`, + macDmg: `Maka-${version}-mac-arm64.dmg`, + windowsExe: `Maka-${version}-win-x64.exe`, + windowsZip: `Maka-${version}-win-x64.zip`, + }; +} + +async function sha256(path) { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex'); +} + +async function rewriteNightlyMetadata(source, destination, version) { + const metadata = parse(await readFile(source, 'utf8')); + const prefix = `versions/${version}/`; + metadata.path = `${prefix}${metadata.path}`; + metadata.files = metadata.files.map((file) => ({ + ...file, + url: `${prefix}${file.url}`, + })); + await writeFile(destination, stringify(metadata), 'utf8'); +} + +function nightlyIndex(version, sourceCommit, names) { + return ` + +Maka Desktop Nightly + +
+

Maka Desktop Nightly

+

Desktop Nightly is a developer snapshot, not an Apache release. It may be unstable and its files are temporary.

+

Version ${version}, built from source commit ${sourceCommit}.

+ +

Installed Nightly builds update automatically from this channel. See nightly.json for the machine-readable identity.

+
+ + +`; +} + +export async function stageDesktopNightly({ + inputDirectory, + outputDirectory, + version, + sourceCommit, +}) { + assertStandaloneNightlyVersion(version); + if (typeof sourceCommit !== 'string' || !/^[0-9a-f]{40}$/u.test(sourceCommit)) { + throw new Error('Desktop Nightly requires an exact source commit'); + } + const names = nightlyArtifactNames(version); + const payloads = [ + names.macDmg, + names.macZip, + `${names.macZip}.blockmap`, + names.windowsExe, + `${names.windowsExe}.blockmap`, + names.windowsZip, + ]; + const metadataNames = ['latest-mac.yml', 'latest.yml']; + const expected = [...payloads, ...metadataNames].sort(); + const actual = (await readdir(inputDirectory)).sort(); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Desktop Nightly input is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, + ); + } + + await Promise.all([ + verifyDesktopUpdateArtifacts({ + directory: inputDirectory, + metadataName: 'latest-mac.yml', + version, + artifactName: names.macZip, + }), + verifyDesktopUpdateArtifacts({ + directory: inputDirectory, + metadataName: 'latest.yml', + version, + artifactName: names.windowsExe, + }), + ]); + + await rm(outputDirectory, { recursive: true, force: true }); + const versionDirectory = join(outputDirectory, 'versions', version); + await mkdir(versionDirectory, { recursive: true }); + await Promise.all( + payloads.map(async (name) => { + const source = join(inputDirectory, name); + const info = await stat(source); + if (!info.isFile()) throw new Error(`Desktop Nightly payload is not a file: ${source}`); + await copyFile(source, join(versionDirectory, name)); + }), + ); + for (const name of [names.macDmg, names.macZip, names.windowsExe, names.windowsZip]) { + await writeFile( + join(versionDirectory, `${name}.sha256`), + `${await sha256(join(versionDirectory, name))} ${name}\n`, + 'utf8', + ); + } + await Promise.all([ + rewriteNightlyMetadata( + join(inputDirectory, 'latest-mac.yml'), + join(outputDirectory, 'latest-mac.yml'), + version, + ), + rewriteNightlyMetadata( + join(inputDirectory, 'latest.yml'), + join(outputDirectory, 'latest.yml'), + version, + ), + ]); + await writeFile( + join(outputDirectory, 'nightly.json'), + `${JSON.stringify({ version, sourceCommit, artifacts: payloads }, null, 2)}\n`, + 'utf8', + ); + await writeFile( + join(outputDirectory, 'index.html'), + nightlyIndex(version, sourceCommit, names), + 'utf8', + ); + return { versionDirectory, payloads }; +} + +async function main(args, environment = process.env) { + const [command, ...rest] = args; + if (command === 'identity' && rest.length === 0) { + const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + const identity = desktopNightlyIdentity({ + productVersion: productManifest.version, + date: new Date(environment.NIGHTLY_BUILD_DATE ?? Date.now()), + runNumber: environment.GITHUB_RUN_NUMBER, + sourceCommit: environment.GITHUB_SHA, + }); + if (environment.GITHUB_OUTPUT) { + await appendFile( + environment.GITHUB_OUTPUT, + `version=${identity.version}\nsource_commit=${identity.sourceCommit}\n`, + 'utf8', + ); + } + console.log(JSON.stringify(identity)); + return; + } + if (command === 'stage' && rest.length === 4) { + const [inputDirectory, outputDirectory, version, sourceCommit] = rest; + console.log( + JSON.stringify( + await stageDesktopNightly({ + inputDirectory, + outputDirectory, + version, + sourceCommit, + }), + ), + ); + return; + } + throw new Error( + 'usage: desktop-nightly.mjs identity | stage ', + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(process.argv.slice(2)); +} + +export function desktopNightlyIdentity({ productVersion, date, runNumber, sourceCommit }) { + if (parseProductReleaseVersion(productVersion).prerelease.length > 0) { + throw new Error('Desktop Nightly requires a stable checked-in product version'); + } + if (!(date instanceof Date) || !Number.isFinite(date.getTime())) { + throw new Error('Desktop Nightly requires a valid build date'); + } + if (typeof runNumber !== 'string' || !/^[1-9]\d*$/u.test(runNumber)) { + throw new Error('Desktop Nightly requires a positive run number'); + } + if (typeof sourceCommit !== 'string' || !/^[0-9a-f]{40}$/u.test(sourceCommit)) { + throw new Error('Desktop Nightly requires an exact source commit'); + } + + const day = date.toISOString().slice(0, 10).replaceAll('-', ''); + const version = `${productVersion}-dev.${day}.${runNumber}`; + assertDesktopNightlyVersion(version, productVersion); + return { + version, + sourceCommit, + }; +} diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs new file mode 100644 index 0000000000..e821741fdb --- /dev/null +++ b/scripts/desktop-nightly.test.mjs @@ -0,0 +1,64 @@ +/* + * 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 { resolveDesktopBuilderConfig } from '../apps/desktop/electron-builder.config.mjs'; +import { desktopNightlyIdentity, resolveDesktopBuildVersion } from './desktop-nightly.mjs'; + +test('a nightly identity is a dev build of the checked-in product version', () => { + assert.deepEqual( + desktopNightlyIdentity({ + productVersion: '0.2.0', + date: new Date('2026-08-29T18:17:00Z'), + runNumber: '42', + sourceCommit: 'a'.repeat(40), + }), + { + version: '0.2.0-dev.20260829.42', + sourceCommit: 'a'.repeat(40), + }, + ); +}); + +test('a nightly package embeds only the Apache Nightlies update authority', () => { + const version = '0.2.0-dev.20260829.42'; + const config = resolveDesktopBuilderConfig({ + MAKA_DESKTOP_NIGHTLY_VERSION: version, + }); + + assert.equal(config.extraMetadata.version, version); + assert.equal(config.extraMetadata.makaUpdateChannel, 'nightly'); + assert.deepEqual(config.publish, [ + { + provider: 'generic', + url: 'https://nightlies.apache.org/maka/desktop/', + }, + ]); +}); + +test('packaging observes a valid nightly version without changing product manifests', () => { + assert.equal( + resolveDesktopBuildVersion('0.2.0', { + MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.20260829.42', + }), + '0.2.0-dev.20260829.42', + ); + assert.equal(resolveDesktopBuildVersion('0.2.0', {}), '0.2.0'); +}); diff --git a/scripts/desktop-update-contract.mjs b/scripts/desktop-update-contract.mjs index 35b5004732..6d2279815f 100644 --- a/scripts/desktop-update-contract.mjs +++ b/scripts/desktop-update-contract.mjs @@ -30,6 +30,11 @@ export const DESKTOP_UPDATE_PROVIDER = Object.freeze({ repo: 'maka', updaterCacheDirName: '@makadesktop-updater', }); +export const DESKTOP_NIGHTLY_UPDATE_PROVIDER = Object.freeze({ + provider: 'generic', + url: 'https://nightlies.apache.org/maka/desktop/', + updaterCacheDirName: '@makadesktop-updater', +}); /** A stable successor lets stable, alpha, and beta candidates use one feed contract. */ export function bumpedAutoupdateVersion(candidateVersion) { @@ -58,7 +63,10 @@ function requireExactObject(actual, expected, subject) { } /** Proves that a packaged client points at the one production release authority. */ -export async function assertPackagedUpdateConfiguration(resourcesPath, { read = readFile } = {}) { +export async function assertPackagedUpdateConfiguration( + resourcesPath, + { channel = 'release', read = readFile } = {}, +) { const path = join(resourcesPath, 'app-update.yml'); let configuration; try { @@ -66,7 +74,9 @@ export async function assertPackagedUpdateConfiguration(resourcesPath, { read = } catch (error) { throw new Error(`Packaged update configuration is unreadable: ${path}`, { cause: error }); } - requireExactObject(configuration, DESKTOP_UPDATE_PROVIDER, 'Packaged update configuration'); + const expected = + channel === 'nightly' ? DESKTOP_NIGHTLY_UPDATE_PROVIDER : DESKTOP_UPDATE_PROVIDER; + requireExactObject(configuration, expected, 'Packaged update configuration'); return configuration; } diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs index 078ba0c0bf..47df4b0b5b 100644 --- a/scripts/package-macos-arm64.mjs +++ b/scripts/package-macos-arm64.mjs @@ -21,6 +21,7 @@ import { spawn } from 'node:child_process'; import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolveDesktopBuildVersion } from './desktop-nightly.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); @@ -81,8 +82,9 @@ export async function packageMacosArm64({ } const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); - const dmgPath = join(releaseDirectory, `Maka-${manifest.version}-mac-arm64.dmg`); - const zipPath = join(releaseDirectory, `Maka-${manifest.version}-mac-arm64.zip`); + const buildVersion = resolveDesktopBuildVersion(manifest.version, env); + const dmgPath = join(releaseDirectory, `Maka-${buildVersion}-mac-arm64.dmg`); + const zipPath = join(releaseDirectory, `Maka-${buildVersion}-mac-arm64.zip`); const updateMetadataPath = join(releaseDirectory, 'latest-mac.yml'); for (const path of requiredElectronLicensePaths) { diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs index 515d95b870..6ad99bf0c3 100644 --- a/scripts/package-windows-x64.mjs +++ b/scripts/package-windows-x64.mjs @@ -21,6 +21,7 @@ import { spawn } from 'node:child_process'; import { access, copyFile, mkdir, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolveDesktopBuildVersion } from './desktop-nightly.mjs'; import { npmSpawnOptions } from './npm-spawn.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -84,6 +85,7 @@ export async function packageWindowsX64({ platform = process.platform, arch = process.arch, run = runCommand, + env = process.env, remove = rm, assertFile = access, } = {}) { @@ -92,8 +94,9 @@ export async function packageWindowsX64({ } const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); - const exePath = join(releaseDirectory, `Maka-${manifest.version}-win-x64.exe`); - const zipPath = join(releaseDirectory, `Maka-${manifest.version}-win-x64.zip`); + const buildVersion = resolveDesktopBuildVersion(manifest.version, env); + const exePath = join(releaseDirectory, `Maka-${buildVersion}-win-x64.exe`); + const zipPath = join(releaseDirectory, `Maka-${buildVersion}-win-x64.zip`); const updateMetadataPath = join(releaseDirectory, 'latest.yml'); const unpackedDirectory = join(releaseDirectory, 'win-unpacked'); diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 5252bbca7a..3854aa8081 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -192,6 +192,7 @@ test('Desktop packaging derives the Runtime Host setup package from product mani const checkedRootManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); assert.deepEqual(desktopBuilderConfig.extraMetadata, { runtimeHostSetupPackage: `maka-agent@${checkedRootManifest.version}`, + makaUpdateChannel: 'release', }); assert.deepEqual(desktopBuilderConfig.publish, [ { provider: 'github', owner: 'apache', repo: 'maka' }, @@ -257,7 +258,7 @@ test('platform package verifiers keep Git checks out of current artifacts', asyn ); assert.match( windowsSource, - /if \(requiresCurrentContract\) \{\s*await assertPackagedUpdateConfiguration\(resources\);\s*await assertPackagedDependencyClosure\(resources\);\s*\}\s*else await requirePath\(join\(resources, ['"]git['"]/u, + /if \(requiresCurrentContract\) \{\s*await assertPackagedUpdateConfiguration\(resources, \{\s*channel: environment\.MAKA_DESKTOP_NIGHTLY_VERSION \? ['"]nightly['"] : ['"]release['"],\s*\}\);\s*await assertPackagedDependencyClosure\(resources\);\s*\}\s*else await requirePath\(join\(resources, ['"]git['"]/u, ); const macosSource = await readFile( diff --git a/scripts/verify-macos-arm64-dmg.mjs b/scripts/verify-macos-arm64-dmg.mjs index d3c9fa77f1..96f1a4ccea 100644 --- a/scripts/verify-macos-arm64-dmg.mjs +++ b/scripts/verify-macos-arm64-dmg.mjs @@ -33,6 +33,7 @@ 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 { resolveDesktopBuildVersion } from './desktop-nightly.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -126,6 +127,7 @@ export async function verifyPackagedMacApp( smokeRenderer = smokePackagedRenderer, smokeFilesystemWorker = smokePackagedFilesystemWorker, workingDirectory = dirname(appPath), + environment = process.env, } = {}, ) { const product = await readProductManifestIdentity(); @@ -138,8 +140,9 @@ export async function verifyPackagedMacApp( throw new Error(`Expected app id ${expectedAppId}, found ${appId}.`); } const version = await readPlistValue(run, infoPlist, 'CFBundleShortVersionString'); - if (version !== product.version) { - throw new Error(`Expected app version ${product.version}, found ${version}.`); + const expectedVersion = resolveDesktopBuildVersion(product.version, environment); + if (version !== expectedVersion) { + throw new Error(`Expected app version ${expectedVersion}, found ${version}.`); } const executableName = await readPlistValue(run, infoPlist, 'CFBundleExecutable'); const executable = join(contents, 'MacOS', executableName); @@ -148,7 +151,9 @@ export async function verifyPackagedMacApp( await requirePath(executable); await assertPackagedResources(resources, { requirePath, forbidPath }); - await assertPackagedUpdateConfiguration(resources); + await assertPackagedUpdateConfiguration(resources, { + channel: environment.MAKA_DESKTOP_NIGHTLY_VERSION ? 'nightly' : 'release', + }); await assertPackagedDependencyClosure(resources); const executableArchitectures = await run('lipo', ['-archs', executable]); diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index e46cf03d8a..e420dddc7b 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -24,6 +24,7 @@ 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 { resolveDesktopBuildVersion } from './desktop-nightly.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -125,6 +126,7 @@ export async function verifyPackagedWindowsApp( workingDirectory = appDirectory, expectedVersion, artifactContract = 'current', + environment = process.env, } = {}, ) { if (artifactContract !== 'current' && artifactContract !== 'legacy-baseline') { @@ -150,7 +152,9 @@ export async function verifyPackagedWindowsApp( requireDirectPeerArtifact: requiresCurrentContract, }); if (requiresCurrentContract) { - await assertPackagedUpdateConfiguration(resources); + await assertPackagedUpdateConfiguration(resources, { + channel: environment.MAKA_DESKTOP_NIGHTLY_VERSION ? 'nightly' : 'release', + }); await assertPackagedDependencyClosure(resources); } else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); @@ -240,7 +244,10 @@ export async function verifyPackagedWindowsApp( run, `(Get-Item -LiteralPath ${powerShellLiteral(executable)}).VersionInfo.ProductVersion`, ); - assertWindowsProductVersion(stdout, expectedVersion ?? product.version); + assertWindowsProductVersion( + stdout, + expectedVersion ?? resolveDesktopBuildVersion(product.version, environment), + ); step('smoking node-pty through conpty'); const ptyProbe = makePtyProbe( From 98c53a87c6c74c0f65dbf21db5152f539b994c17 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 03:22:19 +0800 Subject: [PATCH 2/4] fix: make Desktop Nightly publication append-only Treat each fresh workflow run as one immutable version, publish payloads before independent platform feeds, and reject in-place reruns. Replace the third-party rsync action with strict host-key-verified transport scoped to the protected Nightly environment. Generated-by: Codex --- .github/DESKTOP_NIGHTLY.md | 10 +- .github/workflows/desktop-nightly.yml | 78 +++++++------ package.json | 2 +- scripts/desktop-nightly-stage.test.mjs | 30 +++-- .../desktop-nightly-workflow-policy.test.mjs | 107 +++++++++++++++--- scripts/desktop-nightly.mjs | 71 +++--------- scripts/desktop-nightly.test.mjs | 43 +++++++ 7 files changed, 221 insertions(+), 120 deletions(-) diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md index 4d206918eb..d3f285e693 100644 --- a/.github/DESKTOP_NIGHTLY.md +++ b/.github/DESKTOP_NIGHTLY.md @@ -21,17 +21,17 @@ Desktop Nightly is an ephemeral developer snapshot, not an Apache release. It builds the current `main` commit every day so contributors can try recent Desktop changes and report problems without waiting for an ASF source-release vote. -The workflow gives each snapshot an immutable version such as `0.2.0-dev.20260829.42` and records its exact source commit in `nightly.json`. A packaged Nightly accepts updates only from `https://nightlies.apache.org/maka/desktop/`, accepts only newer `dev` versions, and verifies that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build continues to use the GitHub Release feed and the formal product-release attestation identity. +The workflow gives each snapshot an immutable version such as `0.2.0-dev.20260829.42`. The download page records its exact source commit. A packaged Nightly accepts updates only from `https://nightlies.apache.org/maka/desktop/`, accepts only newer `dev` versions, and verifies that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build continues to use the GitHub Release feed and the formal product-release attestation identity. Nightly currently uses the same application identity as the formal Desktop. Installing it replaces the existing Maka installation rather than creating a second side-by-side app. Its user data remains in the same location. Testers who need the formal build should reinstall that build before returning to the formal channel. ## One-time setup -1. Ask Apache Infra to allow `apache/maka` to publish GitHub Actions output to `nightlies.apache.org`. The resulting repository secrets are `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_PORT`, `NIGHTLIES_RSYNC_USER`, and `NIGHTLIES_RSYNC_KEY`. -2. Create a GitHub Environment named `nightly` that permits only `main`. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to pull-request workflows. +1. Ask Apache Infra to allow `apache/maka` to publish GitHub Actions output to `nightlies.apache.org`, provide the SSH `known_hosts` entry through an authenticated channel, and confirm whether retention is service-managed or requires a separate project cleanup job. Do not enable scheduled publication until that retention owner is explicit. +2. Create a GitHub Environment named `nightly` that permits only `main`. Store `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_PORT`, `NIGHTLIES_RSYNC_USER`, `NIGHTLIES_RSYNC_KEY`, and the Infra-verified `NIGHTLIES_RSYNC_KNOWN_HOSTS` value as Environment secrets. Configure its macOS signing and notarization secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, and `APPLE_API_ISSUER`. Do not expose these secrets to repository-wide or pull-request workflows. 3. Merge the Nightly workflow while it is disabled. After Infra publishing and the Environment secrets are ready, set the repository variable `DESKTOP_NIGHTLY_ENABLED` to `true` and run `Desktop Nightly` manually once. -4. Verify the download page, `nightly.json`, `latest-mac.yml`, and `latest.yml` under `https://nightlies.apache.org/maka/desktop/`, install both platform artifacts on clean machines, and confirm one automatic update before sharing the channel with testers. +4. Verify the download page, `latest-mac.yml`, and `latest.yml` under `https://nightlies.apache.org/maka/desktop/`, install both platform artifacts on clean machines, and confirm one automatic update before sharing the channel with testers. -The scheduled run starts at 18:17 UTC. It audits the shipped dependency closure, builds and verifies macOS arm64 and Windows x64 artifacts, issues and locally verifies Sigstore provenance, replaces the previous versioned payloads, and advances the mutable update metadata last. A failed build leaves the previous Nightly feed untouched. Apache Nightlies storage is temporary; it must not be used as a formal release archive. +The scheduled run starts at 18:17 UTC. It audits the shipped dependency closure, builds and verifies macOS arm64 and Windows x64 artifacts, issues and locally verifies Sigstore provenance, appends a new immutable version directory, and advances the mutable update metadata last. A failure before publication leaves both existing platform feeds untouched. Each platform feed file is replaced independently after its complete payload exists, so an interrupted feed transfer may temporarily leave macOS and Windows on different valid Nightly versions. Do not rerun a failed workflow attempt in place; start a fresh manual run so it receives a new version. Historical payload cleanup is separate from publication, targets the Nightlies retention policy, and must never rewrite a published version or delete one referenced by a feed. Apache Nightlies storage is temporary; it must not be used as a formal release archive. Remote Runtime Host setup still follows the package identity embedded in the repository manifests. A Nightly does not publish a matching npm package, so clean remote setup is outside this channel until that dependency has its own reviewed snapshot distribution contract. diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml index 204a6dbec1..c249e17555 100644 --- a/.github/workflows/desktop-nightly.yml +++ b/.github/workflows/desktop-nightly.yml @@ -31,7 +31,7 @@ concurrency: jobs: identity: - if: vars.DESKTOP_NIGHTLY_ENABLED == 'true' + if: vars.DESKTOP_NIGHTLY_ENABLED == 'true' && github.run_attempt == 1 runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: @@ -57,6 +57,7 @@ jobs: run: node scripts/desktop-nightly.mjs identity desktop: + if: github.run_attempt == 1 needs: identity strategy: fail-fast: false @@ -168,15 +169,17 @@ jobs: - name: Preserve the verified Nightly artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: desktop-nightly-${{ matrix.platform }}-${{ github.run_attempt }} + name: desktop-nightly-${{ matrix.platform }} path: ${{ runner.temp }}/desktop-nightly if-no-files-found: error compression-level: 0 retention-days: 30 publish: + if: github.run_attempt == 1 needs: [identity, desktop] runs-on: ubuntu-24.04 + environment: nightly timeout-minutes: 20 permissions: artifact-metadata: write @@ -202,8 +205,8 @@ jobs: - name: Download both verified Desktop builds uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: desktop-nightly-*-${{ github.run_attempt }} - path: ${{ runner.temp }}/desktop-nightly-input + pattern: desktop-nightly-* + path: ${{ github.workspace }}/.nightly-input merge-multiple: true - name: Stage the versioned Nightly site @@ -212,23 +215,16 @@ jobs: SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }} run: | node scripts/desktop-nightly.mjs stage \ - "$RUNNER_TEMP/desktop-nightly-input" \ - "$RUNNER_TEMP/desktop-nightly-site" \ + "$GITHUB_WORKSPACE/.nightly-input" \ + "$GITHUB_WORKSPACE/.nightly-publish" \ "$NIGHTLY_VERSION" \ "$SOURCE_COMMIT" - mkdir "$RUNNER_TEMP/desktop-nightly-feed" - cp -- \ - "$RUNNER_TEMP/desktop-nightly-site/latest-mac.yml" \ - "$RUNNER_TEMP/desktop-nightly-site/latest.yml" \ - "$RUNNER_TEMP/desktop-nightly-site/nightly.json" \ - "$RUNNER_TEMP/desktop-nightly-site/index.html" \ - "$RUNNER_TEMP/desktop-nightly-feed/" - name: Attest the exact Nightly payloads id: attest uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 with: - subject-path: ${{ runner.temp }}/desktop-nightly-site/versions/${{ needs.identity.outputs.version }}/* + subject-path: ${{ github.workspace }}/.nightly-publish/versions/${{ needs.identity.outputs.version }}/* - name: Verify the issued Nightly provenance env: @@ -245,7 +241,7 @@ jobs: --cert-identity "$CERTIFICATE_IDENTITY" \ --cert-oidc-issuer https://token.actions.githubusercontent.com verified=$((verified + 1)) - done < <(find "$RUNNER_TEMP/desktop-nightly-site/versions/$NIGHTLY_VERSION" -maxdepth 1 -type f -print0) + done < <(find "$GITHUB_WORKSPACE/.nightly-publish/versions/$NIGHTLY_VERSION" -maxdepth 1 -type f -print0) if (( verified == 0 )); then echo "No Desktop Nightly artifacts were verified" >&2 exit 1 @@ -257,26 +253,40 @@ jobs: NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} run: >- cp -- "$ATTESTATION_BUNDLE" - "$RUNNER_TEMP/desktop-nightly-site/versions/$NIGHTLY_VERSION/Maka-$NIGHTLY_VERSION-attestation.sigstore.json" + "$GITHUB_WORKSPACE/.nightly-publish/versions/$NIGHTLY_VERSION/Maka-$NIGHTLY_VERSION-attestation.sigstore.json" + + - name: Prepare authenticated Nightlies SSH transport + env: + NIGHTLIES_RSYNC_HOST: ${{ secrets.NIGHTLIES_RSYNC_HOST }} + NIGHTLIES_RSYNC_KEY: ${{ secrets.NIGHTLIES_RSYNC_KEY }} + NIGHTLIES_RSYNC_KNOWN_HOSTS: ${{ secrets.NIGHTLIES_RSYNC_KNOWN_HOSTS }} + NIGHTLIES_RSYNC_PATH: ${{ secrets.NIGHTLIES_RSYNC_PATH }} + NIGHTLIES_RSYNC_PORT: ${{ secrets.NIGHTLIES_RSYNC_PORT }} + NIGHTLIES_RSYNC_USER: ${{ secrets.NIGHTLIES_RSYNC_USER }} + run: | + test -n "$NIGHTLIES_RSYNC_HOST" + test -n "$NIGHTLIES_RSYNC_KEY" + test -n "$NIGHTLIES_RSYNC_KNOWN_HOSTS" + test -n "$NIGHTLIES_RSYNC_PATH" + test -n "$NIGHTLIES_RSYNC_USER" + [[ "$NIGHTLIES_RSYNC_PORT" =~ ^[0-9]{1,5}$ ]] + (( NIGHTLIES_RSYNC_PORT >= 1 && NIGHTLIES_RSYNC_PORT <= 65535 )) + ssh_directory="$RUNNER_TEMP/nightlies-ssh" + install -m 700 -d "$ssh_directory" + umask 077 + printf '%s\n' "$NIGHTLIES_RSYNC_KEY" > "$ssh_directory/key" + printf '%s\n' "$NIGHTLIES_RSYNC_KNOWN_HOSTS" > "$ssh_directory/known_hosts" + { + echo "NIGHTLIES_RSYNC_TARGET=$NIGHTLIES_RSYNC_USER@$NIGHTLIES_RSYNC_HOST:${NIGHTLIES_RSYNC_PATH%/}/maka/desktop" + echo "RSYNC_RSH=ssh -i $ssh_directory/key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$ssh_directory/known_hosts -p $NIGHTLIES_RSYNC_PORT" + } >> "$GITHUB_ENV" - name: Publish immutable Nightly payloads - uses: burnett01/rsync-deployments@53581dff6de0d8511d74864451aacee4846bdf39 # v8.0.2 - with: - switches: -rlptDvz --delete - path: ${{ runner.temp }}/desktop-nightly-site/versions/ - remote_path: ${{ secrets.NIGHTLIES_RSYNC_PATH }}/maka/desktop/versions - remote_host: ${{ secrets.NIGHTLIES_RSYNC_HOST }} - remote_port: ${{ secrets.NIGHTLIES_RSYNC_PORT }} - remote_user: ${{ secrets.NIGHTLIES_RSYNC_USER }} - remote_key: ${{ secrets.NIGHTLIES_RSYNC_KEY }} + run: rsync -rlptDvz --protect-args .nightly-publish/versions/ "$NIGHTLIES_RSYNC_TARGET/versions/" - name: Advance the Nightly update feed last - uses: burnett01/rsync-deployments@53581dff6de0d8511d74864451aacee4846bdf39 # v8.0.2 - with: - switches: -rlptDvz - path: ${{ runner.temp }}/desktop-nightly-feed/* - remote_path: ${{ secrets.NIGHTLIES_RSYNC_PATH }}/maka/desktop - remote_host: ${{ secrets.NIGHTLIES_RSYNC_HOST }} - remote_port: ${{ secrets.NIGHTLIES_RSYNC_PORT }} - remote_user: ${{ secrets.NIGHTLIES_RSYNC_USER }} - remote_key: ${{ secrets.NIGHTLIES_RSYNC_KEY }} + run: rsync -rlptDvz --protect-args .nightly-publish/feed/ "$NIGHTLIES_RSYNC_TARGET/" + + - name: Remove the temporary Nightlies credentials + if: always() + run: rm -f "$RUNNER_TEMP/nightlies-ssh/key" "$RUNNER_TEMP/nightlies-ssh/known_hosts" diff --git a/package.json b/package.json index 3d960adda0..4d37afc8cd 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "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-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-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-workflow-policy.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/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs index 4cba821b2c..d4140bc9cc 100644 --- a/scripts/desktop-nightly-stage.test.mjs +++ b/scripts/desktop-nightly-stage.test.mjs @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -46,7 +46,7 @@ async function writeUpdateSet(directory, version, platform) { ); } -test('staging publishes immutable payloads before versioned Nightly metadata', async (t) => { +test('staging separates append-only payloads from the mutable Nightly feed', async (t) => { const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-')); t.after(() => rm(root, { recursive: true, force: true })); const input = join(root, 'input'); @@ -68,21 +68,29 @@ test('staging publishes immutable payloads before versioned Nightly metadata', a }); const macMetadata = (await import('yaml')).parse( - await readFile(join(output, 'latest-mac.yml'), 'utf8'), + await readFile(join(output, 'feed', 'latest-mac.yml'), 'utf8'), ); const windowsMetadata = (await import('yaml')).parse( - await readFile(join(output, 'latest.yml'), 'utf8'), + await readFile(join(output, 'feed', 'latest.yml'), 'utf8'), ); assert.equal(macMetadata.files[0].url, `versions/${version}/Maka-${version}-mac-arm64.zip`); assert.equal(windowsMetadata.path, `versions/${version}/Maka-${version}-win-x64.exe`); - assert.equal( - JSON.parse(await readFile(join(output, 'nightly.json'), 'utf8')).sourceCommit, - 'a'.repeat(40), - ); - const index = await readFile(join(output, 'index.html'), 'utf8'); + const index = await readFile(join(output, 'feed', 'index.html'), 'utf8'); assert.match(index, /Desktop Nightly is a developer snapshot, not an Apache release/u); + assert.match(index, new RegExp(`source commit ${'a'.repeat(40)}`, 'u')); assert.match(index, new RegExp(`versions/${version}/Maka-${version}-mac-arm64\.dmg`, 'u')); assert.match(index, new RegExp(`versions/${version}/Maka-${version}-win-x64\.exe`, 'u')); - await readFile(join(output, 'versions', version, `Maka-${version}-mac-arm64.zip`)); - await readFile(join(output, 'versions', version, `Maka-${version}-win-x64.exe`)); + assert.deepEqual((await readdir(join(output, 'feed'))).sort(), [ + 'index.html', + 'latest-mac.yml', + 'latest.yml', + ]); + assert.deepEqual((await readdir(join(output, 'versions', version))).sort(), [ + `Maka-${version}-mac-arm64.dmg`, + `Maka-${version}-mac-arm64.zip`, + `Maka-${version}-mac-arm64.zip.blockmap`, + `Maka-${version}-win-x64.exe`, + `Maka-${version}-win-x64.exe.blockmap`, + `Maka-${version}-win-x64.zip`, + ]); }); diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs index b122ea0e45..2b30706616 100644 --- a/scripts/desktop-nightly-workflow-policy.test.mjs +++ b/scripts/desktop-nightly-workflow-policy.test.mjs @@ -20,27 +20,102 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; +import { parse } from 'yaml'; const workflowPath = new URL('../.github/workflows/desktop-nightly.yml', import.meta.url); +async function readWorkflow() { + return parse(await readFile(workflowPath, 'utf8')); +} + +test('a failed Nightly is retried only as a fresh workflow run', async () => { + const workflow = await readWorkflow(); + assert.deepEqual(workflow.concurrency, { + group: 'desktop-nightly', + 'cancel-in-progress': false, + }); + assert.equal( + workflow.jobs.identity.if, + "vars.DESKTOP_NIGHTLY_ENABLED == 'true' && github.run_attempt == 1", + ); + assert.equal(workflow.jobs.desktop.if, 'github.run_attempt == 1'); + assert.equal(workflow.jobs.publish.if, 'github.run_attempt == 1'); + const upload = workflow.jobs.desktop.steps.find((step) => + step.uses?.startsWith('actions/upload-artifact@'), + ); + const download = workflow.jobs.publish.steps.find((step) => + step.uses?.startsWith('actions/download-artifact@'), + ); + assert.equal(upload.with.name, 'desktop-nightly-${{ matrix.platform }}'); + assert.equal(download.with.pattern, 'desktop-nightly-*'); +}); + +test('the protected publisher appends workspace-staged payloads before advancing the feed', async () => { + const workflow = await readWorkflow(); + const publish = workflow.jobs.publish; + assert.equal(publish.environment, 'nightly'); + assert.equal( + publish.steps.filter((step) => step.uses?.startsWith('burnett01/rsync-deployments@')).length, + 0, + ); + const transport = publish.steps.find( + (step) => step.name === 'Prepare authenticated Nightlies SSH transport', + ); + assert.equal(transport.env.NIGHTLIES_RSYNC_KEY, '${{ secrets.NIGHTLIES_RSYNC_KEY }}'); + assert.equal( + transport.env.NIGHTLIES_RSYNC_KNOWN_HOSTS, + '${{ secrets.NIGHTLIES_RSYNC_KNOWN_HOSTS }}', + ); + assert.match(transport.run, /StrictHostKeyChecking=yes/u); + assert.doesNotMatch(transport.run, /ssh-keyscan|StrictHostKeyChecking=no/u); + const transfers = [ + 'Publish immutable Nightly payloads', + 'Advance the Nightly update feed last', + ].map((name) => publish.steps.find((step) => step.name === name)); + assert.deepEqual( + transfers.map((step) => step.env?.NIGHTLIES_RSYNC_KEY), + [undefined, undefined], + ); + for (const step of transfers) { + assert.match(step.run, /^rsync -rlptDvz --protect-args /u); + assert.doesNotMatch(step.run, /--delete/u); + } +}); + test('Nightly stays disabled until its external publishing authority is configured', async () => { - const workflow = await readFile(workflowPath, 'utf8'); - assert.match(workflow, /if: vars\.DESKTOP_NIGHTLY_ENABLED == 'true'/u); - assert.match(workflow, /test "\$GITHUB_REPOSITORY" = apache\/maka/u); - assert.match(workflow, /test "\$GITHUB_REF" = refs\/heads\/main/u); - assert.doesNotMatch(workflow, /source_reference_tag|incubating|contents: write/u); + const workflow = await readWorkflow(); + assert.equal(workflow.permissions.contents, 'read'); + assert.equal( + workflow.jobs.identity.if, + "vars.DESKTOP_NIGHTLY_ENABLED == 'true' && github.run_attempt == 1", + ); + const branchGate = workflow.jobs.identity.steps.find( + (step) => step.name === 'Require the Apache main branch', + ); + assert.match(branchGate.run, /test "\$GITHUB_REPOSITORY" = apache\/maka/u); + assert.match(branchGate.run, /test "\$GITHUB_REF" = refs\/heads\/main/u); + assert.equal(workflow.jobs.desktop.environment, 'nightly'); + assert.equal(workflow.jobs.publish.environment, 'nightly'); }); test('Nightly verifies provenance and advances mutable feeds only after payload upload', async () => { - const workflow = await readFile(workflowPath, 'utf8'); - const attest = workflow.indexOf('name: Attest the exact Nightly payloads'); - const verify = workflow.indexOf('name: Verify the issued Nightly provenance'); - const payloads = workflow.indexOf('name: Publish immutable Nightly payloads'); - const feed = workflow.indexOf('name: Advance the Nightly update feed last'); - - assert.ok(attest >= 0 && verify > attest && payloads > verify && feed > payloads); - assert.match(workflow, /\.github\/workflows\/desktop-nightly\.yml@refs\/heads\/main/u); - assert.match(workflow, /gh attestation verify/u); - assert.match(workflow, /NIGHTLIES_RSYNC_PATH/u); - assert.match(workflow, /switches: -rlptDvz --delete/u); + const workflow = await readWorkflow(); + const steps = workflow.jobs.publish.steps; + const positions = [ + 'Attest the exact Nightly payloads', + 'Verify the issued Nightly provenance', + 'Publish immutable Nightly payloads', + 'Advance the Nightly update feed last', + ].map((name) => steps.findIndex((step) => step.name === name)); + assert.deepEqual( + positions, + positions.toSorted((left, right) => left - right), + ); + assert.ok(positions.every((position) => position >= 0)); + const verify = steps[positions[1]]; + assert.equal( + verify.env.CERTIFICATE_IDENTITY, + 'https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main', + ); + assert.match(verify.run, /gh attestation verify/u); }); diff --git a/scripts/desktop-nightly.mjs b/scripts/desktop-nightly.mjs index c42cabc876..b8235449b1 100644 --- a/scripts/desktop-nightly.mjs +++ b/scripts/desktop-nightly.mjs @@ -17,7 +17,6 @@ * under the License. */ -import { createHash } from 'node:crypto'; import { appendFile, copyFile, @@ -30,7 +29,6 @@ import { } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { parse, stringify } from 'yaml'; import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; import { parseProductReleaseVersion } from './release-version.mjs'; @@ -62,18 +60,6 @@ export function resolveDesktopBuildVersion(productVersion, environment = process : productVersion; } -function assertStandaloneNightlyVersion(version) { - const parsed = parseProductReleaseVersion(version); - if ( - parsed.prerelease.length !== 3 || - parsed.prerelease[0] !== 'dev' || - !/^\d{8}$/u.test(parsed.prerelease[1]) || - !/^[1-9]\d*$/u.test(parsed.prerelease[2]) - ) { - throw new Error(`Desktop Nightly version is invalid: ${version}`); - } -} - function nightlyArtifactNames(version) { return { macZip: `Maka-${version}-mac-arm64.zip`, @@ -83,13 +69,8 @@ function nightlyArtifactNames(version) { }; } -async function sha256(path) { - return createHash('sha256') - .update(await readFile(path)) - .digest('hex'); -} - async function rewriteNightlyMetadata(source, destination, version) { + const { parse, stringify } = await import('yaml'); const metadata = parse(await readFile(source, 'utf8')); const prefix = `versions/${version}/`; metadata.path = `${prefix}${metadata.path}`; @@ -113,7 +94,7 @@ function nightlyIndex(version, sourceCommit, names) {
  • Download for macOS arm64
  • Download for Windows x64 (unsigned preview)
  • -

    Installed Nightly builds update automatically from this channel. See nightly.json for the machine-readable identity.

    +

    Installed Nightly builds update automatically from this channel.

    @@ -126,7 +107,8 @@ export async function stageDesktopNightly({ version, sourceCommit, }) { - assertStandaloneNightlyVersion(version); + const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + assertDesktopNightlyVersion(version, productManifest.version); if (typeof sourceCommit !== 'string' || !/^[0-9a-f]{40}$/u.test(sourceCommit)) { throw new Error('Desktop Nightly requires an exact source commit'); } @@ -165,7 +147,11 @@ export async function stageDesktopNightly({ await rm(outputDirectory, { recursive: true, force: true }); const versionDirectory = join(outputDirectory, 'versions', version); - await mkdir(versionDirectory, { recursive: true }); + const feedDirectory = join(outputDirectory, 'feed'); + await Promise.all([ + mkdir(versionDirectory, { recursive: true }), + mkdir(feedDirectory, { recursive: true }), + ]); await Promise.all( payloads.map(async (name) => { const source = join(inputDirectory, name); @@ -174,36 +160,19 @@ export async function stageDesktopNightly({ await copyFile(source, join(versionDirectory, name)); }), ); - for (const name of [names.macDmg, names.macZip, names.windowsExe, names.windowsZip]) { - await writeFile( - join(versionDirectory, `${name}.sha256`), - `${await sha256(join(versionDirectory, name))} ${name}\n`, - 'utf8', - ); - } await Promise.all([ rewriteNightlyMetadata( join(inputDirectory, 'latest-mac.yml'), - join(outputDirectory, 'latest-mac.yml'), + join(feedDirectory, 'latest-mac.yml'), version, ), rewriteNightlyMetadata( join(inputDirectory, 'latest.yml'), - join(outputDirectory, 'latest.yml'), + join(feedDirectory, 'latest.yml'), version, ), ]); - await writeFile( - join(outputDirectory, 'nightly.json'), - `${JSON.stringify({ version, sourceCommit, artifacts: payloads }, null, 2)}\n`, - 'utf8', - ); - await writeFile( - join(outputDirectory, 'index.html'), - nightlyIndex(version, sourceCommit, names), - 'utf8', - ); - return { versionDirectory, payloads }; + await writeFile(join(feedDirectory, 'index.html'), nightlyIndex(version, sourceCommit, names)); } async function main(args, environment = process.env) { @@ -228,16 +197,12 @@ async function main(args, environment = process.env) { } if (command === 'stage' && rest.length === 4) { const [inputDirectory, outputDirectory, version, sourceCommit] = rest; - console.log( - JSON.stringify( - await stageDesktopNightly({ - inputDirectory, - outputDirectory, - version, - sourceCommit, - }), - ), - ); + await stageDesktopNightly({ + inputDirectory, + outputDirectory, + version, + sourceCommit, + }); return; } throw new Error( diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs index e821741fdb..eddd30d4cb 100644 --- a/scripts/desktop-nightly.test.mjs +++ b/scripts/desktop-nightly.test.mjs @@ -18,10 +18,19 @@ */ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; import { resolveDesktopBuilderConfig } from '../apps/desktop/electron-builder.config.mjs'; import { desktopNightlyIdentity, resolveDesktopBuildVersion } from './desktop-nightly.mjs'; +const run = promisify(execFile); +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + test('a nightly identity is a dev build of the checked-in product version', () => { assert.deepEqual( desktopNightlyIdentity({ @@ -37,6 +46,40 @@ test('a nightly identity is a dev build of the checked-in product version', () = ); }); +test('the identity entrypoint runs before repository dependencies are installed', async (t) => { + const fixture = await mkdtemp(join(tmpdir(), 'maka-nightly-identity-')); + t.after(() => rm(fixture, { recursive: true, force: true })); + await mkdir(join(fixture, 'scripts')); + await Promise.all([ + copyFile(join(repoRoot, 'package.json'), join(fixture, 'package.json')), + copyFile( + join(repoRoot, 'scripts', 'desktop-nightly.mjs'), + join(fixture, 'scripts', 'desktop-nightly.mjs'), + ), + copyFile( + join(repoRoot, 'scripts', 'desktop-update-contract.mjs'), + join(fixture, 'scripts', 'desktop-update-contract.mjs'), + ), + copyFile( + join(repoRoot, 'scripts', 'release-version.mjs'), + join(fixture, 'scripts', 'release-version.mjs'), + ), + ]); + + const { stdout } = await run(process.execPath, ['scripts/desktop-nightly.mjs', 'identity'], { + cwd: fixture, + env: { + GITHUB_RUN_NUMBER: '42', + GITHUB_SHA: 'a'.repeat(40), + NIGHTLY_BUILD_DATE: '2026-08-29T18:17:00Z', + }, + }); + assert.deepEqual(JSON.parse(stdout), { + version: '0.2.0-dev.20260829.42', + sourceCommit: 'a'.repeat(40), + }); +}); + test('a nightly package embeds only the Apache Nightlies update authority', () => { const version = '0.2.0-dev.20260829.42'; const config = resolveDesktopBuilderConfig({ From ab2bc6f6ac70b9299062a34d637b83e2843da7b7 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 10:41:34 +0800 Subject: [PATCH 3/4] fix: integrate Nightly control-plane contracts Fail in-place workflow reruns explicitly, route every Nightly authority change through release-contract validation, and strengthen staged-feed verification. Generated-by: Codex --- .github/workflows/desktop-nightly.yml | 12 ++++-- scripts/ci-test-plan.mjs | 2 + scripts/ci-test-plan.test.mjs | 12 ++++++ scripts/desktop-nightly-stage.test.mjs | 40 +++++++++++++++---- .../desktop-nightly-workflow-policy.test.mjs | 19 +++++---- 5 files changed, 63 insertions(+), 22 deletions(-) diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml index c249e17555..c58bf6f836 100644 --- a/.github/workflows/desktop-nightly.yml +++ b/.github/workflows/desktop-nightly.yml @@ -31,13 +31,19 @@ concurrency: jobs: identity: - if: vars.DESKTOP_NIGHTLY_ENABLED == 'true' && github.run_attempt == 1 + if: vars.DESKTOP_NIGHTLY_ENABLED == 'true' runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: source_commit: ${{ steps.identity.outputs.source_commit }} version: ${{ steps.identity.outputs.version }} steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + - name: Check out the scheduled main commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -57,7 +63,6 @@ jobs: run: node scripts/desktop-nightly.mjs identity desktop: - if: github.run_attempt == 1 needs: identity strategy: fail-fast: false @@ -176,7 +181,6 @@ jobs: retention-days: 30 publish: - if: github.run_attempt == 1 needs: [identity, desktop] runs-on: ubuntu-24.04 environment: nightly @@ -222,7 +226,7 @@ jobs: - name: Attest the exact Nightly payloads id: attest - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: ${{ github.workspace }}/.nightly-publish/versions/${{ needs.identity.outputs.version }}/* diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 9e4f034f5b..a147af6210 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -43,6 +43,7 @@ const RELEASE_CONTRACT_FILES = new Set([ 'apps/desktop/electron-builder.config.mjs', 'apps/desktop/package.json', '.github/workflows/cli-package-validation.yml', + '.github/workflows/desktop-nightly.yml', '.github/workflows/release-cli-finalize.yml', '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', @@ -146,6 +147,7 @@ function isCliPackagePath(path) { function isReleaseContractPath(path) { return ( RELEASE_CONTRACT_FILES.has(path) || + path.startsWith('scripts/desktop-nightly') || path.startsWith('scripts/product-release-') || path.startsWith('scripts/release-cli-') ); diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 2ee7e3b524..1da1e0afd4 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -167,6 +167,18 @@ test('release authority changes select their dedicated contract gate', () => { assert.equal(planTests(['.github/RELEASE_CHECKLIST.md'], { graph }).releaseContract, false); }); +test('Desktop Nightly authority changes select the release contract gate', () => { + for (const path of [ + '.github/workflows/desktop-nightly.yml', + 'scripts/desktop-nightly.mjs', + 'scripts/desktop-nightly.test.mjs', + 'scripts/desktop-nightly-stage.test.mjs', + 'scripts/desktop-nightly-workflow-policy.test.mjs', + ]) { + assert.equal(planTests([path], { graph }).releaseContract, true, path); + } +}); + // Both notices are committed generator output. A hand edit or a merge-conflict // resolution can corrupt either one, and `check:release` is what regenerates // and diffs them, so both must reach that gate — the desktop notice lives diff --git a/scripts/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs index d4140bc9cc..9871f17ca5 100644 --- a/scripts/desktop-nightly-stage.test.mjs +++ b/scripts/desktop-nightly-stage.test.mjs @@ -25,6 +25,7 @@ import { join } from 'node:path'; import { test } from 'node:test'; import { stringify } from 'yaml'; import { stageDesktopNightly } from './desktop-nightly.mjs'; +import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; async function writeUpdateSet(directory, version, platform) { const isMac = platform === 'mac'; @@ -67,6 +68,36 @@ test('staging separates append-only payloads from the mutable Nightly feed', asy sourceCommit: 'a'.repeat(40), }); + const payloadNames = [ + `Maka-${version}-mac-arm64.dmg`, + `Maka-${version}-mac-arm64.zip`, + `Maka-${version}-mac-arm64.zip.blockmap`, + `Maka-${version}-win-x64.exe`, + `Maka-${version}-win-x64.exe.blockmap`, + `Maka-${version}-win-x64.zip`, + ]; + for (const name of payloadNames) { + assert.deepEqual( + await readFile(join(output, 'versions', version, name)), + await readFile(join(input, name)), + name, + ); + } + await Promise.all([ + verifyDesktopUpdateArtifacts({ + directory: output, + metadataName: 'feed/latest-mac.yml', + version, + artifactName: `versions/${version}/Maka-${version}-mac-arm64.zip`, + }), + verifyDesktopUpdateArtifacts({ + directory: output, + metadataName: 'feed/latest.yml', + version, + artifactName: `versions/${version}/Maka-${version}-win-x64.exe`, + }), + ]); + const macMetadata = (await import('yaml')).parse( await readFile(join(output, 'feed', 'latest-mac.yml'), 'utf8'), ); @@ -85,12 +116,5 @@ test('staging separates append-only payloads from the mutable Nightly feed', asy 'latest-mac.yml', 'latest.yml', ]); - assert.deepEqual((await readdir(join(output, 'versions', version))).sort(), [ - `Maka-${version}-mac-arm64.dmg`, - `Maka-${version}-mac-arm64.zip`, - `Maka-${version}-mac-arm64.zip.blockmap`, - `Maka-${version}-win-x64.exe`, - `Maka-${version}-win-x64.exe.blockmap`, - `Maka-${version}-win-x64.zip`, - ]); + assert.deepEqual((await readdir(join(output, 'versions', version))).sort(), payloadNames); }); diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs index 2b30706616..212fa047b4 100644 --- a/scripts/desktop-nightly-workflow-policy.test.mjs +++ b/scripts/desktop-nightly-workflow-policy.test.mjs @@ -18,6 +18,7 @@ */ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import { parse } from 'yaml'; @@ -34,12 +35,13 @@ test('a failed Nightly is retried only as a fresh workflow run', async () => { group: 'desktop-nightly', 'cancel-in-progress': false, }); - assert.equal( - workflow.jobs.identity.if, - "vars.DESKTOP_NIGHTLY_ENABLED == 'true' && github.run_attempt == 1", - ); - assert.equal(workflow.jobs.desktop.if, 'github.run_attempt == 1'); - assert.equal(workflow.jobs.publish.if, 'github.run_attempt == 1'); + assert.equal(workflow.jobs.identity.if, "vars.DESKTOP_NIGHTLY_ENABLED == 'true'"); + const rerunGuard = workflow.jobs.identity.steps[0]; + assert.equal(rerunGuard.name, 'Reject in-place workflow reruns'); + assert.equal(rerunGuard.if, 'github.run_attempt != 1'); + assert.equal(spawnSync('bash', ['-c', rerunGuard.run]).status, 1); + assert.equal(workflow.jobs.desktop.if, undefined); + assert.equal(workflow.jobs.publish.if, undefined); const upload = workflow.jobs.desktop.steps.find((step) => step.uses?.startsWith('actions/upload-artifact@'), ); @@ -85,10 +87,7 @@ test('the protected publisher appends workspace-staged payloads before advancing test('Nightly stays disabled until its external publishing authority is configured', async () => { const workflow = await readWorkflow(); assert.equal(workflow.permissions.contents, 'read'); - assert.equal( - workflow.jobs.identity.if, - "vars.DESKTOP_NIGHTLY_ENABLED == 'true' && github.run_attempt == 1", - ); + assert.equal(workflow.jobs.identity.if, "vars.DESKTOP_NIGHTLY_ENABLED == 'true'"); const branchGate = workflow.jobs.identity.steps.find( (step) => step.name === 'Require the Apache main branch', ); From e841adb1d11b2801926c0dca6c0736ff284111ce Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 11:43:15 +0800 Subject: [PATCH 4/4] fix: make Nightly execution boundaries explicit Reject in-place reruns independently in every rerunnable Nightly job. Keep formal release contract checks deterministic when packaging runs with Nightly environment variables. Generated-by: Codex --- .github/workflows/desktop-nightly.yml | 12 ++++++++++++ .../desktop-nightly-workflow-policy.test.mjs | 10 ++++++---- scripts/desktop-nightly.test.mjs | 19 +++++++++++++++++++ scripts/product-release.test.mjs | 3 ++- 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml index c58bf6f836..728906c864 100644 --- a/.github/workflows/desktop-nightly.yml +++ b/.github/workflows/desktop-nightly.yml @@ -81,6 +81,12 @@ jobs: env: MAKA_DESKTOP_NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + - name: Check out the exact Nightly source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -191,6 +197,12 @@ jobs: contents: read id-token: write steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "Desktop Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + - name: Check out the Nightly publisher uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs index 212fa047b4..cc6e183215 100644 --- a/scripts/desktop-nightly-workflow-policy.test.mjs +++ b/scripts/desktop-nightly-workflow-policy.test.mjs @@ -36,10 +36,12 @@ test('a failed Nightly is retried only as a fresh workflow run', async () => { 'cancel-in-progress': false, }); assert.equal(workflow.jobs.identity.if, "vars.DESKTOP_NIGHTLY_ENABLED == 'true'"); - const rerunGuard = workflow.jobs.identity.steps[0]; - assert.equal(rerunGuard.name, 'Reject in-place workflow reruns'); - assert.equal(rerunGuard.if, 'github.run_attempt != 1'); - assert.equal(spawnSync('bash', ['-c', rerunGuard.run]).status, 1); + for (const jobName of ['identity', 'desktop', 'publish']) { + const rerunGuard = workflow.jobs[jobName].steps[0]; + assert.equal(rerunGuard.name, 'Reject in-place workflow reruns'); + assert.equal(rerunGuard.if, 'github.run_attempt != 1'); + assert.equal(spawnSync('bash', ['-c', rerunGuard.run]).status, 1); + } assert.equal(workflow.jobs.desktop.if, undefined); assert.equal(workflow.jobs.publish.if, undefined); const upload = workflow.jobs.desktop.steps.find((step) => diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs index eddd30d4cb..76922eac7a 100644 --- a/scripts/desktop-nightly.test.mjs +++ b/scripts/desktop-nightly.test.mjs @@ -96,6 +96,25 @@ test('a nightly package embeds only the Apache Nightlies update authority', () = ]); }); +test('formal release checks ignore the ambient Nightly packaging environment', async () => { + const { NODE_TEST_CONTEXT: _nodeTestContext, ...environment } = process.env; + await run( + process.execPath, + [ + '--test', + '--test-name-pattern=Desktop packaging derives|platform package verifiers', + 'scripts/product-release.test.mjs', + ], + { + cwd: repoRoot, + env: { + ...environment, + MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.20260829.42', + }, + }, + ); +}); + test('packaging observes a valid nightly version without changing product manifests', () => { assert.equal( resolveDesktopBuildVersion('0.2.0', { diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 3854aa8081..aa9d02eb7c 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -25,7 +25,7 @@ import { join } from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; -import desktopBuilderConfig from '../apps/desktop/electron-builder.config.mjs'; +import { resolveDesktopBuilderConfig } from '../apps/desktop/electron-builder.config.mjs'; import { parseAsfSourceReferenceTag, resolveProductManifestIdentity, @@ -56,6 +56,7 @@ import { ensureProductTag } from './product-release-tag.mjs'; const execFileAsync = promisify(execFile); const repoRoot = join(import.meta.dirname, '..'); +const desktopBuilderConfig = resolveDesktopBuilderConfig({}); const rootManifest = { version: '1.2.3',