diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md
index 91d2fd5d1f..f6120f2136 100644
--- a/.github/DESKTOP_NIGHTLY.md
+++ b/.github/DESKTOP_NIGHTLY.md
@@ -21,19 +21,22 @@
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 npm publication workflow gives each snapshot an immutable version such as `0.2.0-dev.42.20260829`. The run number is the sole ordering authority. After that exact npm version is public, it triggers Desktop Nightly with a version-only artifact; the authenticated workflow event supplies the exact source commit and upstream run. A packaged Nightly accepts updates only from `https://nightlies.apache.org/maka/desktop/`, advances only to a higher run number, 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 npm publication workflow gives each snapshot an immutable version such as `0.2.0-dev.42.20260829`. The run number is the sole ordering authority. After that exact npm version is public, it triggers Desktop Nightly with a version-only artifact; the authenticated workflow event supplies the exact source commit and upstream run. Each fresh Desktop Nightly creates a protected `v` tag and one GitHub draft prerelease containing the macOS and Windows packages, blockmaps, `dev-mac.yml`, `dev.yml`, and one offline Sigstore bundle. The workflow verifies every remote asset before it publishes the prerelease as non-Latest. Packaged Nightlies use the GitHub `dev` channel and verify that downloaded bytes were attested by `.github/workflows/desktop-nightly.yml` on `main`. A formal Desktop build uses the separate stable GitHub Release channel and 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.
+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. Builds previously downloaded from `nightlies.apache.org` do not migrate automatically; testers must install the newest GitHub prerelease once, after which GitHub Nightlies update automatically.
## One-time setup
-1. Ask Apache Infra to allow `apache/maka` to publish GitHub Actions output to `nightlies.apache.org` and whitelist the repository for the standard `NIGHTLIES_RSYNC_HOST`, `NIGHTLIES_RSYNC_KEY`, `NIGHTLIES_RSYNC_PATH`, `NIGHTLIES_RSYNC_PORT`, and `NIGHTLIES_RSYNC_USER` secrets.
-2. After the checked-in `.asf.yaml` reaches `main`, verify that ASF reconciliation created the `nightly` GitHub Environment with only `main` permitted and no approval gate. Do not maintain that policy manually in GitHub. Verify that its jobs can read the five Infra-provided Nightlies 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. Configure npm Trusted Publishing for `apache/maka` and `.github/workflows/npm-publication.yml`, restricted to the `npm-publication` Environment and with both `npm publish` and `npm stage publish` allowed. Do not create or store a long-lived npm token.
-4. After npm Trusted Publishing is ready, set `NPM_NIGHTLY_ENABLED` to `true`, run `npm publication` from `main` with `channel=nightly`, and verify the exact npm version and `nightly` dist-tag. This does not depend on Desktop Infra.
-5. After Infra publishing and the `nightly` Environment secrets are ready, set `DESKTOP_NIGHTLY_ENABLED` to `true` and start a fresh npm Nightly. Confirm that its successful run triggers `Desktop Nightly`.
-6. 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.
+1. After the checked-in `.asf.yaml` reaches `main`, verify that ASF reconciliation created the `nightly` GitHub Environment with only `main` permitted and no approval gate. Do not maintain that policy manually in GitHub. 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.
+2. Configure npm Trusted Publishing for `apache/maka` and `.github/workflows/npm-publication.yml`, restricted to the `npm-publication` Environment and with both `npm publish` and `npm stage publish` allowed. Do not create or store a long-lived npm token.
+3. Enable GitHub Immutable Releases for `apache/maka` before distributing Desktop Nightlies so published tags and assets cannot be replaced or deleted.
+4. After npm Trusted Publishing is ready, set `NPM_NIGHTLY_ENABLED` to `true`, run `npm publication` from `main` with `channel=nightly`, and verify the exact npm version and `nightly` dist-tag.
+5. Set `DESKTOP_NIGHTLY_ENABLED` to `true` and manually dispatch a fresh npm Nightly. Confirm that its successful run triggers `Desktop Nightly`. Do not rerun a failed attempt in place.
+6. Verify that `v` points to the exact source SHA and that its GitHub Release is published with Draft off, Prerelease on, Latest off, Immutable on, and exactly the nine expected assets. Install that prerelease on both platforms.
+7. Publish one later fresh Nightly and confirm a GitHub-to-GitHub automatic and differential update on both platforms before sharing the channel with testers.
-The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop applies the same check against both remote feed files before uploading anything. It then appends a new immutable Desktop version directory and advances the mutable update metadata last. 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 npm Nightly so it receives a new version. The Maka project owns historical payload cleanup separately from publication. Cleanup 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.
+The npm schedule starts at 18:17 UTC. Before changing the npm tag, the workflow requires its run number to exceed the current `nightly` version. Desktop assembles and verifies a draft before one publish mutation; a packaging, attestation, tag, upload, or digest failure leaves no partially published GitHub Release. Never rerun a failed workflow attempt in place; dispatch a fresh npm Nightly with a newer version.
+
+GitHub Release retention is intentionally outside this workflow. Do not delete an old Nightly prerelease or its tag while any installed client may need its payload or blockmap. Disabling `DESKTOP_NIGHTLY_ENABLED` stops new Desktop publication without mutating tags or releases.
Remote Runtime Host setup uses the exact `maka-agent@` package embedded in the Desktop manifest. The npm package is verified before Desktop artifacts become visible, so clean remote setup never depends on an unpublished Runtime Host version.
diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml
index 8e9fc57db4..fb03a3b485 100644
--- a/.github/workflows/desktop-nightly.yml
+++ b/.github/workflows/desktop-nightly.yml
@@ -184,14 +184,14 @@ jobs:
"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 \
+ apps/desktop/release/dev-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 \
+ apps/desktop/release/dev.yml \
"$STAGE_DIRECTORY/"
fi
@@ -212,7 +212,7 @@ jobs:
permissions:
artifact-metadata: write
attestations: write
- contents: read
+ contents: write
id-token: write
steps:
- name: Reject in-place workflow reruns
@@ -243,29 +243,26 @@ jobs:
path: ${{ github.workspace }}/.nightly-input
merge-multiple: true
- - name: Stage the versioned Nightly site
+ - name: Stage the exact GitHub Release assets
env:
NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
- SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
run: |
node scripts/desktop-nightly.mjs stage \
"$GITHUB_WORKSPACE/.nightly-input" \
- "$GITHUB_WORKSPACE/.nightly-publish" \
- "$NIGHTLY_VERSION" \
- "$SOURCE_COMMIT"
+ "$GITHUB_WORKSPACE/.nightly-stage" \
+ "$NIGHTLY_VERSION"
- - name: Attest the exact Nightly payloads
+ - name: Attest every GitHub Nightly asset subject
id: attest
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
- subject-path: ${{ github.workspace }}/.nightly-publish/versions/${{ needs.identity.outputs.version }}/*
+ subject-path: ${{ github.workspace }}/.nightly-stage/release/*
- 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
@@ -275,70 +272,51 @@ jobs:
--cert-identity "$CERTIFICATE_IDENTITY" \
--cert-oidc-issuer https://token.actions.githubusercontent.com
verified=$((verified + 1))
- 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
+ done < <(find "$GITHUB_WORKSPACE/.nightly-stage/release" -maxdepth 1 -type f -print0)
+ if (( verified != 8 )); then
+ echo "Expected 8 verified Desktop Nightly subjects, found $verified" >&2
exit 1
fi
- - name: Add the offline provenance bundle
+ - name: Add the one offline provenance bundle
env:
ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }}
NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
- run: >-
- cp -- "$ATTESTATION_BUNDLE"
- "$GITHUB_WORKSPACE/.nightly-publish/versions/$NIGHTLY_VERSION/Maka-$NIGHTLY_VERSION-attestation.sigstore.json"
+ run: |
+ node scripts/desktop-nightly.mjs add-attestation \
+ "$GITHUB_WORKSPACE/.nightly-stage" \
+ "$NIGHTLY_VERSION" \
+ "$ATTESTATION_BUNDLE"
- - name: Prepare authenticated Nightlies SSH transport
+ - name: Ensure the exact versioned Nightly tag
env:
- NIGHTLIES_RSYNC_HOST: ${{ secrets.NIGHTLIES_RSYNC_HOST }}
- NIGHTLIES_RSYNC_KEY: ${{ secrets.NIGHTLIES_RSYNC_KEY }}
- 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_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"
- {
- echo "NIGHTLIES_RSYNC_BASE=$NIGHTLIES_RSYNC_USER@$NIGHTLIES_RSYNC_HOST:${NIGHTLIES_RSYNC_PATH%/}"
- 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=no -o UserKnownHostsFile=/dev/null -p $NIGHTLIES_RSYNC_PORT"
- } >> "$GITHUB_ENV"
-
- - name: Ensure the Nightly destination exists
+ GH_TOKEN: ${{ github.token }}
+ NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
+ SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
run: |
- mkdir -p .nightly-empty/maka/desktop
- rsync -rlptDz --protect-args .nightly-empty/maka/ "$NIGHTLIES_RSYNC_BASE/maka/"
+ gh auth setup-git
+ node scripts/product-release-tag.mjs ensure "v$NIGHTLY_VERSION" "$SOURCE_COMMIT"
- - name: Require the Desktop Nightly feed to advance
+ - name: Prepare and verify the draft GitHub Prerelease
env:
+ GH_TOKEN: ${{ github.token }}
NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
+ SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
run: |
- mkdir -p .nightly-current-feed
- rsync -rlptDz --protect-args \
- --include='/latest-mac.yml' \
- --include='/latest.yml' \
- --exclude='*' \
- "$NIGHTLIES_RSYNC_TARGET/" \
- .nightly-current-feed/
- node scripts/desktop-nightly.mjs assert-feed-advance \
- .nightly-current-feed \
- "$NIGHTLY_VERSION"
-
- - name: Publish immutable Nightly payloads
- run: rsync -rlptDvz --protect-args .nightly-publish/versions/ "$NIGHTLIES_RSYNC_TARGET/versions/"
-
- - name: Advance the Nightly update feed last
- run: rsync -rlptDvz --protect-args .nightly-publish/feed/ "$NIGHTLIES_RSYNC_TARGET/"
+ node scripts/desktop-nightly-release.mjs prepare \
+ "$GITHUB_WORKSPACE/.nightly-stage/release" \
+ "$NIGHTLY_VERSION" \
+ "$SOURCE_COMMIT" \
+ "$GITHUB_REPOSITORY"
- - name: Remove the temporary Nightlies credentials
- if: always()
- run: rm -f "$RUNNER_TEMP/nightlies-ssh/key"
+ - name: Publish the complete GitHub Prerelease
+ env:
+ GH_TOKEN: ${{ github.token }}
+ NIGHTLY_VERSION: ${{ needs.identity.outputs.version }}
+ SOURCE_COMMIT: ${{ needs.identity.outputs.source_commit }}
+ run: |
+ node scripts/desktop-nightly-release.mjs publish \
+ "$GITHUB_WORKSPACE/.nightly-stage/release" \
+ "$NIGHTLY_VERSION" \
+ "$SOURCE_COMMIT" \
+ "$GITHUB_REPOSITORY"
diff --git a/README.md b/README.md
index 4f6cc46034..d8533b7b17 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@
- 
+ 
Daily builds from main for developers and testers. Not an ASF release, not intended for production use.
@@ -101,7 +101,7 @@ Apache Maka has not made an Apache release yet. Everything currently published f
Once Apache releases exist, the official release is the source release published by the ASF and approved by the podling PPMC and the Incubator PMC. A package built from that source and distributed elsewhere, for example through a package registry or as a Desktop installer, is a convenience artifact rather than the release itself, and it is valid only when it is built from an approved source release. [`.github/ASF_SOURCE_RELEASE.md`](./.github/ASF_SOURCE_RELEASE.md) holds the candidate contract, signing path, and verification steps.
-[Desktop Nightly](https://nightlies.apache.org/maka/desktop/) is built daily from `main` for developers and testers. It is not an ASF release and is not intended for production use. Desktop currently targets Apple Silicon Macs (`arm64`). Intel Macs and Linux are not supported yet. [Windows](docs/windows-support.md) is an unsigned preview, not a supported release tier.
+[Desktop Nightly](https://github.com/apache/maka/releases) is built daily from `main` for developers and testers. Choose the newest **Maka Desktop Nightly** prerelease; after installation, the app updates automatically on the Nightly channel. It is not an ASF release and is not intended for production use. Desktop currently targets Apple Silicon Macs (`arm64`). Intel Macs and Linux are not supported yet. [Windows](docs/windows-support.md) is an unsigned preview, not a supported release tier.
### Requirements
diff --git a/README.zh-CN.md b/README.zh-CN.md
index d0a050ebe1..ce2643c0d2 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -39,7 +39,7 @@
- 
+ 
每天从 main 构建,面向开发者和测试者。不是 ASF release,也不适合生产使用。
@@ -99,7 +99,7 @@ Apache Maka 目前还没有发布过 Apache release。当前从本仓库或包
在 Apache release 出现之后,官方 release 指的是由 ASF 发布、并经 podling PPMC 和 Incubator PMC 批准的源码 release。由该源码构建并通过其他渠道分发的包,例如包管理器中的包或 Desktop 安装程序,属于 convenience artifact,本身不是 release,并且只有在由获批源码 release 构建时才有效。候选契约、签名路径和验包步骤见 [`.github/ASF_SOURCE_RELEASE.md`](./.github/ASF_SOURCE_RELEASE.md)。
-[Desktop Nightly](https://nightlies.apache.org/maka/desktop/) 面向开发者和测试者,每天从 `main` 构建。它不是 ASF release,不适合生产使用。Desktop 目前面向 Apple Silicon Mac(`arm64`)。暂不支持 Intel Mac 和 Linux。[Windows](docs/windows-support.md) 是未签名预览,不是正式支持的发布层级。
+[Desktop Nightly](https://github.com/apache/maka/releases) 面向开发者和测试者,每天从 `main` 构建。请选择最新的 **Maka Desktop Nightly** prerelease;安装后,应用会在 Nightly 渠道自动更新。它不是 ASF release,不适合生产使用。Desktop 目前面向 Apple Silicon Mac(`arm64`)。暂不支持 Intel Mac 和 Linux。[Windows](docs/windows-support.md) 是未签名预览,不是正式支持的发布层级。
### 环境要求
diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs
index 42ac6f2ebf..269d57267a 100644
--- a/apps/desktop/electron-builder.config.mjs
+++ b/apps/desktop/electron-builder.config.mjs
@@ -20,7 +20,6 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
- DESKTOP_NIGHTLY_FEED_URL,
resolveDesktopBuildVersion,
resolveRuntimeHostSetupPackage,
} from '../../scripts/desktop-nightly.mjs';
@@ -287,7 +286,7 @@ export function resolveDesktopBuilderConfig(environment = process.env) {
runtimeHostSetupPackage: resolveRuntimeHostSetupPackage(rootManifest.version, environment),
makaUpdateChannel: 'nightly',
},
- publish: [{ provider: 'generic', url: DESKTOP_NIGHTLY_FEED_URL, channel: 'latest' }],
+ publish: [{ provider: 'github', owner: 'apache', repo: 'maka', channel: 'dev' }],
};
}
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 0cf9e7c523..c4b69c7753 100644
--- a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts
+++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts
@@ -108,7 +108,7 @@ test('download verification accepts only a trusted exact artifact subject', asyn
);
});
-test('nightly verification fetches provenance from the versioned Nightlies path', async (t) => {
+test('nightly verification fetches provenance from the versioned GitHub Release asset', 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');
@@ -151,7 +151,7 @@ test('nightly verification fetches provenance from the versioned Nightlies path'
assert.equal(
fetchedUrl,
- `https://nightlies.apache.org/maka/desktop/versions/${version}/Maka-${version}-attestation.sigstore.json`,
+ `https://github.com/apache/maka/releases/download/v${version}/Maka-${version}-attestation.sigstore.json`,
);
});
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 bae3519c73..5f1d3f1686 100644
--- a/apps/desktop/src/main/__tests__/app-update-service.test.ts
+++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts
@@ -187,6 +187,7 @@ describe('AppUpdateService', () => {
assert.equal(releaseUpdater.allowPrerelease, false);
assert.equal(nightlyUpdater.allowPrerelease, true);
+ assert.equal(Object.hasOwn(nightlyUpdater, 'channel'), false);
});
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 d92c3f05de..1a7aa9e67a 100644
--- a/apps/desktop/src/main/app-update-attestation.ts
+++ b/apps/desktop/src/main/app-update-attestation.ts
@@ -26,7 +26,6 @@ import { createReadStream } from 'node:fs';
const PRODUCT_REPOSITORY = 'apache/maka';
const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml';
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';
@@ -102,12 +101,9 @@ function productReleaseAttestationName(version: string): string {
function productReleaseAttestationUrl(
version: string,
- channel: DesktopUpdateChannel,
+ _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)}`;
}
diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md
index 1f5ee2b048..0b5fc3f60d 100644
--- a/docs/cli-npm-release.md
+++ b/docs/cli-npm-release.md
@@ -131,14 +131,13 @@ The two workflows publish in this order:
1. require the candidate npm run number to be newer than the current `nightly` tag;
2. publish the exact npm tarball with provenance under `nightly`;
3. require both the exact version and `nightly` tag to be readable from the public registry;
-4. require the candidate Desktop run number to be newer than every existing platform feed;
-5. append the immutable Desktop payloads to `nightlies.apache.org`;
-6. advance the mutable Desktop update feed last.
+4. build, verify, and attest the exact Desktop packages and GitHub `dev` metadata;
+5. bind a protected `v` tag to the exact source commit and verify all nine draft assets;
+6. publish the GitHub prerelease with Latest disabled only after the draft is complete.
This ordering prevents Desktop from advertising a Runtime Host version that npm does not have and
-lets npm Nightly operate before Desktop's Infra transport is enabled. A failed npm or Desktop run is
-never rerun in place because npm versions and the Nightlies version directory are immutable; start
-a fresh npm Nightly run instead:
+keeps npm Nightly independent from Desktop packaging. A failed npm or Desktop run is never rerun in
+place because each attempt has an immutable npm version; start a fresh npm Nightly run instead:
```sh
gh workflow run npm-publication.yml --ref main -f channel=nightly
diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md
index 06f9bef79d..989a59f04d 100644
--- a/docs/cli-npm-release.zh-CN.md
+++ b/docs/cli-npm-release.zh-CN.md
@@ -116,12 +116,12 @@ authentication and disallow tokens**,然后撤销不再使用的 publish token
1. 要求候选 npm run number 大于当前 `nightly` tag;
2. 使用 provenance 将精确 npm tarball 发布到 `nightly`;
3. 要求公共 registry 中的精确版本和 `nightly` tag 都已可读;
-4. 要求候选 Desktop run number 大于所有现有平台 feed;
-5. 向 `nightlies.apache.org` 追加 immutable Desktop payload;
-6. 最后推进可变的 Desktop update feed。
+4. 构建、验证并 attest 精确的 Desktop 安装包和 GitHub `dev` metadata;
+5. 将受保护的 `v` tag 绑定到精确 source commit,并验证 Draft 中全部九项资产;
+6. 仅在 Draft 完整后发布 Latest 关闭的 GitHub prerelease。
-这个顺序既避免 Desktop 指向 npm 中不存在的 Runtime Host,也允许 npm Nightly 在 Desktop Infra
-就绪前独立运行。npm 或 Desktop run 失败后都不得原地 rerun;应启动新的 npm Nightly:
+这个顺序既避免 Desktop 指向 npm 中不存在的 Runtime Host,也让 npm Nightly 与 Desktop 打包彼此
+独立。npm 或 Desktop run 失败后都不得原地 rerun;应启动新的 npm Nightly:
```sh
gh workflow run npm-publication.yml --ref main -f channel=nightly
diff --git a/package.json b/package.json
index 762424ce31..f16041e72c 100644
--- a/package.json
+++ b/package.json
@@ -74,7 +74,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-nightly.test.mjs 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",
+ "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-nightly.test.mjs scripts/desktop-nightly.test.mjs scripts/desktop-nightly-stage.test.mjs scripts/desktop-nightly-release.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-release.mjs b/scripts/desktop-nightly-release.mjs
new file mode 100644
index 0000000000..0b9407c341
--- /dev/null
+++ b/scripts/desktop-nightly-release.mjs
@@ -0,0 +1,286 @@
+/*
+ * 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 { execFile } from 'node:child_process';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+import { promisify } from 'node:util';
+import {
+ compareProductReleaseNames,
+ productReleaseArtifactRecords,
+ verifyProductReleaseArtifactDirectory,
+} from './product-release-artifacts.mjs';
+import { remoteProductTagCommit } from './product-release-tag.mjs';
+import { desktopNightlyReleaseAssetNames } from './desktop-nightly.mjs';
+import { parseProductNightlyVersion } from './release-version.mjs';
+
+const execFileAsync = promisify(execFile);
+
+function validateIdentity(version, sourceCommit, repository) {
+ parseProductNightlyVersion(version);
+ if (!/^[0-9a-f]{40}$/u.test(sourceCommit)) {
+ throw new Error('Desktop Nightly source must be an exact commit SHA');
+ }
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) {
+ throw new Error('Desktop Nightly repository must be an exact owner/name');
+ }
+ return { tag: `v${version}`, version };
+}
+
+function notes(version, sourceCommit) {
+ return `Developer Snapshot ${version}
+
+This Desktop Nightly was built from ${sourceCommit} for development and testing. It is not an Apache Release and has not been approved by an ASF release vote.
+
+The packaged applications carry the repository DISCLAIMER-WIP and Apache License 2.0 materials. They may be unstable and are not intended as a stable release for general users.`;
+}
+
+function snapshotFromView(value) {
+ return {
+ id: value?.databaseId,
+ tag: value?.tagName,
+ draft: value?.isDraft,
+ prerelease: value?.isPrerelease,
+ assets: (value?.assets ?? [])
+ .map(({ name, size, digest }) => ({ name, size, digest }))
+ .sort((left, right) => compareProductReleaseNames(left.name, right.name)),
+ };
+}
+
+function snapshotFromRest(value) {
+ return {
+ id: value?.id,
+ tag: value?.tag_name,
+ draft: value?.draft,
+ prerelease: value?.prerelease,
+ assets: (value?.assets ?? [])
+ .map(({ name, size, digest }) => ({ name, size, digest }))
+ .sort((left, right) => compareProductReleaseNames(left.name, right.name)),
+ };
+}
+
+function assertReleaseState(release, { tag, draft }) {
+ if (!Number.isSafeInteger(release?.id) || release.id < 1 || release.tag !== tag) {
+ throw new Error(`GitHub Release does not identify Desktop Nightly ${tag}`);
+ }
+ if (release.draft !== draft || release.prerelease !== true) {
+ throw new Error(`GitHub Release ${tag} must be a ${draft ? 'Draft ' : ''}Prerelease`);
+ }
+ return release;
+}
+
+function assertExactAssets(release, expected) {
+ if (JSON.stringify(release.assets) !== JSON.stringify(expected)) {
+ throw new Error(`GitHub Desktop Nightly ${release.tag} assets do not match local bytes`);
+ }
+ return release;
+}
+
+function assertAssetSubset(release, expected) {
+ const records = new Map(expected.map((record) => [record.name, record]));
+ for (const asset of release.assets) {
+ if (JSON.stringify(records.get(asset.name)) !== JSON.stringify(asset)) {
+ throw new Error(
+ `Draft GitHub Desktop Nightly contains unexpected or changed asset ${asset.name}`,
+ );
+ }
+ }
+}
+
+async function viewRelease({ repository, tag, cwd, run }) {
+ const result = await run(
+ 'gh',
+ [
+ 'release',
+ 'view',
+ tag,
+ '--repo',
+ repository,
+ '--json',
+ 'databaseId,tagName,isDraft,isPrerelease,assets',
+ ],
+ { cwd },
+ );
+ try {
+ return snapshotFromView(JSON.parse(result.stdout));
+ } catch (error) {
+ throw new Error(`GitHub returned an invalid Desktop Nightly record for ${tag}`, {
+ cause: error,
+ });
+ }
+}
+
+function isMissingRelease(error) {
+ return /release not found|HTTP 404/u.test(`${error?.stderr ?? ''}\n${error?.message ?? ''}`);
+}
+
+async function localRelease({ artifactDirectory, version }) {
+ const names = desktopNightlyReleaseAssetNames(version);
+ await verifyProductReleaseArtifactDirectory(artifactDirectory, names);
+ return productReleaseArtifactRecords(artifactDirectory, names);
+}
+
+async function verifyTag({ cwd, repository, run, sourceCommit, tag }) {
+ const remote = await remoteProductTagCommit({ cwd, remote: 'origin', tag, run });
+ if (remote !== sourceCommit) {
+ throw new Error(
+ `Desktop Nightly tag ${tag} points to ${remote ?? 'nothing'} instead of ${sourceCommit}`,
+ );
+ }
+ return repository;
+}
+
+export async function prepareDesktopNightlyRelease({
+ artifactDirectory,
+ repository,
+ sourceCommit,
+ version,
+ cwd = process.cwd(),
+ run = execFileAsync,
+}) {
+ const { tag } = validateIdentity(version, sourceCommit, repository);
+ const expected = await localRelease({ artifactDirectory, version });
+ await verifyTag({ cwd, repository, run, sourceCommit, tag });
+ let release;
+ try {
+ release = await viewRelease({ repository, tag, cwd, run });
+ } catch (error) {
+ if (!isMissingRelease(error)) throw error;
+ await run(
+ 'gh',
+ [
+ 'release',
+ 'create',
+ tag,
+ '--repo',
+ repository,
+ '--draft',
+ '--verify-tag',
+ '--prerelease',
+ '--latest=false',
+ '--title',
+ `Maka Desktop Nightly ${version}`,
+ '--notes',
+ notes(version, sourceCommit),
+ ],
+ { cwd },
+ );
+ release = await viewRelease({ repository, tag, cwd, run });
+ }
+ assertReleaseState(release, { tag, draft: true });
+ assertAssetSubset(release, expected);
+ await run(
+ 'gh',
+ [
+ 'release',
+ 'edit',
+ tag,
+ '--repo',
+ repository,
+ '--draft',
+ '--prerelease',
+ '--latest=false',
+ '--title',
+ `Maka Desktop Nightly ${version}`,
+ '--notes',
+ notes(version, sourceCommit),
+ ],
+ { cwd },
+ );
+ const existing = new Set(release.assets.map(({ name }) => name));
+ const missing = expected.filter(({ name }) => !existing.has(name));
+ if (missing.length > 0) {
+ await run(
+ 'gh',
+ [
+ 'release',
+ 'upload',
+ tag,
+ ...missing.map(({ name }) => join(artifactDirectory, name)),
+ '--repo',
+ repository,
+ ],
+ { cwd },
+ );
+ }
+ const complete = await viewRelease({ repository, tag, cwd, run });
+ assertReleaseState(complete, { tag, draft: true });
+ return assertExactAssets(complete, expected);
+}
+
+export async function publishDesktopNightlyRelease({
+ artifactDirectory,
+ repository,
+ sourceCommit,
+ version,
+ cwd = process.cwd(),
+ run = execFileAsync,
+}) {
+ const { tag } = validateIdentity(version, sourceCommit, repository);
+ const expected = await localRelease({ artifactDirectory, version });
+ await verifyTag({ cwd, repository, run, sourceCommit, tag });
+ const draft = assertExactAssets(
+ assertReleaseState(await viewRelease({ repository, tag, cwd, run }), { tag, draft: true }),
+ expected,
+ );
+ const response = await run(
+ 'gh',
+ [
+ 'api',
+ '--method',
+ 'PATCH',
+ `repos/${repository}/releases/${draft.id}`,
+ '-F',
+ 'draft=false',
+ '-F',
+ 'prerelease=true',
+ '-f',
+ 'make_latest=false',
+ ],
+ { cwd },
+ );
+ let published;
+ try {
+ published = snapshotFromRest(JSON.parse(response.stdout));
+ } catch (error) {
+ throw new Error(`GitHub returned an invalid Desktop Nightly publication for ${tag}`, {
+ cause: error,
+ });
+ }
+ assertExactAssets(assertReleaseState(published, { tag, draft: false }), expected);
+ assertExactAssets(
+ assertReleaseState(await viewRelease({ repository, tag, cwd, run }), { tag, draft: false }),
+ expected,
+ );
+ return published;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ const [command, artifactDirectory, version, sourceCommit, repository] = process.argv.slice(2);
+ const input = { artifactDirectory, version, sourceCommit, repository };
+ if (command === 'prepare' && artifactDirectory && version && sourceCommit && repository) {
+ await prepareDesktopNightlyRelease(input);
+ } else if (command === 'publish' && artifactDirectory && version && sourceCommit && repository) {
+ await publishDesktopNightlyRelease(input);
+ } else {
+ throw new Error(
+ 'usage: desktop-nightly-release.mjs ',
+ );
+ }
+}
diff --git a/scripts/desktop-nightly-release.test.mjs b/scripts/desktop-nightly-release.test.mjs
new file mode 100644
index 0000000000..00d0ce8e49
--- /dev/null
+++ b/scripts/desktop-nightly-release.test.mjs
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
+import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { basename, join } from 'node:path';
+import test from 'node:test';
+import {
+ prepareDesktopNightlyRelease,
+ publishDesktopNightlyRelease,
+} from './desktop-nightly-release.mjs';
+import {
+ addDesktopNightlyAttestation,
+ desktopNightlyReleaseAssetNames,
+ stageDesktopNightly,
+} from './desktop-nightly.mjs';
+import { productReleaseArtifactRecords } from './product-release-artifacts.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 ? 'dev-mac.yml' : 'dev.yml';
+ const bytes = Buffer.from(`${platform} nightly bytes`);
+ const sha512 = createHash('sha512').update(bytes).digest('base64');
+ await Promise.all([
+ writeFile(join(directory, artifact), bytes),
+ writeFile(join(directory, `${artifact}.blockmap`), `${platform} blockmap`),
+ writeFile(
+ join(directory, metadata),
+ `version: ${version}\nfiles:\n - url: ${artifact}\n sha512: ${sha512}\n size: ${bytes.length}\npath: ${artifact}\nsha512: ${sha512}\n`,
+ ),
+ ]);
+}
+
+async function stageRelease(root, version) {
+ const input = join(root, 'input');
+ const output = join(root, 'output');
+ 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,
+ });
+ const bundle = join(root, 'bundle.json');
+ await writeFile(bundle, 'sigstore bundle');
+ await addDesktopNightlyAttestation({ outputDirectory: output, version, bundlePath: bundle });
+ return join(output, 'release');
+}
+
+test('Nightly publication verifies the exact draft before one Prerelease/non-Latest mutation', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-nightly-release-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const version = '0.2.0-dev.42.20260829';
+ const tag = `v${version}`;
+ const sourceCommit = 'a'.repeat(40);
+ const directory = await stageRelease(root, version);
+ const records = await productReleaseArtifactRecords(
+ directory,
+ desktopNightlyReleaseAssetNames(version),
+ );
+ const calls = [];
+ let remoteAssets = records.slice(0, 1);
+ let draft = true;
+ let tampered = false;
+ const release = () => ({
+ databaseId: 42,
+ tagName: tag,
+ isDraft: draft,
+ isPrerelease: true,
+ assets: remoteAssets.map((record, index) =>
+ tampered && index === 0 ? { ...record, digest: `sha256:${'0'.repeat(64)}` } : record,
+ ),
+ });
+ const run = async (command, args) => {
+ calls.push([command, args]);
+ if (command === 'git') return { stdout: `${sourceCommit}\trefs/tags/${tag}\n` };
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'upload') {
+ remoteAssets = records;
+ return { stdout: '' };
+ }
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'edit') return { stdout: '' };
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'view') {
+ return { stdout: JSON.stringify(release()) };
+ }
+ if (command === 'gh' && args.includes('PATCH')) {
+ draft = false;
+ return {
+ stdout: JSON.stringify({
+ id: 42,
+ tag_name: tag,
+ draft: false,
+ prerelease: true,
+ assets: records,
+ }),
+ };
+ }
+ throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
+ };
+
+ await prepareDesktopNightlyRelease({
+ artifactDirectory: directory,
+ repository: 'apache/maka',
+ run,
+ sourceCommit,
+ version,
+ });
+ tampered = true;
+ await assert.rejects(
+ publishDesktopNightlyRelease({
+ artifactDirectory: directory,
+ repository: 'apache/maka',
+ run,
+ sourceCommit,
+ version,
+ }),
+ /assets do not match local bytes/u,
+ );
+ assert.equal(draft, true);
+ assert.equal(
+ calls.some(([, args]) => args.includes('PATCH')),
+ false,
+ );
+ tampered = false;
+ await publishDesktopNightlyRelease({
+ artifactDirectory: directory,
+ repository: 'apache/maka',
+ run,
+ sourceCommit,
+ version,
+ });
+
+ const upload = calls.find(([, args]) => args[0] === 'release' && args[1] === 'upload');
+ const patchCall = calls.find(([, args]) => args.includes('PATCH'));
+ assert.ok(upload);
+ assert.deepEqual(
+ upload[1].slice(3, upload[1].indexOf('--repo')).map((path) => basename(path)),
+ records.slice(1).map(({ name }) => name),
+ );
+ assert.ok(patchCall);
+ assert.ok(calls.indexOf(upload) < calls.indexOf(patchCall));
+ assert.ok(patchCall[1].includes('draft=false'));
+ assert.ok(patchCall[1].includes('prerelease=true'));
+ assert.ok(patchCall[1].includes('make_latest=false'));
+ const edit = calls.find(([, args]) => args[0] === 'release' && args[1] === 'edit');
+ assert.match(edit[1].at(-1), /Developer Snapshot/u);
+ assert.match(edit[1].at(-1), /not an Apache Release/u);
+ assert.match(edit[1].at(-1), /DISCLAIMER-WIP/u);
+ assert.match(edit[1].at(-1), /Apache License 2\.0/u);
+});
+
+test('a missing Nightly release is created only as a draft prerelease with Latest disabled', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-nightly-create-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const version = '0.2.0-dev.42.20260829';
+ const tag = `v${version}`;
+ const sourceCommit = 'a'.repeat(40);
+ const directory = await stageRelease(root, version);
+ const records = await productReleaseArtifactRecords(
+ directory,
+ desktopNightlyReleaseAssetNames(version),
+ );
+ const calls = [];
+ let created = false;
+ let uploaded = false;
+ const run = async (command, args) => {
+ calls.push([command, args]);
+ if (command === 'git') return { stdout: `${sourceCommit}\trefs/tags/${tag}\n` };
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'view') {
+ if (!created) {
+ const error = new Error('release not found');
+ error.stderr = 'release not found';
+ throw error;
+ }
+ return {
+ stdout: JSON.stringify({
+ databaseId: 42,
+ tagName: tag,
+ isDraft: true,
+ isPrerelease: true,
+ assets: uploaded ? records : [],
+ }),
+ };
+ }
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'create') {
+ created = true;
+ return { stdout: '' };
+ }
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'edit') return { stdout: '' };
+ if (command === 'gh' && args[0] === 'release' && args[1] === 'upload') {
+ uploaded = true;
+ return { stdout: '' };
+ }
+ throw new Error(`unexpected command: ${command} ${args.join(' ')}`);
+ };
+
+ await prepareDesktopNightlyRelease({
+ artifactDirectory: directory,
+ repository: 'apache/maka',
+ run,
+ sourceCommit,
+ version,
+ });
+
+ const create = calls.find(([, args]) => args[0] === 'release' && args[1] === 'create');
+ for (const flag of ['--draft', '--verify-tag', '--prerelease', '--latest=false']) {
+ assert.ok(create[1].includes(flag), flag);
+ }
+});
diff --git a/scripts/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs
index 879e34a5e3..c8a23a5388 100644
--- a/scripts/desktop-nightly-stage.test.mjs
+++ b/scripts/desktop-nightly-stage.test.mjs
@@ -24,13 +24,13 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { stringify } from 'yaml';
-import { assertDesktopNightlyFeedAdvance, stageDesktopNightly } from './desktop-nightly.mjs';
+import { addDesktopNightlyAttestation, stageDesktopNightly } from './desktop-nightly.mjs';
import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.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 metadata = isMac ? 'dev-mac.yml' : 'dev.yml';
const bytes = Buffer.from(`${platform} nightly bytes`);
const sha512 = createHash('sha512').update(bytes).digest('base64');
await writeFile(join(directory, artifact), bytes);
@@ -47,7 +47,7 @@ async function writeUpdateSet(directory, version, platform) {
);
}
-test('staging separates append-only payloads from the mutable Nightly feed', async (t) => {
+test('staging creates only the exact GitHub Release assets', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-'));
t.after(() => rm(root, { recursive: true, force: true }));
const input = join(root, 'input');
@@ -65,7 +65,6 @@ test('staging separates append-only payloads from the mutable Nightly feed', asy
inputDirectory: input,
outputDirectory: output,
version,
- sourceCommit: 'a'.repeat(40),
});
const payloadNames = [
@@ -76,69 +75,45 @@ test('staging separates append-only payloads from the mutable Nightly feed', asy
`Maka-${version}-win-x64.exe.blockmap`,
`Maka-${version}-win-x64.zip`,
];
+ const release = join(output, 'release');
for (const name of payloadNames) {
- assert.deepEqual(
- await readFile(join(output, 'versions', version, name)),
- await readFile(join(input, name)),
- name,
- );
+ assert.deepEqual(await readFile(join(release, name)), await readFile(join(input, name)), name);
}
await Promise.all([
verifyDesktopUpdateArtifacts({
- directory: output,
- metadataName: 'feed/latest-mac.yml',
+ directory: release,
+ metadataName: 'dev-mac.yml',
version,
- artifactName: `versions/${version}/Maka-${version}-mac-arm64.zip`,
+ artifactName: `Maka-${version}-mac-arm64.zip`,
}),
verifyDesktopUpdateArtifacts({
- directory: output,
- metadataName: 'feed/latest.yml',
+ directory: release,
+ metadataName: 'dev.yml',
version,
- artifactName: `versions/${version}/Maka-${version}-win-x64.exe`,
+ artifactName: `Maka-${version}-win-x64.exe`,
}),
]);
- const macMetadata = (await import('yaml')).parse(
- await readFile(join(output, 'feed', 'latest-mac.yml'), 'utf8'),
+ assert.deepEqual(
+ (await readdir(release)).sort(),
+ [...payloadNames, 'dev-mac.yml', 'dev.yml'].sort(),
);
- const windowsMetadata = (await import('yaml')).parse(
- 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`);
- 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'));
- assert.deepEqual((await readdir(join(output, 'feed'))).sort(), [
- 'index.html',
- 'latest-mac.yml',
- 'latest.yml',
- ]);
- assert.deepEqual((await readdir(join(output, 'versions', version))).sort(), payloadNames);
+ assert.deepEqual(await readdir(output), ['release']);
});
-test('the Desktop feed advances only to a newer npm run number', async (t) => {
- const directory = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-feed-'));
- t.after(() => rm(directory, { recursive: true, force: true }));
- await Promise.all([
- writeFile(join(directory, 'latest-mac.yml'), 'version: 0.2.0-dev.42.20260829\n'),
- writeFile(join(directory, 'latest.yml'), 'version: 0.2.0-dev.42.20260829\n'),
- ]);
- await assert.doesNotReject(
- assertDesktopNightlyFeedAdvance({
- directory,
- candidateVersion: '0.3.0-dev.43.20260828',
- productVersion: '0.3.0',
- }),
- );
- await assert.rejects(
- assertDesktopNightlyFeedAdvance({
- directory,
- candidateVersion: '0.2.0-dev.41.20260830',
- productVersion: '0.2.0',
- }),
- /does not advance current run/u,
- );
+test('one attestation bundle is staged only as a GitHub Release asset', async (t) => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-desktop-nightly-attestation-'));
+ t.after(() => rm(root, { recursive: true, force: true }));
+ const output = join(root, 'output');
+ const release = join(output, 'release');
+ const version = '0.2.0-dev.42.20260829';
+ const bundle = join(root, 'bundle.json');
+ const bytes = Buffer.from('one offline Sigstore bundle');
+ await Promise.all([mkdir(release, { recursive: true }), writeFile(bundle, bytes)]);
+
+ await addDesktopNightlyAttestation({ outputDirectory: output, version, bundlePath: bundle });
+
+ const name = `Maka-${version}-attestation.sigstore.json`;
+ assert.deepEqual(await readFile(join(release, name)), bytes);
+ assert.deepEqual(await readdir(output), ['release']);
});
diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs
index 5dac5578c0..b604549cb9 100644
--- a/scripts/desktop-nightly-workflow-policy.test.mjs
+++ b/scripts/desktop-nightly-workflow-policy.test.mjs
@@ -107,80 +107,50 @@ test('a failed Desktop Nightly is retried through a fresh npm Nightly', async ()
assert.equal(download.with.pattern, 'desktop-nightly-*');
});
-test('the first Desktop Nightly creates its destination with rsync 3.1-compatible flags', async () => {
+test('Desktop Nightly packages the GitHub dev feeds and grants write only to its publisher', async () => {
const workflow = await readWorkflow('desktop-nightly.yml');
- const steps = workflow.jobs.publish.steps;
- const transport = steps.find(
- (step) => step.name === 'Prepare authenticated Nightlies SSH transport',
- );
- const bootstrap = steps.find((step) => step.name === 'Ensure the Nightly destination exists');
- const feedGuardPosition = steps.findIndex(
- (step) => step.name === 'Require the Desktop Nightly feed to advance',
+ assert.deepEqual(workflow.permissions, { actions: 'read', contents: 'read' });
+ assert.equal(workflow.jobs.publish.permissions.contents, 'write');
+ assert.equal(workflow.jobs.desktop.permissions, undefined);
+ const stage = workflow.jobs.desktop.steps.find(
+ (step) => step.name === 'Stage the exact Nightly artifacts',
);
-
- assert.ok(bootstrap);
- assert.match(transport.run, /NIGHTLIES_RSYNC_BASE=/u);
- assert.match(bootstrap.run, /mkdir -p \.nightly-empty\/maka\/desktop/u);
- assert.match(bootstrap.run, /^rsync -rlptDz --protect-args /mu);
- assert.doesNotMatch(bootstrap.run, /--mkpath/u);
- assert.match(bootstrap.run, /\.nightly-empty\/maka\/ "\$NIGHTLIES_RSYNC_BASE\/maka\/"/u);
- assert.doesNotMatch(bootstrap.run, /\.nightly-publish/u);
- assert.ok(steps.indexOf(bootstrap) < feedGuardPosition);
+ assert.match(stage.run, /apps\/desktop\/release\/dev-mac\.yml/u);
+ assert.match(stage.run, /apps\/desktop\/release\/dev\.yml/u);
+ assert.doesNotMatch(stage.run, /latest-mac\.yml|latest\.yml/u);
});
-test('the protected Desktop publisher appends payloads before advancing the feed', async () => {
+test('the publisher verifies exact GitHub identity and assets before publishing last', async () => {
const workflow = await readWorkflow('desktop-nightly.yml');
- const publish = workflow.jobs.publish;
- assert.equal(workflow.jobs.desktop.environment, 'nightly');
- 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.deepEqual(Object.keys(transport.env).toSorted(), [
- 'NIGHTLIES_RSYNC_HOST',
- 'NIGHTLIES_RSYNC_KEY',
- 'NIGHTLIES_RSYNC_PATH',
- 'NIGHTLIES_RSYNC_PORT',
- 'NIGHTLIES_RSYNC_USER',
- ]);
- assert.match(transport.run, /StrictHostKeyChecking=no/u);
- assert.match(transport.run, /UserKnownHostsFile=\/dev\/null/u);
- assert.doesNotMatch(transport.run, /ssh-keyscan|KNOWN_HOSTS/u);
- const steps = publish.steps;
+ const steps = workflow.jobs.publish.steps;
const positions = [
- 'Attest the exact Nightly payloads',
+ 'Attest every GitHub Nightly asset subject',
'Verify the issued Nightly provenance',
- 'Require the Desktop Nightly feed to advance',
- 'Publish immutable Nightly payloads',
- 'Advance the Nightly update feed last',
+ 'Add the one offline provenance bundle',
+ 'Ensure the exact versioned Nightly tag',
+ 'Prepare and verify the draft GitHub Prerelease',
+ 'Publish the complete GitHub Prerelease',
].map((name) => steps.findIndex((step) => step.name === name));
+ assert.ok(positions.every((position) => position >= 0));
assert.deepEqual(
positions,
positions.toSorted((left, right) => left - right),
);
- assert.ok(positions.every((position) => position >= 0));
+ assert.match(steps[positions[0]].with['subject-path'], /\.nightly-stage\/release\/\*/u);
+ assert.match(steps[positions[3]].run, /product-release-tag\.mjs ensure/u);
+ assert.match(steps[positions[4]].run, /desktop-nightly-release\.mjs prepare/u);
+ assert.match(steps[positions[5]].run, /desktop-nightly-release\.mjs publish/u);
assert.equal(
steps[positions[1]].env.CERTIFICATE_IDENTITY,
'https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main',
);
- for (const name of [
- 'Ensure the Nightly destination exists',
- 'Publish immutable Nightly payloads',
- 'Advance the Nightly update feed last',
- ]) {
- const step = steps.find((candidate) => candidate.name === name);
- assert.doesNotMatch(step.run, /--delete/u);
- }
- for (const name of [
- 'Publish immutable Nightly payloads',
- 'Advance the Nightly update feed last',
- ]) {
- const step = steps.find((candidate) => candidate.name === name);
- assert.match(step.run, /^rsync -rlptDvz --protect-args /u);
- }
+});
+
+test('Desktop Nightly has no Apache Nightlies transport or compatibility state', async () => {
+ const workflow = await readWorkflow('desktop-nightly.yml');
+ assert.equal(workflow.jobs.publish.environment, 'nightly');
+ assert.doesNotMatch(
+ JSON.stringify(workflow),
+ /nightlies\.apache\.org|NIGHTLIES_RSYNC|resolve-cutover|github-cutover|\brsync\b|\bssh\b/u,
+ );
});
diff --git a/scripts/desktop-nightly.mjs b/scripts/desktop-nightly.mjs
index 927f7fe1f1..2159552142 100644
--- a/scripts/desktop-nightly.mjs
+++ b/scripts/desktop-nightly.mjs
@@ -17,13 +17,12 @@
* under the License.
*/
-import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
+import { copyFile, mkdir, readFile, readdir, rm, stat } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs';
-import { assertProductNightlyAdvances, assertProductNightlyVersion } from './release-version.mjs';
+import { assertProductNightlyVersion } 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) {
@@ -41,27 +40,19 @@ export function resolveRuntimeHostSetupPackage(productVersion, environment = pro
return `maka-agent@${resolveDesktopBuildVersion(productVersion, environment)}`;
}
-export async function assertDesktopNightlyFeedAdvance({
- directory,
- candidateVersion,
- productVersion,
-}) {
- const { parse } = await import('yaml');
- for (const name of ['latest-mac.yml', 'latest.yml']) {
- let source;
- try {
- source = await readFile(join(directory, name), 'utf8');
- } catch (error) {
- if (error?.code === 'ENOENT') continue;
- throw error;
- }
- const currentVersion = parse(source)?.version;
- if (typeof currentVersion !== 'string') {
- throw new Error(`Desktop Nightly feed ${name} has no valid version`);
- }
- assertProductNightlyAdvances(candidateVersion, currentVersion, productVersion);
- }
- return candidateVersion;
+export function desktopNightlyReleaseAssetNames(version) {
+ const names = nightlyArtifactNames(version);
+ return [
+ names.macDmg,
+ names.macZip,
+ `${names.macZip}.blockmap`,
+ names.windowsExe,
+ `${names.windowsExe}.blockmap`,
+ names.windowsZip,
+ `Maka-${version}-attestation.sigstore.json`,
+ 'dev-mac.yml',
+ 'dev.yml',
+ ].sort();
}
function nightlyArtifactNames(version) {
@@ -73,49 +64,9 @@ function nightlyArtifactNames(version) {
};
}
-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}`;
- 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.
-
-
-
-`;
-}
-
-export async function stageDesktopNightly({
- inputDirectory,
- outputDirectory,
- version,
- sourceCommit,
-}) {
+export async function stageDesktopNightly({ inputDirectory, outputDirectory, 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');
- }
const names = nightlyArtifactNames(version);
const payloads = [
names.macDmg,
@@ -125,7 +76,7 @@ export async function stageDesktopNightly({
`${names.windowsExe}.blockmap`,
names.windowsZip,
];
- const metadataNames = ['latest-mac.yml', 'latest.yml'];
+ const metadataNames = ['dev-mac.yml', 'dev.yml'];
const expected = [...payloads, ...metadataNames].sort();
const actual = (await readdir(inputDirectory)).sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
@@ -137,72 +88,61 @@ export async function stageDesktopNightly({
await Promise.all([
verifyDesktopUpdateArtifacts({
directory: inputDirectory,
- metadataName: 'latest-mac.yml',
+ metadataName: 'dev-mac.yml',
version,
artifactName: names.macZip,
}),
verifyDesktopUpdateArtifacts({
directory: inputDirectory,
- metadataName: 'latest.yml',
+ metadataName: 'dev.yml',
version,
artifactName: names.windowsExe,
}),
]);
await rm(outputDirectory, { recursive: true, force: true });
- const versionDirectory = join(outputDirectory, 'versions', version);
- const feedDirectory = join(outputDirectory, 'feed');
- await Promise.all([
- mkdir(versionDirectory, { recursive: true }),
- mkdir(feedDirectory, { recursive: true }),
- ]);
+ const releaseDirectory = join(outputDirectory, 'release');
+ await mkdir(releaseDirectory, { recursive: true });
await Promise.all(
- payloads.map(async (name) => {
+ [...payloads, ...metadataNames].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));
+ await copyFile(source, join(releaseDirectory, name));
}),
);
- await Promise.all([
- rewriteNightlyMetadata(
- join(inputDirectory, 'latest-mac.yml'),
- join(feedDirectory, 'latest-mac.yml'),
- version,
- ),
- rewriteNightlyMetadata(
- join(inputDirectory, 'latest.yml'),
- join(feedDirectory, 'latest.yml'),
- version,
- ),
- ]);
- await writeFile(join(feedDirectory, 'index.html'), nightlyIndex(version, sourceCommit, names));
+}
+
+export async function addDesktopNightlyAttestation({ outputDirectory, version, bundlePath }) {
+ const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
+ assertDesktopNightlyVersion(version, productManifest.version);
+ const details = await stat(bundlePath);
+ if (!details.isFile() || details.size === 0) {
+ throw new Error('Desktop Nightly attestation must be a non-empty regular file');
+ }
+ const name = `Maka-${version}-attestation.sigstore.json`;
+ await copyFile(bundlePath, join(outputDirectory, 'release', name));
+ return name;
}
async function main(args) {
const [command, ...rest] = args;
- if (command === 'stage' && rest.length === 4) {
- const [inputDirectory, outputDirectory, version, sourceCommit] = rest;
+ if (command === 'stage' && rest.length === 3) {
+ const [inputDirectory, outputDirectory, version] = rest;
await stageDesktopNightly({
inputDirectory,
outputDirectory,
version,
- sourceCommit,
});
return;
}
- if (command === 'assert-feed-advance' && rest.length === 2) {
- const [directory, candidateVersion] = rest;
- const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8'));
- await assertDesktopNightlyFeedAdvance({
- directory,
- candidateVersion,
- productVersion: productManifest.version,
- });
+ if (command === 'add-attestation' && rest.length === 3) {
+ const [outputDirectory, version, bundlePath] = rest;
+ await addDesktopNightlyAttestation({ outputDirectory, version, bundlePath });
return;
}
throw new Error(
- 'usage: desktop-nightly.mjs stage | assert-feed-advance ',
+ 'usage: desktop-nightly.mjs stage | add-attestation ',
);
}
diff --git a/scripts/desktop-nightly.test.mjs b/scripts/desktop-nightly.test.mjs
index 52f437fbfc..c65b595756 100644
--- a/scripts/desktop-nightly.test.mjs
+++ b/scripts/desktop-nightly.test.mjs
@@ -19,18 +19,23 @@
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
+import { createRequire } from 'node:module';
import { promisify } from 'node:util';
-import { dirname, join } from 'node:path';
+import { basename, 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 { packageMacosArm64 } from './package-macos-arm64.mjs';
+import { packageWindowsX64 } from './package-windows-x64.mjs';
import { resolveDesktopBuildVersion, resolveRuntimeHostSetupPackage } from './desktop-nightly.mjs';
import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs';
const run = promisify(execFile);
const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)));
+const require = createRequire(import.meta.url);
+const { GitHubProvider } = require('electron-updater/out/providers/GitHubProvider.js');
-test('a nightly package embeds only the Apache Nightlies update authority', () => {
+test('a nightly package embeds only the Apache GitHub dev update authority', () => {
const version = '0.2.0-dev.42.20260829';
const config = resolveDesktopBuilderConfig({
MAKA_DESKTOP_NIGHTLY_VERSION: version,
@@ -40,22 +45,56 @@ test('a nightly package embeds only the Apache Nightlies update authority', () =
assert.equal(config.extraMetadata.runtimeHostSetupPackage, `maka-agent@${version}`);
assert.equal(config.extraMetadata.makaUpdateChannel, 'nightly');
assert.equal(config.publish.length, 1);
- assert.equal(config.publish[0].provider, 'generic');
- assert.equal(config.publish[0].url, 'https://nightlies.apache.org/maka/desktop/');
+ assert.deepEqual(config.publish[0], {
+ provider: 'github',
+ owner: 'apache',
+ repo: 'maka',
+ channel: 'dev',
+ });
});
-test('a dev Nightly identity still advances the latest Desktop feed', () => {
- const config = resolveDesktopBuilderConfig({
- MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.42.20260829',
+test('the macOS Nightly wrapper accepts dev update metadata', async () => {
+ const version = '0.2.0-dev.42.20260829';
+ await packageMacosArm64({
+ platform: 'darwin',
+ arch: 'arm64',
+ env: {
+ MAKA_DESKTOP_NIGHTLY_VERSION: version,
+ CSC_LINK: 'fixture',
+ CSC_KEY_PASSWORD: 'fixture',
+ APPLE_API_KEY: 'fixture',
+ APPLE_API_KEY_ID: 'fixture',
+ APPLE_API_ISSUER: 'fixture',
+ },
+ run: async () => {},
+ remove: async () => {},
+ assertFile: async (path) => {
+ if (path.endsWith('.yml')) assert.equal(basename(path), 'dev-mac.yml');
+ },
});
+});
- assert.equal(config.publish[0].channel, 'latest');
+test('the Windows Nightly wrapper accepts dev update metadata', async () => {
+ const version = '0.2.0-dev.42.20260829';
+ await packageWindowsX64({
+ platform: 'win32',
+ arch: 'x64',
+ env: { MAKA_DESKTOP_NIGHTLY_VERSION: version },
+ run: async () => {},
+ remove: async () => {},
+ makeDirectory: async () => {},
+ copy: async () => {},
+ assertFile: async (path) => {
+ if (path.endsWith('.yml')) assert.equal(basename(path), 'dev.yml');
+ },
+ });
});
-test('a packaged Nightly accepts the pinned latest update channel', async () => {
- const packagedConfiguration = `provider: generic
-url: https://nightlies.apache.org/maka/desktop/
-channel: latest
+test('a packaged Nightly accepts the pinned GitHub dev update channel', async () => {
+ const packagedConfiguration = `provider: github
+owner: apache
+repo: maka
+channel: dev
updaterCacheDirName: '@makadesktop-updater'
`;
@@ -65,6 +104,63 @@ updaterCacheDirName: '@makadesktop-updater'
});
});
+test('the GitHub dev provider resolves each platform payload to its absolute Release asset URL', () => {
+ const version = '0.2.0-dev.42.20260829';
+ for (const { platform, channel, name } of [
+ {
+ platform: 'darwin',
+ channel: 'dev-mac',
+ name: `Maka-${version}-mac-arm64.zip`,
+ },
+ { platform: 'win32', channel: 'dev', name: `Maka-${version}-win-x64.exe` },
+ ]) {
+ const provider = new GitHubProvider(
+ { provider: 'github', owner: 'apache', repo: 'maka', channel: 'dev' },
+ {
+ allowPrerelease: true,
+ channel: undefined,
+ currentVersion: { raw: version },
+ },
+ { executor: {}, platform },
+ );
+ const [resolved] = provider.resolveFiles({
+ tag: `v${version}`,
+ files: [{ url: name, sha512: 'fixture' }],
+ });
+
+ assert.equal(provider.channel, channel);
+ assert.equal(
+ resolved.url.href,
+ `https://github.com/apache/maka/releases/download/v${version}/${name}`,
+ );
+ }
+});
+
+test('GitHub differential updates derive the previous blockmap from the previous versioned tag', () => {
+ const previous = '0.2.0-dev.41.20260828';
+ const current = '0.2.0-dev.42.20260829';
+ const provider = new GitHubProvider(
+ { provider: 'github', owner: 'apache', repo: 'maka', channel: 'dev' },
+ {
+ allowPrerelease: true,
+ channel: undefined,
+ currentVersion: { raw: previous },
+ },
+ { executor: {}, platform: 'win32' },
+ );
+ const currentAsset = new URL(
+ `https://github.com/apache/maka/releases/download/v${current}/Maka-${current}-win-x64.exe`,
+ );
+
+ const [oldBlockmap, newBlockmap] = provider.getBlockMapFiles(currentAsset, previous, current);
+
+ assert.equal(
+ oldBlockmap.href,
+ `https://github.com/apache/maka/releases/download/v${previous}/Maka-${previous}-win-x64.exe.blockmap`,
+ );
+ assert.equal(newBlockmap.href, `${currentAsset.href}.blockmap`);
+});
+
test('formal release checks ignore the ambient Nightly packaging environment', async () => {
const { NODE_TEST_CONTEXT: _nodeTestContext, ...environment } = process.env;
await run(
diff --git a/scripts/desktop-update-contract.mjs b/scripts/desktop-update-contract.mjs
index 13c64c2e95..d058dcc3a1 100644
--- a/scripts/desktop-update-contract.mjs
+++ b/scripts/desktop-update-contract.mjs
@@ -31,9 +31,10 @@ export const DESKTOP_UPDATE_PROVIDER = Object.freeze({
updaterCacheDirName: '@makadesktop-updater',
});
export const DESKTOP_NIGHTLY_UPDATE_PROVIDER = Object.freeze({
- provider: 'generic',
- url: 'https://nightlies.apache.org/maka/desktop/',
- channel: 'latest',
+ provider: 'github',
+ owner: 'apache',
+ repo: 'maka',
+ channel: 'dev',
updaterCacheDirName: '@makadesktop-updater',
});
diff --git a/scripts/package-macos-arm64.mjs b/scripts/package-macos-arm64.mjs
index 47df4b0b5b..c7b97fba7e 100644
--- a/scripts/package-macos-arm64.mjs
+++ b/scripts/package-macos-arm64.mjs
@@ -85,7 +85,10 @@ export async function packageMacosArm64({
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');
+ const updateMetadataPath = join(
+ releaseDirectory,
+ buildVersion === manifest.version ? 'latest-mac.yml' : 'dev-mac.yml',
+ );
for (const path of requiredElectronLicensePaths) {
await assertFile(path);
diff --git a/scripts/package-windows-x64.mjs b/scripts/package-windows-x64.mjs
index 6ad99bf0c3..28bdef8fe1 100644
--- a/scripts/package-windows-x64.mjs
+++ b/scripts/package-windows-x64.mjs
@@ -88,6 +88,8 @@ export async function packageWindowsX64({
env = process.env,
remove = rm,
assertFile = access,
+ makeDirectory = mkdir,
+ copy = copyFile,
} = {}) {
if (platform !== 'win32' || arch !== 'x64') {
throw new Error('Release packaging requires a Windows x64 host.');
@@ -97,7 +99,10 @@ export async function packageWindowsX64({
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 updateMetadataPath = join(
+ releaseDirectory,
+ buildVersion === manifest.version ? 'latest.yml' : 'dev.yml',
+ );
const unpackedDirectory = join(releaseDirectory, 'win-unpacked');
for (const path of requiredElectronLicensePaths) {
@@ -110,8 +115,8 @@ export async function packageWindowsX64({
await run('npm', ['run', 'check:runtime-host-peer-notices']);
await run('cargo', ['build', '--manifest-path', sandboxManifestPath, '--release', '--locked']);
await run('npm', ['run', 'check:windows-cargo-notices']);
- await mkdir(sandboxResourceDirectory, { recursive: true });
- await copyFile(sandboxBinaryPath, sandboxResourcePath);
+ await makeDirectory(sandboxResourceDirectory, { recursive: true });
+ await copy(sandboxBinaryPath, sandboxResourcePath);
await run('npm', ['run', 'check:release']);
await remove(releaseDirectory, { recursive: true, force: true });
await run('npm', ['--workspace', '@maka/desktop', 'run', 'package:windows-x64']);
diff --git a/scripts/product-release-artifacts.mjs b/scripts/product-release-artifacts.mjs
index c4767a49cc..ae2596dfe5 100644
--- a/scripts/product-release-artifacts.mjs
+++ b/scripts/product-release-artifacts.mjs
@@ -106,7 +106,7 @@ function digestFile(path, algorithm = 'sha256') {
});
}
-async function artifactRecords(directory, names) {
+export async function productReleaseArtifactRecords(directory, names) {
return Promise.all(
[...names].sort(compareProductReleaseNames).map(async (name) => {
const path = join(directory, name);
@@ -259,7 +259,7 @@ export async function createProductReleasePublicationRecord({
sourceCommit: identity.sourceCommit,
tag: identity.tag,
version: identity.version,
- assets: await artifactRecords(artifactDirectory, allArtifactNames(identity)),
+ assets: await productReleaseArtifactRecords(artifactDirectory, allArtifactNames(identity)),
});
}
@@ -271,7 +271,7 @@ export async function verifyProductReleasePublicationRecord({
assertProductReleasePublicationRecord(record, expected);
const names = record.assets.map(({ name }) => name);
await verifyProductReleaseArtifactDirectory(artifactDirectory, names);
- const actual = await artifactRecords(artifactDirectory, names);
+ const actual = await productReleaseArtifactRecords(artifactDirectory, names);
if (JSON.stringify(actual) !== JSON.stringify(record.assets)) {
throw new Error('Product release artifacts do not match the immutable publication record');
}