diff --git a/.asf.yaml b/.asf.yaml index adc2cf51f4..ceacc9ef40 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -85,9 +85,11 @@ github: restrict_force_push: true environments: - # These environments are the external human gates around signing secrets - # and npm OIDC. Naming an environment here replaces its settings wholesale, - # so keep every protection rule in this declarative authority. + # These environments are the deployment boundaries around signing secrets + # and npm OIDC. Release publication adds human review; scheduled npm + # Nightly relies on exact ref admission instead. Naming an environment here + # replaces its settings wholesale, so keep every protection rule in this + # declarative authority. release: required_reviewers: - id: M4n5ter @@ -99,17 +101,15 @@ github: policies: - name: "v*-incubating-rc*" type: tag - npm-release: - required_reviewers: - - id: M4n5ter - type: User + npm-publication: + required_reviewers: [] wait_timer: 0 - prevent_self_review: true + prevent_self_review: false deployment_branch_policy: protected_branches: false policies: - - name: "v*" - type: tag + - name: main + type: branch product-release: required_reviewers: - id: M4n5ter diff --git a/.github/ASF_NPM_RELEASE.md b/.github/ASF_NPM_RELEASE.md index 01b11ad9b5..db621a0c88 100644 --- a/.github/ASF_NPM_RELEASE.md +++ b/.github/ASF_NPM_RELEASE.md @@ -72,9 +72,9 @@ registry side effects. After both source-release votes approve the candidate, the product Release workflow creates `v` at that approved commit. The npm Stage workflow -then builds and validates one tarball from that final tag, submits those exact -bytes to npm staging through the protected `npm-release` Environment and OIDC, -and records the stage identity. Human approval with npm 2FA makes the package +then builds and validates one tarball from that final tag in jobs without OIDC. The OIDC job runs +only reviewed `main` publisher code, submits those exact bytes through the main-restricted +`npm-publication` Environment, and records both product-source and publisher identities. Human approval with npm 2FA makes the package public; Finalize verifies the registry bytes, integrity, signature, provenance, and dist-tag. diff --git a/.github/DESKTOP_NIGHTLY.md b/.github/DESKTOP_NIGHTLY.md index d3f285e693..6e0661ce9e 100644 --- a/.github/DESKTOP_NIGHTLY.md +++ b/.github/DESKTOP_NIGHTLY.md @@ -21,7 +21,7 @@ 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`. 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. +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. 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. @@ -29,9 +29,11 @@ Nightly currently uses the same application identity as the formal Desktop. Inst 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, `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. +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. -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. +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. 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. +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/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 92710f9e74..72a11d93a0 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -61,8 +61,12 @@ must never be exposed to fork or ordinary pull-request jobs. Before the first product release, confirm the checked-in `.asf.yaml` has reconciled the live repository: - the `Immutable release tags` ruleset blocks updates, force-pushes, and deletions of `v*` tags; -- the `release` and `npm-release` Environments accept only their declared tag patterns, - `product-release` accepts only `main`, and each requires a reviewer other than the triggering user. +- the `release` Environment accepts only its declared source-candidate tag pattern and requires a + reviewer other than the triggering user; +- `npm-publication` and `product-release` accept only `main`; `product-release` requires a reviewer + other than the triggering user. `npm-publication` has no GitHub + approval gate because scheduled Nightly publication is automatic; formal npm publication still + requires human 2FA approval after staging. These controls close the check-to-upload and check-to-stage windows. Finalize uses GitHub Actions OIDC rather than a stored signing key to attest every convenience artifact. Keep the Release in @@ -83,8 +87,8 @@ bytes and expected filename are not covered by that protected workflow identity. 5. Confirm `release-identity`, both Desktop matrix entries, `cli-macos-arm64`, and `publish` pass. A skipped or failed required job must prevent Draft creation. 6. Confirm one Draft named `v` targets the approved source SHA, identifies the ASF source - reference in its notes, is marked as a GitHub prerelease exactly when the product version is a - prerelease, is not marked Latest while it remains a Draft, and contains exactly the manifest + reference in its notes, is not marked as a GitHub prerelease or Latest while it remains a Draft, + and contains exactly the manifest reported by `node scripts/product-release-artifacts.mjs list`. The manifest covers both Desktop platforms and update metadata, the standalone CLI/TUI, and their required checksums. 7. Inspect the CLI ZIP. It must contain `bin/maka`, `RELEASE.json`, `DISCLAIMER-WIP`, `LICENSE`, `NOTICE`, @@ -111,7 +115,8 @@ then rerun. If only the tag exists, the retry creates the missing Draft. Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact product tag and Draft: 1. Record the successful **Release** workflow run ID and attempt that built the Draft assets. Run - **Stage CLI npm release** from `v` and record its successful run ID and attempt. + **npm publication** with `channel=formal` from `main` and record its successful run ID and + attempt. 2. Inspect the staged tarball and provenance, then approve that exact stage with npm 2FA. 3. Run **Finalize product release** from `main`. Its first job verifies the public package bytes, provenance, signature, and release dist-tag. @@ -122,7 +127,7 @@ Keep the GitHub Release in Draft throughout this sequence. The final workflow jo has passed. It verifies the live Draft digests against the immutable publication record from the exact successful Release run, creates Sigstore provenance and an offline `Maka--attestation.sigstore.json` bundle, then publishes the convenience Release and makes a -stable release Latest in the same GitHub operation; prereleases remain non-Latest. Do not publish or +stable release Latest in the same GitHub operation. Do not publish or change the Latest designation manually. A failed or rejected npm candidate requires a new product version; never publish the Draft to work around npm state. diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index d7c08009fc..68b36e7f30 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -21,6 +21,7 @@ on: pull_request: paths: - '.github/workflows/cli-package-validation.yml' + - '.github/workflows/npm-publication.yml' - '.github/workflows/runtime-host-peer-admission.yml' - 'deny.toml' - 'native/runtime-host-peer/**' @@ -48,6 +49,11 @@ on: required: false type: string default: '' + package_version: + description: Exact Product Nightly version; empty builds the checked-in formal version + required: false + type: string + default: '' outputs: release_candidate_artifact_id: description: Immutable artifact produced by the build job @@ -165,6 +171,7 @@ jobs: merge-multiple: true - name: Build the release tarball once env: + MAKA_CLI_NIGHTLY_VERSION: ${{ inputs.package_version }} MAKA_RUNTIME_HOST_PEER_PREBUILDS: ${{ runner.temp }}/runtime-host-peer-prebuilds run: npm run release:cli:pack - name: Upload the immutable release candidate diff --git a/.github/workflows/desktop-nightly.yml b/.github/workflows/desktop-nightly.yml index 728906c864..b763d8149d 100644 --- a/.github/workflows/desktop-nightly.yml +++ b/.github/workflows/desktop-nightly.yml @@ -18,11 +18,14 @@ name: Desktop Nightly on: - schedule: - - cron: '17 18 * * *' - workflow_dispatch: + workflow_run: + workflows: + - npm publication + types: + - completed permissions: + actions: read contents: read concurrency: @@ -31,36 +34,51 @@ concurrency: jobs: identity: - if: vars.DESKTOP_NIGHTLY_ENABLED == 'true' + if: >- + vars.DESKTOP_NIGHTLY_ENABLED == 'true' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.display_title == 'npm nightly publication' runs-on: ubuntu-24.04 timeout-minutes: 10 outputs: - source_commit: ${{ steps.identity.outputs.source_commit }} + source_commit: ${{ github.event.workflow_run.head_sha }} 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 + echo "Desktop Nightly retries require a fresh npm Nightly dispatch" >&2 exit 1 - - name: Check out the scheduled main commit + - name: Check out the published Nightly source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ github.sha }} + ref: ${{ github.event.workflow_run.head_sha }} persist-credentials: false - - name: Require the Apache main branch + - name: Require the successful Apache npm Nightly run + env: + UPSTREAM_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} run: | test "$GITHUB_REPOSITORY" = apache/maka - test "$GITHUB_REF" = refs/heads/main + test "$UPSTREAM_REPOSITORY" = apache/maka - - name: Resolve the immutable Nightly identity + - name: Download the published Nightly version + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: product-nightly-version + path: ${{ runner.temp }}/product-nightly-version + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Bind Desktop to the exact npm Nightly version id: identity env: - GITHUB_SHA: ${{ github.sha }} - GITHUB_RUN_NUMBER: ${{ github.run_number }} - run: node scripts/desktop-nightly.mjs identity + VERSION_PATH: ${{ runner.temp }}/product-nightly-version/version.txt + run: | + node scripts/product-nightly.mjs inspect-version "$VERSION_PATH" "$GITHUB_OUTPUT" desktop: needs: identity @@ -84,7 +102,7 @@ jobs: - name: Reject in-place workflow reruns if: github.run_attempt != 1 run: | - echo "Desktop Nightly retries require a fresh workflow dispatch" >&2 + echo "Desktop Nightly retries require a fresh npm Nightly dispatch" >&2 exit 1 - name: Check out the exact Nightly source @@ -200,7 +218,7 @@ jobs: - name: Reject in-place workflow reruns if: github.run_attempt != 1 run: | - echo "Desktop Nightly retries require a fresh workflow dispatch" >&2 + echo "Desktop Nightly retries require a fresh npm Nightly dispatch" >&2 exit 1 - name: Check out the Nightly publisher @@ -297,6 +315,21 @@ jobs: 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: Require the Desktop Nightly feed to advance + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + 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/" diff --git a/.github/workflows/npm-publication.yml b/.github/workflows/npm-publication.yml new file mode 100644 index 0000000000..909f58e60a --- /dev/null +++ b/.github/workflows/npm-publication.yml @@ -0,0 +1,197 @@ +# 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: npm publication +run-name: npm ${{ inputs.channel || 'nightly' }} publication + +on: + schedule: + - cron: '17 18 * * *' + workflow_dispatch: + inputs: + channel: + description: Publication channel + required: true + type: choice + default: nightly + options: + - nightly + - formal + version: + description: Exact formal product version; ignored for Nightly + required: false + type: string + default: '' + +permissions: + contents: read + +concurrency: + group: npm-publication-${{ inputs.channel || 'nightly' }} + cancel-in-progress: false + +jobs: + formal: + if: github.event_name == 'workflow_dispatch' && inputs.channel == 'formal' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/release-cli-stage.yml + with: + version: ${{ inputs.version }} + + identity: + if: vars.NPM_NIGHTLY_ENABLED == 'true' && (github.event_name == 'schedule' || inputs.channel == 'nightly') + 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 "npm 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: + 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/product-nightly.mjs identity + + cli: + name: Validate npm Nightly + needs: identity + uses: ./.github/workflows/cli-package-validation.yml + with: + source_commit: ${{ needs.identity.outputs.source_commit }} + package_version: ${{ needs.identity.outputs.version }} + + publish: + name: Publish npm Nightly + needs: [identity, cli] + runs-on: ubuntu-24.04 + environment: npm-publication + timeout-minutes: 20 + permissions: + contents: read + id-token: write + steps: + - name: Reject in-place workflow reruns + if: github.run_attempt != 1 + run: | + echo "npm Nightly retries require a fresh workflow dispatch" >&2 + exit 1 + + - 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: Select the publication npm toolchain + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" + + - name: Download the validated npm Nightly + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.cli.outputs.release_candidate_artifact_id }} + path: packages/cli/release + + - name: Bind the npm Nightly to this workflow run + id: npm-nightly + env: + NIGHTLY_VERSION: ${{ needs.identity.outputs.version }} + run: | + node scripts/release-cli-publication.mjs prepare-nightly \ + packages/cli/release \ + "$NIGHTLY_VERSION" \ + "$GITHUB_OUTPUT" + + - name: Require the Nightly channel to advance + env: + NIGHTLY_VERSION: ${{ steps.npm-nightly.outputs.version }} + run: | + current="$(npm view maka-agent dist-tags.nightly --registry https://registry.npmjs.org/)" + node scripts/product-nightly.mjs assert-channel-advance "$NIGHTLY_VERSION" "$current" + + - name: Publish the exact npm Nightly + env: + NIGHTLY_DIST_TAG: ${{ steps.npm-nightly.outputs.dist_tag }} + NIGHTLY_TARBALL: ${{ steps.npm-nightly.outputs.tarball }} + run: | + test "$NIGHTLY_DIST_TAG" = nightly + npm publish "$NIGHTLY_TARBALL" \ + --tag nightly \ + --registry https://registry.npmjs.org/ \ + --provenance + + - name: Require the public npm Nightly + env: + NIGHTLY_VERSION: ${{ steps.npm-nightly.outputs.version }} + run: | + for attempt in {1..12}; do + published="$(npm view "maka-agent@$NIGHTLY_VERSION" version --registry https://registry.npmjs.org/ 2>/dev/null || true)" + tagged="$(npm view maka-agent dist-tags.nightly --registry https://registry.npmjs.org/ 2>/dev/null || true)" + if [[ "$published" == "$NIGHTLY_VERSION" && "$tagged" == "$NIGHTLY_VERSION" ]]; then + exit 0 + fi + sleep 5 + done + echo "npm Nightly did not become readable with the exact nightly tag" >&2 + exit 1 + + - name: Record the published Product Nightly version + env: + VERSION_DIRECTORY: ${{ runner.temp }}/product-nightly-version + NIGHTLY_VERSION: ${{ steps.npm-nightly.outputs.version }} + run: | + mkdir -p "$VERSION_DIRECTORY" + node scripts/product-nightly.mjs write-version \ + "$VERSION_DIRECTORY/version.txt" \ + "$NIGHTLY_VERSION" + + - name: Hand the exact version to Desktop Nightly + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: product-nightly-version + path: ${{ runner.temp }}/product-nightly-version/version.txt + if-no-files-found: error + compression-level: 0 + retention-days: 30 diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 169410a7be..92cb75da66 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -18,7 +18,7 @@ name: Stage CLI npm release on: - workflow_dispatch: + workflow_call: inputs: version: description: Exact maka-agent version from packages/cli/package.json @@ -41,20 +41,28 @@ jobs: source_commit: ${{ steps.product.outputs.source_commit }} product_tag: ${{ steps.product.outputs.product_tag }} steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out the trusted publisher + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: v${{ inputs.version }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - - name: Require the exact product tag + + - name: Require the Apache main publisher env: RELEASE_REF: ${{ github.ref }} - PRODUCT_TAG: v${{ inputs.version }} + RELEASE_REPOSITORY: ${{ github.repository }} run: | - if [[ "$RELEASE_REF" != "refs/tags/$PRODUCT_TAG" ]]; then - echo "CLI releases must be dispatched from $PRODUCT_TAG; found $RELEASE_REF" >&2 - exit 1 - fi + test "$RELEASE_REPOSITORY" = apache/maka + test "$RELEASE_REF" = refs/heads/main + + - name: Check out the approved product source as data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: v${{ inputs.version }} + path: product-source + fetch-depth: 1 + persist-credentials: false - name: Resolve the exact product release id: product @@ -62,8 +70,7 @@ jobs: GH_TOKEN: ${{ github.token }} PRODUCT_TAG: v${{ inputs.version }} run: | - source_commit="$(git rev-parse HEAD)" - test "$source_commit" = "$GITHUB_SHA" + source_commit="$(git -C product-source rev-parse HEAD)" node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$source_commit" "$GITHUB_REPOSITORY" echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT" @@ -73,6 +80,7 @@ jobs: env: GITHUB_SHA: ${{ steps.product.outputs.source_commit }} EXPECTED_PRODUCT_VERSION: ${{ inputs.version }} + PRODUCT_MANIFEST_ROOT: ${{ github.workspace }}/product-source run: node scripts/product-release-identity.mjs validate: @@ -88,15 +96,16 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 environment: - name: npm-release + name: npm-publication url: https://www.npmjs.com/package/maka-agent permissions: contents: read id-token: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out trusted staging code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ needs.authorize.outputs.source_commit }} + ref: ${{ github.sha }} fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -119,14 +128,16 @@ jobs: RELEASE_REPOSITORY: ${{ github.repository }} RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} - RELEASE_SHA: ${{ github.sha }} - RELEASE_WORKFLOW: .github/workflows/release-cli-stage.yml + PRODUCT_SOURCE_SHA: ${{ needs.authorize.outputs.source_commit }} + PUBLISHER_SHA: ${{ github.sha }} + RELEASE_WORKFLOW: .github/workflows/npm-publication.yml run: | node scripts/release-cli-publication.mjs prepare-stage \ packages/cli/release \ "$EXPECTED_VERSION" \ "$PRODUCT_TAG" \ - "$RELEASE_SHA" \ + "$PRODUCT_SOURCE_SHA" \ + "$PUBLISHER_SHA" \ "$RELEASE_RUN_ID" \ "$RELEASE_RUN_ATTEMPT" \ "$RELEASE_REPOSITORY" \ @@ -160,12 +171,6 @@ jobs: echo "- stage run ID: \`$RELEASE_RUN_ID\`" echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" echo "- version: \`$RELEASE_VERSION\`" - if [[ "$RELEASE_DIST_TAG" == "latest" ]]; then - echo - echo "Before finalizing this stable release, inspect npm dist-tags. If \`next\` is older than \`latest\`, authenticate interactively with npm and run:" - echo - echo "\`npm dist-tag add \"maka-agent@$RELEASE_VERSION\" next --registry https://registry.npmjs.org/\`" - fi } >> "$GITHUB_STEP_SUMMARY" - name: Submit the candidate to npm staging env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73006cb1a9..e9fe3aeed2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,6 @@ jobs: timeout-minutes: 10 outputs: version: ${{ steps.identity.outputs.version }} - is_prerelease: ${{ steps.identity.outputs.is_prerelease }} tag: ${{ steps.identity.outputs.tag }} source_commit: ${{ steps.identity.outputs.source_commit }} source_reference_tag: ${{ steps.identity.outputs.source_reference_tag }} @@ -451,7 +450,6 @@ jobs: env: GH_TOKEN: ${{ github.token }} VERSION: ${{ needs.release-identity.outputs.version }} - IS_PRERELEASE: ${{ needs.release-identity.outputs.is_prerelease }} TAG: ${{ needs.release-identity.outputs.tag }} SOURCE_COMMIT: ${{ needs.release-identity.outputs.source_commit }} SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }} @@ -466,14 +464,6 @@ jobs: Known limitations: Computer Use and managed-workspace execution are not included in this release. The Desktop convenience artifacts do not distribute a Git runtime." - classification=(--prerelease=false --latest=false) - if [[ "$IS_PRERELEASE" == "true" ]]; then - classification=(--prerelease --latest=false) - elif [[ "$IS_PRERELEASE" != "false" ]]; then - echo "Product prerelease classification must be true or false; found $IS_PRERELEASE" >&2 - exit 1 - fi - if gh release view "$TAG" >/dev/null 2>&1; then is_draft="$(gh release view "$TAG" --json isDraft --jq .isDraft)" if [[ "$is_draft" != "true" ]]; then @@ -484,7 +474,8 @@ jobs: gh release create "$TAG" \ --draft \ --verify-tag \ - "${classification[@]}" \ + --prerelease=false \ + --latest=false \ --title "Maka ${VERSION}" \ --notes "$notes" fi @@ -494,12 +485,13 @@ jobs: exit 1 fi gh release edit "$TAG" \ - "${classification[@]}" \ + --prerelease=false \ + --latest=false \ --title "Maka ${VERSION}" \ --notes "$notes" actual_prerelease="$(gh release view "$TAG" --json isPrerelease --jq .isPrerelease)" - if [[ "$actual_prerelease" != "$IS_PRERELEASE" ]]; then - echo "Release ${TAG} prerelease state is ${actual_prerelease}, expected ${IS_PRERELEASE}." >&2 + if [[ "$actual_prerelease" != "false" ]]; then + echo "Release ${TAG} must not be a prerelease." >&2 exit 1 fi asset_names="$(gh release view "$TAG" --json assets --jq '.assets[].name')" @@ -532,7 +524,7 @@ jobs: fi done release_state="$(gh release view "$TAG" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | map(tostring) | join(" ")')" - if [[ "$release_state" != "true $IS_PRERELEASE" ]]; then + if [[ "$release_state" != "true false" ]]; then echo "Release ${TAG} changed state while assets were checked: ${release_state}." >&2 exit 1 fi diff --git a/apps/desktop/electron-builder.config.mjs b/apps/desktop/electron-builder.config.mjs index 7893be54d9..ddec325d76 100644 --- a/apps/desktop/electron-builder.config.mjs +++ b/apps/desktop/electron-builder.config.mjs @@ -21,6 +21,7 @@ import { readFileSync } from 'node:fs'; import { DESKTOP_NIGHTLY_FEED_URL, resolveDesktopBuildVersion, + resolveRuntimeHostSetupPackage, } from '../../scripts/desktop-nightly.mjs'; import { resolveProductManifestIdentity } from '../../scripts/product-release-identity.mjs'; @@ -264,6 +265,7 @@ export function resolveDesktopBuilderConfig(environment = process.env) { extraMetadata: { ...baseDesktopBuilderConfig.extraMetadata, version, + runtimeHostSetupPackage: resolveRuntimeHostSetupPackage(rootManifest.version, environment), makaUpdateChannel: 'nightly', }, publish: [{ provider: 'generic', url: DESKTOP_NIGHTLY_FEED_URL }], diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index 6322344b85..b32c2ebaa4 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -21,7 +21,7 @@ [简体中文](./cli-npm-release.zh-CN.md) -This runbook is the operational authority for publishing the `maka-agent` npm installation channel. The root `package.json` remains the sole Maka product-version authority, and `packages/cli/package.json` must match it. Every public npm version must come from the exact tarball validated by the Stage workflow. +This runbook is the operational authority for publishing the `maka-agent` npm installation channels. The root `package.json` remains the sole Maka product-version authority, and `packages/cli/package.json` must match it. Every public npm version must come from an exact tarball validated by the shared package workflow. The IPMC-approved source archive on ASF distribution infrastructure is the Apache release. npm, Desktop installers, and GitHub Release assets are convenience packages built from that approved @@ -36,26 +36,32 @@ retaining Maka's stronger protected-Environment, staged-publishing, 2FA, and Fin ## Release invariants - Dispatch the product Release workflow only from the exact approved ASF source candidate tag. - Dispatch npm Stage from the resulting product `v` tag and product Finalize from `main`. -- Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to - a version older than `latest`; when no newer prerelease exists, both tags point to the stable - version. + Dispatch both npm Stage and product Finalize from `main`; Stage accepts the resulting product + `v` tag only as verified release data. +- Publish approved stable versions under `latest`. Publish developer snapshots under `nightly`. + There is no `next` channel, and `nightly` must never modify `latest`. - Do not create an npm-specific Git tag or GitHub Release. The `Release` workflow creates the product `v` tag and Draft before npm staging; Finalize is the sole publisher of that Draft. - Keep that GitHub Release in Draft until npm Finalize and Desktop remote Runtime Host acceptance succeed. The Draft supplies npm's product identity; its publication is the final product action. -- Do not run `npm publish`. GitHub Actions may only run `npm stage publish`; a human package - maintainer approves the staged package with npm 2FA. +- Formal publication may only run `npm stage publish`; a human package maintainer approves the + staged package with npm 2FA. Product Nightly alone may run `npm publish --tag nightly` directly. - Do not rebuild between validation, staging, approval, and finalization. -- Never reuse a public version. Fixes require a new prerelease, patch, minor, or major version. - -The two workflow boundaries are: - -1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) resolves the existing product - tag and GitHub Release, checks out that exact product commit, builds and validates one immutable - tarball, records that single tag commit and workflow run, enters the protected `npm-release` - Environment, and submits it to npm staging through OIDC. -2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) accepts only the exact +- Never reuse a public version. Formal product fixes require a new patch, minor, or major version. + +The workflow boundaries are: + +1. [npm publication](../.github/workflows/npm-publication.yml) is the only Trusted Publisher caller. + It runs exclusively from `main`, routes formal publication for an exact product tag supplied as + data, and publishes npm Nightly. +2. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) resolves the existing product + tag and GitHub Release. Jobs without OIDC build and validate one immutable tarball from that + product commit. The OIDC job executes only reviewed `main` publisher code, records the separate + product-source and publisher identities, and submits the validated bytes to npm staging. +3. [Desktop Nightly](../.github/workflows/desktop-nightly.yml) starts only after a successful npm + Nightly run, consumes only its immutable version file, and takes the source commit and upstream + run identity from the authenticated `workflow_run` event. +4. [Finalize product release](../.github/workflows/release-cli-finalize.yml) accepts only the exact successful Stage run, Release build run, and self-contained publication record. The current reviewed verifier on `main` checks the public registry bytes, signature, provenance, dist-tag, immutable build artifacts, and live Draft digests, then waits at the protected `product-release` @@ -67,14 +73,14 @@ The two workflow boundaries are: ### GitHub Environment -The checked-in `.asf.yaml` is the authority for the `npm-release` and `product-release` +The checked-in `.asf.yaml` is the authority for the `npm-publication` and `product-release` Environments. After it reaches `main`, confirm ASF reconciliation produced: -- a selected `v*` tag rule for `npm-release` and a selected `main` branch rule for - `product-release`; -- `M4n5ter` as the required reviewer; -- self-review disabled; +- a selected `main` branch rule for `npm-publication`, with no approval gate so the + scheduled Nightly can publish automatically; +- a selected `main` branch rule for `product-release`, with `M4n5ter` as required reviewer and + self-review disabled; - administrator bypass disabled where repository policy permits it. Repository administration permission is required to inspect or repair reconciliation. Do not maintain @@ -90,24 +96,64 @@ In the `maka-agent` package settings, configure one GitHub Actions trusted publi | --- | --- | | Organization or user | `apache` | | Repository | `maka` | -| Workflow filename | `release-cli-stage.yml` | -| Environment name | `npm-release` | -| Allowed actions | `npm stage publish` only | +| Workflow filename | `npm-publication.yml` | +| Environment name | `npm-publication` | +| Allowed actions | `npm publish` and `npm stage publish` | -The workflow filename is case-sensitive and contains no `.github/workflows/` prefix. Keep -`npm publish` disabled for this trust relationship. +The workflow filename is case-sensitive and contains no `.github/workflows/` prefix. Both formal +staging and direct Nightly publication use the same `npm-publication` Environment. Its deployment +rules admit only `main`. It has no GitHub approval gate because the +scheduled Nightly is automatic; formal publication still requires human npm 2FA approval after +staging. Do not configure a second publisher or npm token. After the first OIDC Stage succeeds, set package publishing access to **Require two-factor authentication and disallow tokens**, then revoke obsolete publish tokens. Do not remove the human package owner or recovery access as part of that change. +Keep the repository variable `NPM_NIGHTLY_ENABLED` unset until `.asf.yaml` has reconciled the +Environment and the Trusted Publisher matches it. Then set it to `true` and run one manual Nightly +before relying on the schedule. This variable controls npm only; Desktop has its own independent +`DESKTOP_NIGHTLY_ENABLED` rollout gate. + +## Product Nightly + +The scheduled `npm-publication.yml` run creates one immutable version such as +`0.2.0-dev.42.20260829` from the exact scheduled `main` commit, validates the four-platform +`maka-agent` tarball, and publishes it under `nightly`. Only after the exact version and dist-tag are +public does the successful workflow trigger `desktop-nightly.yml`. Desktop consumes only that +version; the authenticated upstream event supplies the exact source commit. The packaged Desktop +records the exact Runtime Host setup specifier, for example `maka-agent@0.2.0-dev.42.20260829`; it +never installs the mutable `nightly` tag. + +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. + +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: + +```sh +gh workflow run npm-publication.yml --ref main -f channel=nightly +``` + +Nightly is a developer snapshot, not an Apache release. Do not promote it from end-user download +pages. Developers may install the moving channel explicitly with `maka-agent@nightly`; product +automation must use the exact version recorded by Desktop. + ## Prepare a release 1. Merge all intended package, documentation, and release changes to `main`, prepare the ASF source candidate, and complete both the podling and Incubator PMC votes. 2. Confirm the root product version, `apps/desktop/package.json`, and - `packages/cli/package.json` have the same unused target version at the approved source commit. - The npm channel maps prerelease versions to `next` and stable versions to `latest`. + `packages/cli/package.json` have the same unused stable target version at the approved source + commit. Formal npm publication only advances `latest`. 3. Dispatch the product `Release` workflow from the exact approved `v-incubating-rc` tag, supplying that same tag as `source_reference_tag`. Confirm its Draft `v` Release points to the approved commit. npm staging consumes this identity and @@ -115,31 +161,34 @@ package owner or recovery access as part of that change. 4. Confirm the target version is absent from both public and staged package state: ```sh - version=0.1.0-beta.1 + version=0.2.0 npm view "maka-agent@$version" version --registry https://registry.npmjs.org/ npm stage list maka-agent --registry https://registry.npmjs.org/ ``` The first command should report that the target version is not present. Resolve any existing stage instead of submitting the same version again. -5. Confirm the `npm-release` Environment and Trusted Publisher still match the values above and the +5. Confirm the `npm-publication` Environment and Trusted Publisher still match the values above and the approving npm account has 2FA enabled. ## Stage the candidate -1. Dispatch the workflow with the exact product tag as its GitHub ref: +1. Dispatch the workflow from reviewed `main`, supplying the exact product version: ```sh - version=0.1.0-beta.1 - gh workflow run release-cli-stage.yml --ref "v$version" -f version="$version" + version=0.2.0 + gh workflow run npm-publication.yml --ref main \ + -f channel=formal \ + -f version="$version" ``` -2. Confirm the created run uses `v`. The workflow requires its GitHub ref, checkout, product tag, Release, source commit, and npm provenance to identify that one tag commit, and requires the commit to remain an ancestor of `main`. +2. Confirm the created run uses `main`. The workflow resolves `v` and its Draft as data, + requires that tag commit to remain an ancestor of `main`, builds the candidate without OIDC, and + binds npm provenance to the reviewed `main` publisher workflow and exact run. 3. Wait for the reusable package validation jobs to pass. They build one tarball and validate the installed CLI on Linux x64/arm64, macOS arm64, and Windows x64, plus real Harbor and Pier Docker cells on Linux x64. -4. Review and approve the `npm-release` Environment deployment. -5. Record the successful Stage workflow run ID, run attempt, source commit, version, and staged +4. Record the successful Stage workflow run ID, run attempt, source commit, version, and staged artifact checksum from the run summary and `cli-staged-release-` artifact. Do not approve anything on npm if the Stage workflow did not finish successfully. @@ -173,7 +222,7 @@ node scripts/product-release-authority.mjs verify-draft \ ``` The verifier must succeed. Stop if the tag is absent, moved, no longer on `main`, the matching -GitHub Release is no longer a Draft, or its prerelease classification does not match the version. +GitHub Release is no longer a stable Draft, or is marked as a prerelease. Approve only that stage ID. npm requires 2FA and makes the package public as part of approval: @@ -184,23 +233,14 @@ npm stage approve "$stage_id" --registry https://registry.npmjs.org/ The same review and approval can be performed from the package's **Staged Packages** page on npmjs.com. -For a stable release, inspect the public tags after approval: +Inspect the public tags after approval: ```sh version=0.1.0 npm view maka-agent dist-tags --json --registry https://registry.npmjs.org/ ``` -If `next` is absent or older than `latest`, authenticate interactively as an npm package owner and -advance it to the new stable version before running Finalize: - -```sh -npm dist-tag add "maka-agent@$version" next --registry https://registry.npmjs.org/ -``` - -Do not change `next` when it already points to a newer version such as `0.2.0-beta.1`. This step is -intentionally manual: npm Trusted Publishing authenticates `npm publish` and `npm stage publish`, -not dist-tag mutations, and the release workflows must not gain a long-lived npm token. +`latest` must identify the approved version. `nightly`, when present, remains independent. ## Finalize the product release @@ -210,7 +250,7 @@ After npm reports the version as public: 2. Enter the successful Stage run ID and attempt, the successful Release build run ID and attempt, and the version. 3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, - Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. + Trusted Publishing provenance, and the exact `latest` dist-tag. 4. While the publication job waits for `product-release` approval, complete the product checklist's cross-machine acceptance against the Draft. 5. Approve the Environment. Confirm the workflow matches every live Draft digest to the exact @@ -221,7 +261,7 @@ After npm reports the version as public: Check the resulting registry state: ```sh -version=0.1.0-beta.1 +version=0.2.0 npm view "maka-agent@$version" version dist.tarball dist.integrity --json npm view maka-agent dist-tags --json ``` @@ -277,18 +317,16 @@ must not be repeated. First move the affected dist-tag back to a previously verified version: ```sh -known_good=0.1.0-beta.1 -npm dist-tag add "maka-agent@$known_good" next -# For a stable release incident, use latest instead of next. +known_good=0.2.0 +npm dist-tag add "maka-agent@$known_good" latest ``` Then deprecate only the defective version and direct users to the recovered dist-tag, which already points to the verified version: ```sh -bad_version=0.1.0-beta.2 -recovery_tag=next -# For a stable release incident, use latest instead of next. +bad_version=0.2.1 +recovery_tag=latest npm deprecate "maka-agent@$bad_version" "Known issue; install maka-agent@$recovery_tag." ``` @@ -296,10 +334,14 @@ Verify the tags, fix the defect, and release a new version through the complete flow. Do not use `npm unpublish` as routine rollback: removing immutable dependency bytes can break existing installations and does not restore the reviewed release chain. +For a defective Nightly, dispatch a fresh run so a new immutable version advances `nightly`. If an +immediate rollback is required, a human package owner may move `nightly` to a previously verified +Nightly version and deprecate only the defective exact version. Never point `latest` at a Nightly. + ## Ownership and emergency recovery -- GitHub repository admins own the `npm-release` Environment configuration. The release maintainer - owns dispatch, Environment review, staged-package inspection, npm 2FA approval, and final +- GitHub repository admins own the `npm-publication` Environment configuration. The release maintainer + owns dispatch, staged-package inspection, npm 2FA approval, and final acceptance. - npm package owners own Trusted Publisher, publishing-access, maintainer, and dist-tag recovery. - Keep at least one 2FA-protected human owner while trusted publishing is active. Before removing the diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 76cefee8f4..012921f8bd 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -21,7 +21,7 @@ [English](./cli-npm-release.md) -本文档是发布 `maka-agent` npm 安装渠道的操作权威。根目录 `package.json` 仍是 Maka 唯一产品版本权威,`packages/cli/package.json` 必须与其一致。每个公开 npm 版本都必须来自 Stage workflow 验证过的同一个精确 tarball。 +本文档是发布 `maka-agent` npm 安装渠道的操作权威。根目录 `package.json` 仍是 Maka 唯一产品版本权威,`packages/cli/package.json` 必须与其一致。每个公开 npm 版本都必须来自共享 package workflow 验证过的精确 tarball。 ASF 分发基础设施上经 IPMC 批准的源码归档才是 Apache release。npm、Desktop 安装包和 GitHub Release assets 都是从该源码身份构建的便利包,不是额外的 ASF release artifacts。 @@ -30,23 +30,30 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 ## 发布不变量 -- 产品 Release workflow 只能从已批准的 ASF source candidate tag dispatch;npm Stage 只能从随后创建的产品 `v` tag dispatch,npm Finalize 只能从 `main` dispatch; -- 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 - 更新的预发布版本时,两个 tag 都指向稳定版; +- 产品发布工作流只能从已批准的 ASF source candidate tag dispatch;npm Stage 与 npm + Finalize 都只能从 `main` dispatch,Stage 仅把随后创建的产品 `v` tag 作为已验证数据; +- 获批的稳定版本使用 `latest`,开发快照使用 `nightly`;不再存在 `next` 渠道,`nightly` + 永远不得修改 `latest`; - 不创建 npm 专属 Git tag 或 GitHub Release;`Release` workflow 在 npm staging 前创建产品 `v` tag 与 Draft,Finalize 是该 Draft 唯一的发布者; - 在 npm Finalize 与 Desktop 远程 Runtime Host 验收成功前,GitHub Release 必须保持 Draft; Draft 为 npm 提供产品身份,发布 Draft 是最终的产品发布动作; -- 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package - maintainer 使用 npm 2FA 批准 staged package; +- 正式发布只能运行 `npm stage publish`,由人工 package maintainer 使用 npm 2FA 批准;只有 + Product Nightly 可以直接运行 `npm publish --tag nightly`; - validation、staging、approval 和 finalization 之间不得重新构建; -- 已公开的版本不得复用。修复必须使用新的 prerelease、patch、minor 或 major 版本。 - -两个 workflow 边界分别是: - -1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub - Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,记录这个唯一的 tag commit 与 workflow run,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; -2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 +- 已公开的版本不得复用。正式产品修复必须使用新的 patch、minor 或 major 版本。 + +workflow 边界分别是: + +1. [npm publication](../.github/workflows/npm-publication.yml) 是唯一的 Trusted Publisher + caller;它只能从 `main` 运行,把精确产品 tag 作为待验证的数据路由正式发布,并发布 npm Nightly; +2. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub + Release;无 OIDC 的 job 从产品 commit 构建并验证一个 immutable tarball;OIDC job 只执行 + `main` 上已审查的 publisher 代码,分别记录产品 source 与 publisher 身份,再把验证过的字节提交到 npm staging; +3. [Desktop Nightly](../.github/workflows/desktop-nightly.yml) 只在 npm Nightly 成功后启动,只消费 + immutable version file;source commit 与上游 run 身份直接取自已认证的 + `workflow_run` event; +4. [Finalize product release](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 Stage run、Release build run 及其自包含 publication record;`main` 上当前已审查的 verifier 验证公共 registry 字节、signature、provenance、dist-tag、不可变 build artifacts 与 live Draft digest,然后等待受保护的 `product-release` Environment;独立 Desktop 验收完成并批准后,它会 @@ -56,12 +63,13 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 ### GitHub Environment -仓库中的 `.asf.yaml` 是 `npm-release` 和 `product-release` Environment 的权威。该配置进入 `main` 后,确认 ASF +仓库中的 `.asf.yaml` 是 `npm-publication` 和 `product-release` Environment 的权威。该配置进入 `main` 后,确认 ASF 同步出的 live 配置满足: -- `npm-release` 使用 selected `v*` tag rule,`product-release` 使用 selected `main` branch rule; -- required reviewer 为 `M4n5ter`; -- 禁止 self-review; +- `npm-publication` 只允许 selected `main` branch;为保证 Nightly 自动运行,不设置 + approval gate; +- `product-release` 只允许 selected `main` branch,required reviewer 为 `M4n5ter`,并禁止 + self-review; - 仓库策略允许时禁用 administrator bypass。 检查或修复同步结果需要仓库 administration 权限;不要再在 GitHub UI 中维护第二套手工 @@ -76,49 +84,87 @@ Environment policy。Finalize 使用 GitHub Actions OIDC 为精确便利包生 | --- | --- | | Organization or user | `apache` | | Repository | `maka` | -| Workflow filename | `release-cli-stage.yml` | -| Environment name | `npm-release` | -| Allowed actions | 仅 `npm stage publish` | +| Workflow filename | `npm-publication.yml` | +| Environment name | `npm-publication` | +| Allowed actions | `npm publish` 与 `npm stage publish` | -Workflow filename 区分大小写,并且不包含 `.github/workflows/` 前缀。这个 trust relationship -不得启用 `npm publish`。 +Workflow filename 区分大小写,并且不包含 `.github/workflows/` 前缀。正式 staging 与 Nightly +direct publish 都使用 `npm-publication` Environment;它只允许 `main`。因为 Nightly 需要自动 +运行,所以不设置 GitHub approval gate;正式发布在 staging 后仍须由 +人工使用 npm 2FA 批准。不要配置第二个 publisher 或 npm token。 第一次 OIDC Stage 成功后,将 package publishing access 设置为 **Require two-factor authentication and disallow tokens**,然后撤销不再使用的 publish token。不要在这一步移除 人工 package owner 或恢复权限。 +在 `.asf.yaml` 完成 Environment 同步且 Trusted Publisher 与其匹配前,不要设置仓库变量 +`NPM_NIGHTLY_ENABLED`。之后将它设为 `true`,先手动运行一次 Nightly,再依赖 schedule。该变量 +只控制 npm;Desktop 使用独立的 `DESKTOP_NIGHTLY_ENABLED` rollout gate。 + +## Product Nightly + +定时 `npm-publication.yml` run 会从精确的 `main` commit 生成一个类似 +`0.2.0-dev.42.20260829` 的 immutable 版本,验证四平台 `maka-agent` tarball 并发布到 +`nightly`。只有精确版本和 dist-tag 已公开后,成功的 workflow 才会触发 +`desktop-nightly.yml`。Desktop 只消费该版本;精确 source commit 来自已认证的上游 event。 +打包后的 Desktop 记录精确的 Runtime Host setup specifier,例如 +`maka-agent@0.2.0-dev.42.20260829`,绝不安装会漂移的 `nightly` tag。 + +两个 workflow 按以下顺序发布: + +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。 + +这个顺序既避免 Desktop 指向 npm 中不存在的 Runtime Host,也允许 npm Nightly 在 Desktop Infra +就绪前独立运行。npm 或 Desktop run 失败后都不得原地 rerun;应启动新的 npm Nightly: + +```sh +gh workflow run npm-publication.yml --ref main -f channel=nightly +``` + +Nightly 是开发快照,不是 Apache Release,不得从面向最终用户的下载页推广。开发者可以明确使用 +`maka-agent@nightly`;产品自动化必须使用 Desktop 记录的精确版本。 + ## 准备发布 1. 将本次包、文档和发布变更全部合并到 `main`,准备 ASF source candidate,并完成 podling 和 Incubator PMC 两轮投票; -2. 确认已批准 source commit 上的根产品版本、`apps/desktop/package.json` 与 `packages/cli/package.json` 是同一个尚未使用的目标版本。npm 渠道会把 prerelease 映射到 `next`,stable 映射到 `latest`; +2. 确认已批准 source commit 上的根产品版本、`apps/desktop/package.json` 与 + `packages/cli/package.json` 是同一个尚未使用的稳定目标版本。正式 npm 发布只推进 `latest`; 3. 从精确的已批准 `v-incubating-rc` tag dispatch 产品 `Release` workflow,并将同一个 tag 作为 `source_reference_tag`。确认其 Draft `v` Release 指向已批准 commit;npm staging 消费这个身份,不能先于它运行; 4. 确认目标版本既不在公共 registry,也不在 staged package 中: ```sh - version=0.1.0-beta.1 + version=0.2.0 npm view "maka-agent@$version" version --registry https://registry.npmjs.org/ npm stage list maka-agent --registry https://registry.npmjs.org/ ``` 第一个命令应报告目标版本不存在。如果已经存在同版本 stage,先处理它,不要再次提交; -5. 确认 `npm-release` Environment 和 Trusted Publisher 仍与上面的值一致,并确认负责批准的 +5. 确认 `npm-publication` Environment 和 Trusted Publisher 仍与上面的值一致,并确认负责批准的 npm 账号已经启用 2FA。 ## Stage 候选包 -1. 使用精确产品 tag 作为 GitHub ref dispatch workflow: +1. 从已审查的 `main` dispatch workflow,并提供精确产品版本: ```sh - version=0.1.0-beta.1 - gh workflow run release-cli-stage.yml --ref "v$version" -f version="$version" + version=0.2.0 + gh workflow run npm-publication.yml --ref main \ + -f channel=formal \ + -f version="$version" ``` -2. 确认新建的 run 使用 `v`。workflow 要求其 GitHub ref、checkout、产品 tag、Release、source commit 和 npm provenance 全部指向这一个 tag commit,并要求该 commit 仍是 `main` 的 ancestor; +2. 确认新建的 run 使用 `main`。workflow 把 `v` 与 Draft 作为数据解析,要求 tag + commit 仍是 `main` 的 ancestor,在无 OIDC 的 job 构建候选包,并把 npm provenance 绑定到 + 已审查的 `main` publisher workflow 与精确 run; 3. 等待可复用 package validation jobs 全部通过。它们只构建一个 tarball,并在 Linux x64/arm64、 macOS arm64、Windows x64 上验证安装态 CLI,在 Linux x64 上运行真实 Harbor 和 Pier Docker cell; -4. 审查并批准 `npm-release` Environment deployment; -5. 从 run summary 和 `cli-staged-release-` artifact 记录成功 Stage workflow 的 run +4. 从 run summary 和 `cli-staged-release-` artifact 记录成功 Stage workflow 的 run ID、run attempt、source commit、version 和 staged artifact checksum。 Stage workflow 没有成功结束时,不得在 npm 上批准任何内容。 @@ -151,7 +197,7 @@ node scripts/product-release-authority.mjs verify-draft \ ``` verifier 必须成功。tag 不存在、已移动、不再位于 `main`,匹配的 GitHub Release 不再是 Draft, -或 prerelease 分类与版本不一致时都必须停止。 +或被标记为 prerelease 时都必须停止。 只批准这个 stage ID。npm 会要求 2FA,并在批准时将 package 公开: @@ -161,23 +207,14 @@ npm stage approve "$stage_id" --registry https://registry.npmjs.org/ 也可以在 npmjs.com package 的 **Staged Packages** 页面完成相同的检查和批准。 -稳定版获得批准后,检查公共 dist-tags: +获得批准后,检查公共 dist-tags: ```sh version=0.1.0 npm view maka-agent dist-tags --json --registry https://registry.npmjs.org/ ``` -如果 `next` 不存在或比 `latest` 更旧,使用 npm package owner 身份进行交互式认证,并在运行 -Finalize 前将其推进到新的稳定版: - -```sh -npm dist-tag add "maka-agent@$version" next --registry https://registry.npmjs.org/ -``` - -如果 `next` 已经指向 `0.2.0-beta.1` 之类的更新版本,则不要修改。此步骤有意保留为人工操作: -npm Trusted Publishing 只认证 `npm publish` 和 `npm stage publish`,不认证 dist-tag 变更,而 -release workflow 不得获得长期 npm token。 +`latest` 必须指向获批版本;`nightly` 如果存在,保持独立。 ## Finalize 产品发布 @@ -187,7 +224,7 @@ npm 显示该版本已经公开后: 2. 输入成功 Stage 的 run ID 与精确 attempt、成功 Release build 的 run ID 与精确 attempt,以及 version; 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted - Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; + Publishing provenance 与精确的 `latest` dist-tag; 4. publication job 等待 `product-release` 批准期间,针对 Draft 完成产品检查清单中的跨机器验收; 5. 批准 Environment,并确认 workflow 将每个 live Draft digest 与精确 Release attempt 的 publication record 对比,生成并上传 `Maka--attestation.sigstore.json`,发布便利包 @@ -196,7 +233,7 @@ npm 显示该版本已经公开后: 检查最终 registry 状态: ```sh -version=0.1.0-beta.1 +version=0.2.0 npm view "maka-agent@$version" version dist.tarball dist.integrity --json npm view maka-agent dist-tags --json ``` @@ -247,17 +284,15 @@ Release 来让验证通过。如果错误发生在 publication request 之后, 先把受影响的 dist-tag 指回先前验证过的版本: ```sh -known_good=0.1.0-beta.1 -npm dist-tag add "maka-agent@$known_good" next -# 稳定版事故使用 latest,而不是 next。 +known_good=0.2.0 +npm dist-tag add "maka-agent@$known_good" latest ``` 然后只 deprecate 有缺陷的版本,并引导用户使用已经指向验证版本的恢复 dist-tag: ```sh -bad_version=0.1.0-beta.2 -recovery_tag=next -# 稳定版事故使用 latest,而不是 next。 +bad_version=0.2.1 +recovery_tag=latest npm deprecate "maka-agent@$bad_version" "Known issue; install maka-agent@$recovery_tag." ``` @@ -265,10 +300,14 @@ npm deprecate "maka-agent@$bad_version" "Known issue; install maka-agent@$recove `npm unpublish` 当作常规回滚:删除 immutable dependency bytes 会破坏现有安装,也不能恢复 经过审查的发布链。 +Nightly 存在缺陷时,dispatch 新 run,让新的 immutable 版本推进 `nightly`。如果必须立即回滚, +人工 package owner 可以把 `nightly` 指回之前验证过的 Nightly,并只 deprecate 有缺陷的精确 +版本。绝不能让 `latest` 指向 Nightly。 + ## 所有权和紧急恢复 -- GitHub repository admin 负责 `npm-release` Environment 配置;release maintainer 负责 - dispatch、Environment review、staged-package 检查、npm 2FA approval 和最终验收; +- GitHub repository admin 负责 `npm-publication` Environment 配置;release maintainer 负责 + dispatch、staged-package 检查、npm 2FA approval 和最终验收; - npm package owner 负责 Trusted Publisher、publishing access、maintainer 和 dist-tag 恢复; - trusted publishing 启用期间,至少保留一个启用 2FA 的人工 owner。移除当前 direct owner 前,先加入预期的 npm organization publishing team 和另一名人工 direct recovery diff --git a/docs/runtime-host-remote-access.md b/docs/runtime-host-remote-access.md index c98fe30fff..2eab310bc8 100644 --- a/docs/runtime-host-remote-access.md +++ b/docs/runtime-host-remote-access.md @@ -30,7 +30,7 @@ Runtime Host in one command. Linux uses a systemd user service; macOS uses a Lau requires an active GUI login session for that user. ```sh -npx --yes --package maka-agent@next maka runtime-host setup \ +npx --yes --package maka-agent@latest maka runtime-host setup \ --principal my-desktop \ --preset desktop-client \ --root "$HOME/.maka/runtime-host" \ @@ -42,7 +42,7 @@ instead of accumulating credentials. The command installs its exact Maka package directory, starts a loopback-only service, verifies the new credential, and then prints the connection details once. Use `terminal-client` for TUI or CLI. -Run `npx --yes --package maka-agent@next maka runtime-host service uninstall` on the Host to remove the service and +Run `npx --yes --package maka-agent@latest maka runtime-host service uninstall` on the Host to remove the service and managed package. The State Root and Project data are retained. ## Manual Host setup diff --git a/docs/runtime-host-remote-access.zh-CN.md b/docs/runtime-host-remote-access.zh-CN.md index a90aa21497..2d46231632 100644 --- a/docs/runtime-host-remote-access.zh-CN.md +++ b/docs/runtime-host-remote-access.zh-CN.md @@ -28,7 +28,7 @@ Maka Desktop、TUI 和 CLI 可以通过 TLS、SSH 或明确启用的明文 WebSo 在具备 Node.js 22.19 或更新版本的机器上,发布版 CLI 可以用一个命令安装并验证持久 Runtime Host。Linux 使用 systemd user service;macOS 使用 LaunchAgent,并要求该用户存在活跃的 GUI 登录会话: ```sh -npx --yes --package maka-agent@next maka runtime-host setup \ +npx --yes --package maka-agent@latest maka runtime-host setup \ --principal my-desktop \ --preset desktop-client \ --root "$HOME/.maka/runtime-host" \ @@ -37,7 +37,7 @@ npx --yes --package maka-agent@next maka runtime-host setup \ `--principal` 应使用稳定标识;重复执行会替换该 Client 的 credential,不会不断累积 credential。命令会把当前精确版本的 Maka 安装到托管目录,启动仅监听 loopback 的服务,验证新 credential,然后只显示一次连接信息。TUI 或 CLI 使用 `terminal-client`。 -在 Host 上运行 `npx --yes --package maka-agent@next maka runtime-host service uninstall` 会删除 service 与托管 package,但保留 State Root 和 Project 数据。 +在 Host 上运行 `npx --yes --package maka-agent@latest maka runtime-host service uninstall` 会删除 service 与托管 package,但保留 State Root 和 Project 数据。 ## 手动设置 Host diff --git a/package.json b/package.json index 4d37afc8cd..250e379832 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/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-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/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index a147af6210..58e86814d3 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -44,6 +44,7 @@ const RELEASE_CONTRACT_FILES = new Set([ 'apps/desktop/package.json', '.github/workflows/cli-package-validation.yml', '.github/workflows/desktop-nightly.yml', + '.github/workflows/npm-publication.yml', '.github/workflows/release-cli-finalize.yml', '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', @@ -63,6 +64,8 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/verify-macos-arm64-dmg.mjs', 'scripts/verify-macos-autoupdate.mjs', 'scripts/desktop-update-contract.mjs', + 'scripts/product-nightly.mjs', + 'scripts/product-nightly.test.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-windows-autoupdate.mjs', 'scripts/verify-windows-installer-lifecycle.mjs', diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 1da1e0afd4..e7f6ac0322 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -135,6 +135,8 @@ test('release authority changes select their dedicated contract gate', () => { 'apps/desktop/electron-builder.config.mjs', 'apps/desktop/package.json', '.github/workflows/cli-package-validation.yml', + '.github/workflows/desktop-nightly.yml', + '.github/workflows/npm-publication.yml', '.github/workflows/release-cli-finalize.yml', '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', @@ -167,13 +169,16 @@ 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', () => { +test('Product Nightly authority changes select the release contract gate', () => { for (const path of [ '.github/workflows/desktop-nightly.yml', + '.github/workflows/npm-publication.yml', 'scripts/desktop-nightly.mjs', 'scripts/desktop-nightly.test.mjs', 'scripts/desktop-nightly-stage.test.mjs', 'scripts/desktop-nightly-workflow-policy.test.mjs', + 'scripts/product-nightly.mjs', + 'scripts/product-nightly.test.mjs', ]) { assert.equal(planTests([path], { graph }).releaseContract, true, path); } diff --git a/scripts/desktop-nightly-stage.test.mjs b/scripts/desktop-nightly-stage.test.mjs index 9871f17ca5..879e34a5e3 100644 --- a/scripts/desktop-nightly-stage.test.mjs +++ b/scripts/desktop-nightly-stage.test.mjs @@ -24,7 +24,7 @@ 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'; +import { assertDesktopNightlyFeedAdvance, stageDesktopNightly } from './desktop-nightly.mjs'; import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; async function writeUpdateSet(directory, version, platform) { @@ -52,7 +52,7 @@ test('staging separates append-only payloads from the mutable Nightly feed', asy 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'; + const version = '0.2.0-dev.42.20260829'; await mkdir(input); await Promise.all([ writeUpdateSet(input, version, 'mac'), @@ -118,3 +118,27 @@ test('staging separates append-only payloads from the mutable Nightly feed', asy ]); assert.deepEqual((await readdir(join(output, 'versions', version))).sort(), payloadNames); }); + +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, + ); +}); diff --git a/scripts/desktop-nightly-workflow-policy.test.mjs b/scripts/desktop-nightly-workflow-policy.test.mjs index cc6e183215..c1897c6f4d 100644 --- a/scripts/desktop-nightly-workflow-policy.test.mjs +++ b/scripts/desktop-nightly-workflow-policy.test.mjs @@ -23,40 +23,94 @@ 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')); +async function readWorkflow(name) { + return parse(await readFile(new URL(`../.github/workflows/${name}`, import.meta.url), 'utf8')); } -test('a failed Nightly is retried only as a fresh workflow run', async () => { - const workflow = await readWorkflow(); +test('npm publication owns both npm channels and no Desktop authority', async () => { + const workflow = await readWorkflow('npm-publication.yml'); + assert.deepEqual(workflow.concurrency, { + group: "npm-publication-${{ inputs.channel || 'nightly' }}", + 'cancel-in-progress': false, + }); + assert.match(workflow.jobs.identity.if, /vars\.NPM_NIGHTLY_ENABLED == 'true'/u); + assert.equal(workflow.jobs.formal.uses, './.github/workflows/release-cli-stage.yml'); + assert.equal(workflow.jobs.formal.permissions['id-token'], 'write'); + assert.equal(workflow.jobs.cli.uses, './.github/workflows/cli-package-validation.yml'); + assert.equal(workflow.jobs.cli.with.package_version, '${{ needs.identity.outputs.version }}'); + assert.equal(workflow.jobs.publish.environment, 'npm-publication'); + assert.equal(workflow.jobs.publish.permissions['id-token'], 'write'); + const steps = workflow.jobs.publish.steps; + const positions = [ + 'Publish the exact npm Nightly', + 'Require the public npm Nightly', + 'Record the published Product Nightly version', + 'Hand the exact version to Desktop Nightly', + ].map((name) => steps.findIndex((step) => step.name === name)); + assert.deepEqual( + positions, + positions.toSorted((left, right) => left - right), + ); + assert.ok(positions.every((position) => position >= 0)); + assert.doesNotMatch(JSON.stringify(workflow), /DESKTOP_NIGHTLY_ENABLED|NIGHTLIES_RSYNC/u); + assert.doesNotMatch(JSON.stringify(workflow), /NODE_AUTH_TOKEN|NPM_TOKEN/u); +}); + +test('Desktop Nightly starts only from a successful published npm identity', async () => { + const workflow = await readWorkflow('desktop-nightly.yml'); + assert.deepEqual(workflow.on, { + workflow_run: { + workflows: ['npm publication'], + types: ['completed'], + }, + }); + assert.match(workflow.jobs.identity.if, /vars\.DESKTOP_NIGHTLY_ENABLED == 'true'/u); + assert.match(workflow.jobs.identity.if, /workflow_run\.conclusion == 'success'/u); + assert.match(workflow.jobs.identity.if, /workflow_run\.head_branch == 'main'/u); + assert.match(workflow.jobs.identity.if, /display_title == 'npm nightly publication'/u); + const download = workflow.jobs.identity.steps.find( + (step) => step.name === 'Download the published Nightly version', + ); + assert.equal(download.with.name, 'product-nightly-version'); + assert.equal(download.with['run-id'], '${{ github.event.workflow_run.id }}'); + const bind = workflow.jobs.identity.steps.find( + (step) => step.name === 'Bind Desktop to the exact npm Nightly version', + ); + assert.match(bind.run, /product-nightly\.mjs inspect-version/u); + assert.equal( + workflow.jobs.desktop.env.MAKA_DESKTOP_NIGHTLY_VERSION, + '${{ needs.identity.outputs.version }}', + ); + assert.doesNotMatch(JSON.stringify(workflow), /npm publish|npm stage publish/u); +}); + +test('a failed Desktop Nightly is retried through a fresh npm Nightly', async () => { + const workflow = await readWorkflow('desktop-nightly.yml'); assert.deepEqual(workflow.concurrency, { group: 'desktop-nightly', 'cancel-in-progress': false, }); - assert.equal(workflow.jobs.identity.if, "vars.DESKTOP_NIGHTLY_ENABLED == 'true'"); 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.match(rerunGuard.run, /fresh npm Nightly dispatch/u); } - 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@'), ); - const download = workflow.jobs.publish.steps.find((step) => - step.uses?.startsWith('actions/download-artifact@'), + const download = workflow.jobs.publish.steps.find( + (step) => step.uses?.startsWith('actions/download-artifact@') && step.with?.pattern, ); 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(); +test('the protected Desktop publisher appends payloads before advancing the feed', 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, @@ -72,39 +126,11 @@ test('the protected publisher appends workspace-staged payloads before advancing ); 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 readWorkflow(); - assert.equal(workflow.permissions.contents, 'read'); - 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', - ); - 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 readWorkflow(); - const steps = workflow.jobs.publish.steps; + const steps = publish.steps; const positions = [ 'Attest the exact Nightly payloads', 'Verify the issued Nightly provenance', + 'Require the Desktop Nightly feed to advance', 'Publish immutable Nightly payloads', 'Advance the Nightly update feed last', ].map((name) => steps.findIndex((step) => step.name === name)); @@ -113,10 +139,16 @@ test('Nightly verifies provenance and advances mutable feeds only after payload positions.toSorted((left, right) => left - right), ); assert.ok(positions.every((position) => position >= 0)); - const verify = steps[positions[1]]; assert.equal( - verify.env.CERTIFICATE_IDENTITY, + steps[positions[1]].env.CERTIFICATE_IDENTITY, 'https://github.com/${{ github.repository }}/.github/workflows/desktop-nightly.yml@refs/heads/main', ); - assert.match(verify.run, /gh attestation verify/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); + assert.doesNotMatch(step.run, /--delete/u); + } }); diff --git a/scripts/desktop-nightly.mjs b/scripts/desktop-nightly.mjs index b8235449b1..927f7fe1f1 100644 --- a/scripts/desktop-nightly.mjs +++ b/scripts/desktop-nightly.mjs @@ -17,40 +17,17 @@ * under the License. */ -import { - appendFile, - copyFile, - mkdir, - readFile, - readdir, - rm, - stat, - writeFile, -} from 'node:fs/promises'; +import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; -import { parseProductReleaseVersion } from './release-version.mjs'; +import { assertProductNightlyAdvances, 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) { - 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; + return assertProductNightlyVersion(version, productVersion); } export function resolveDesktopBuildVersion(productVersion, environment = process.env) { @@ -60,6 +37,33 @@ export function resolveDesktopBuildVersion(productVersion, environment = process : productVersion; } +export function resolveRuntimeHostSetupPackage(productVersion, environment = process.env) { + 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; +} + function nightlyArtifactNames(version) { return { macZip: `Maka-${version}-mac-arm64.zip`, @@ -175,26 +179,8 @@ export async function stageDesktopNightly({ await writeFile(join(feedDirectory, 'index.html'), nightlyIndex(version, sourceCommit, names)); } -async function main(args, environment = process.env) { +async function main(args) { 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; await stageDesktopNightly({ @@ -205,34 +191,21 @@ async function main(args, environment = process.env) { }); 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, + }); + return; + } throw new Error( - 'usage: desktop-nightly.mjs identity | stage ', + 'usage: desktop-nightly.mjs stage | assert-feed-advance ', ); } 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 index 76922eac7a..2446914b9d 100644 --- a/scripts/desktop-nightly.test.mjs +++ b/scripts/desktop-nightly.test.mjs @@ -19,74 +19,24 @@ 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'; +import { resolveDesktopBuildVersion, resolveRuntimeHostSetupPackage } 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({ - 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('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 version = '0.2.0-dev.42.20260829'; const config = resolveDesktopBuilderConfig({ MAKA_DESKTOP_NIGHTLY_VERSION: version, }); assert.equal(config.extraMetadata.version, version); + assert.equal(config.extraMetadata.runtimeHostSetupPackage, `maka-agent@${version}`); assert.equal(config.extraMetadata.makaUpdateChannel, 'nightly'); assert.deepEqual(config.publish, [ { @@ -109,7 +59,7 @@ test('formal release checks ignore the ambient Nightly packaging environment', a cwd: repoRoot, env: { ...environment, - MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.20260829.42', + MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.42.20260829', }, }, ); @@ -118,9 +68,15 @@ test('formal release checks ignore the ambient Nightly packaging environment', a 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', + MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.42.20260829', }), - '0.2.0-dev.20260829.42', + '0.2.0-dev.42.20260829', ); assert.equal(resolveDesktopBuildVersion('0.2.0', {}), '0.2.0'); + assert.equal( + resolveRuntimeHostSetupPackage('0.2.0', { + MAKA_DESKTOP_NIGHTLY_VERSION: '0.2.0-dev.42.20260829', + }), + 'maka-agent@0.2.0-dev.42.20260829', + ); }); diff --git a/scripts/product-nightly.mjs b/scripts/product-nightly.mjs new file mode 100644 index 0000000000..c0176477a1 --- /dev/null +++ b/scripts/product-nightly.mjs @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { appendFile, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + assertProductNightlyAdvances, + assertProductNightlyVersion, + parseProductReleaseVersion, +} from './release-version.mjs'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +export function productNightlyIdentity({ productVersion, date, runNumber, sourceCommit }) { + if (parseProductReleaseVersion(productVersion).prerelease.length > 0) { + throw new Error('Product Nightly requires a stable checked-in product version'); + } + if (!(date instanceof Date) || !Number.isFinite(date.getTime())) { + throw new Error('Product Nightly requires a valid build date'); + } + if (typeof runNumber !== 'string' || !/^[1-9]\d*$/u.test(runNumber)) { + throw new Error('Product Nightly requires a positive run number'); + } + if (typeof sourceCommit !== 'string' || !/^[0-9a-f]{40}$/u.test(sourceCommit)) { + throw new Error('Product Nightly requires an exact source commit'); + } + + const day = date.toISOString().slice(0, 10).replaceAll('-', ''); + const version = `${productVersion}-dev.${runNumber}.${day}`; + assertProductNightlyVersion(version, productVersion); + return { + version, + sourceCommit, + }; +} + +export function parseProductNightlyVersionFile(source, productVersion) { + if (typeof source !== 'string' || !source.endsWith('\n') || source.slice(0, -1).includes('\n')) { + throw new Error( + 'Product Nightly version file must contain exactly one newline-terminated line', + ); + } + const version = source.slice(0, -1); + return assertProductNightlyVersion(version, productVersion); +} + +async function main(args, environment = process.env) { + const [command, ...rest] = args; + const productManifest = JSON.parse(await readFile(join(repoRoot, 'package.json'), 'utf8')); + if (command === 'identity' && rest.length === 0) { + const identity = productNightlyIdentity({ + 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 === 'write-version' && rest.length === 2) { + const [output, version] = rest; + assertProductNightlyVersion(version, productManifest.version); + await writeFile(output, `${version}\n`, 'utf8'); + return; + } + if (command === 'inspect-version' && rest.length === 2) { + const [input, output] = rest; + const version = parseProductNightlyVersionFile( + await readFile(input, 'utf8'), + productManifest.version, + ); + await appendFile(output, `version=${version}\n`, 'utf8'); + return; + } + if (command === 'assert-channel-advance' && (rest.length === 1 || rest.length === 2)) { + const [candidateVersion, currentVersion = ''] = rest; + assertProductNightlyAdvances(candidateVersion, currentVersion, productManifest.version); + return; + } + throw new Error( + 'usage: product-nightly.mjs identity | write-version | inspect-version | assert-channel-advance [current-version]', + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(process.argv.slice(2)); +} diff --git a/scripts/product-nightly.test.mjs b/scripts/product-nightly.test.mjs new file mode 100644 index 0000000000..b9bdce0b75 --- /dev/null +++ b/scripts/product-nightly.test.mjs @@ -0,0 +1,131 @@ +/* + * 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 { execFile } from 'node:child_process'; +import { copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { parseProductNightlyVersionFile, productNightlyIdentity } from './product-nightly.mjs'; +import { assertProductNightlyAdvances } from './release-version.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( + productNightlyIdentity({ + productVersion: '0.2.0', + date: new Date('2026-08-29T18:17:00Z'), + runNumber: '42', + sourceCommit: 'a'.repeat(40), + }), + { + version: '0.2.0-dev.42.20260829', + sourceCommit: 'a'.repeat(40), + }, + ); +}); + +test('the version handoff contains only the exact npm Nightly version', () => { + assert.equal( + parseProductNightlyVersionFile('0.2.0-dev.42.20260829\n', '0.2.0'), + '0.2.0-dev.42.20260829', + ); + for (const source of [ + '0.2.0-dev.42.20260829', + '0.2.0-dev.42.20260829\nextra\n', + '{"version":"0.2.0-dev.42.20260829"}\n', + ]) { + assert.throws(() => parseProductNightlyVersionFile(source, '0.2.0')); + } +}); + +test('the version handoff CLI passes only the exact version to Desktop', async (t) => { + const fixture = await mkdtemp(join(tmpdir(), 'maka-product-nightly-record-')); + t.after(() => rm(fixture, { recursive: true, force: true })); + const record = join(fixture, 'version.txt'); + const output = join(fixture, 'github-output'); + const script = join(repoRoot, 'scripts', 'product-nightly.mjs'); + const version = '0.2.0-dev.42.20260829'; + await run(process.execPath, [script, 'write-version', record, version]); + await run(process.execPath, [script, 'inspect-version', record, output]); + assert.equal(await readFile(output, 'utf8'), `version=${version}\n`); +}); + +test('run number is the single monotonic Nightly authority across dates and product versions', () => { + assert.equal( + assertProductNightlyAdvances('0.2.0-dev.43.20260828', '0.2.0-dev.42.20260829', '0.2.0'), + '0.2.0-dev.43.20260828', + ); + assert.equal( + assertProductNightlyAdvances('0.3.0-dev.43.20260830', '0.2.0-dev.42.20260829', '0.3.0'), + '0.3.0-dev.43.20260830', + ); + for (const candidate of ['0.2.0-dev.42.20260830', '0.2.0-dev.41.20260830']) { + assert.throws( + () => assertProductNightlyAdvances(candidate, '0.2.0-dev.42.20260829', '0.2.0'), + /does not advance current run/u, + ); + } +}); + +test('the npm channel CLI advances across the checked-in product version boundary', async () => { + await assert.doesNotReject( + run(process.execPath, [ + join(repoRoot, 'scripts', 'product-nightly.mjs'), + 'assert-channel-advance', + '0.2.0-dev.43.20260830', + '0.1.0-dev.42.20260829', + ]), + ); +}); + +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', 'product-nightly.mjs'), + join(fixture, 'scripts', 'product-nightly.mjs'), + ), + copyFile( + join(repoRoot, 'scripts', 'release-version.mjs'), + join(fixture, 'scripts', 'release-version.mjs'), + ), + ]); + + const { stdout } = await run(process.execPath, ['scripts/product-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.42.20260829', + sourceCommit: 'a'.repeat(40), + }); +}); diff --git a/scripts/product-release-artifacts.mjs b/scripts/product-release-artifacts.mjs index 5f2e645d2d..c4767a49cc 100644 --- a/scripts/product-release-artifacts.mjs +++ b/scripts/product-release-artifacts.mjs @@ -40,7 +40,6 @@ const PUBLICATION_RECORD_KEYS = [ 'sourceCommit', 'tag', 'version', - 'prerelease', 'assets', ]; @@ -186,7 +185,7 @@ function assertRepository(repository) { export function assertProductReleasePublicationRecord(record, expected = {}) { exactKeys(record, PUBLICATION_RECORD_KEYS, 'Product release publication record'); - if (record.schemaVersion !== 1) { + if (record.schemaVersion !== 2) { throw new Error('Unsupported product release publication record'); } assertRepository(record.repository); @@ -200,9 +199,9 @@ export function assertProductReleasePublicationRecord(record, expected = {}) { const product = parseProductTag(record.tag); const source = parseAsfSourceReferenceTag(record.sourceReferenceTag); if ( + product.prerelease.length > 0 || record.version !== product.version || - source.version !== product.version || - record.prerelease !== product.prerelease.length > 0 + source.version !== product.version ) { throw new Error('Product release publication identity is inconsistent'); } @@ -251,7 +250,7 @@ export async function createProductReleasePublicationRecord({ assertRunIdentity(runId, runAttempt); await verifyProductReleaseArtifactIntegrity(artifactDirectory, identity); return assertProductReleasePublicationRecord({ - schemaVersion: 1, + schemaVersion: 2, repository, workflow: PRODUCT_RELEASE_WORKFLOW, runId, @@ -260,7 +259,6 @@ export async function createProductReleasePublicationRecord({ sourceCommit: identity.sourceCommit, tag: identity.tag, version: identity.version, - prerelease: identity.isPrerelease, assets: await artifactRecords(artifactDirectory, allArtifactNames(identity)), }); } diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs index 52b2dde783..b49388db5e 100644 --- a/scripts/product-release-authority.mjs +++ b/scripts/product-release-authority.mjs @@ -36,10 +36,12 @@ const execFileAsync = promisify(execFile); function expectedReleaseIdentity(tag) { const { prerelease, version } = parseProductTag(tag); + if (prerelease.length > 0) { + throw new Error('Formal product releases require a stable product tag'); + } return { tag, version, - isPrerelease: prerelease.length > 0, attestationName: `Maka-${version}-attestation.sigstore.json`, }; } @@ -57,19 +59,19 @@ export function assertDraftProductRelease(release, tag) { if (release.draft !== true) { throw new Error(`GitHub Release ${tag} must remain a Draft`); } - if (release.prerelease !== expected.isPrerelease) { - throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); + if (release.prerelease !== false) { + throw new Error(`GitHub Release ${tag} must not be a prerelease`); } return release; } export function assertPublishedProductRelease(release, tag, releaseId, expectedAssets) { - const expected = expectedReleaseIdentity(tag); + expectedReleaseIdentity(tag); if (!release || release.id !== releaseId || release.tag !== tag || release.draft !== false) { throw new Error(`GitHub Release ${tag} was not published`); } - if (release.prerelease !== expected.isPrerelease) { - throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); + if (release.prerelease !== false) { + throw new Error(`GitHub Release ${tag} must not be a prerelease`); } if (JSON.stringify(release.assets) !== JSON.stringify(expectedAssets)) { throw new Error(`GitHub Release ${tag} assets changed during publication`); @@ -242,7 +244,6 @@ export async function publishDraftProductRelease({ throw new Error('Draft GitHub Release does not contain the exact attestation bundle'); } - const isPrerelease = releaseIdentity.isPrerelease; const published = await run( 'gh', [ @@ -255,9 +256,9 @@ export async function publishDraftProductRelease({ '-F', 'draft=false', '-F', - `prerelease=${isPrerelease}`, + 'prerelease=false', '-f', - `make_latest=${isPrerelease ? 'false' : 'true'}`, + 'make_latest=true', ], { cwd }, ); @@ -269,23 +270,21 @@ export async function publishDraftProductRelease({ } assertPublishedProductRelease(record, tag, draft.id, expectedAssets); - if (!isPrerelease) { - let latestTag; - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - const latest = await run('gh', ['api', `repos/${repository}/releases/latest`], { cwd }); - latestTag = JSON.parse(latest.stdout).tag_name; - } catch (error) { - if (attempt === 4) { - throw new Error('GitHub returned an invalid Latest release record', { cause: error }); - } + let latestTag; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + const latest = await run('gh', ['api', `repos/${repository}/releases/latest`], { cwd }); + latestTag = JSON.parse(latest.stdout).tag_name; + } catch (error) { + if (attempt === 4) { + throw new Error('GitHub returned an invalid Latest release record', { cause: error }); } - if (latestTag === tag) break; - if (attempt < 4) await pause(1_000); - } - if (latestTag !== tag) { - throw new Error(`Stable release ${tag} was published but Latest points to ${latestTag}`); } + if (latestTag === tag) break; + if (attempt < 4) await pause(1_000); + } + if (latestTag !== tag) { + throw new Error(`Stable release ${tag} was published but Latest points to ${latestTag}`); } return record; } diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs index f8bf6d7070..55a48d1d02 100644 --- a/scripts/product-release-authority.test.mjs +++ b/scripts/product-release-authority.test.mjs @@ -71,7 +71,6 @@ test('publication verifies live asset digests before one Stable/Latest mutation' artifactDirectory: directory, identity: { version, - isPrerelease: false, tag: `v${version}`, sourceReferenceTag: `v${version}-incubating-rc1`, sourceCommit, diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index 3e868d9b2a..9464d3d806 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -59,7 +59,8 @@ export function parseAsfSourceReferenceTag(tag) { throw new Error('ASF source reference must match v-incubating-rc'); } try { - parseProductReleaseVersion(match[1]); + const product = parseProductReleaseVersion(match[1]); + if (product.prerelease.length > 0) throw new Error('prerelease'); } catch { throw new Error('ASF source reference must match v-incubating-rc'); } @@ -68,9 +69,8 @@ export function parseAsfSourceReferenceTag(tag) { export function resolveProductManifestIdentity({ rootManifest, desktopManifest, cliManifest }) { const { version, prerelease } = parseProductReleaseVersion(rootManifest.version); - const channel = prerelease[0]; - if (channel !== undefined && channel !== 'alpha' && channel !== 'beta') { - throw new Error('Product prerelease channel must be alpha or beta'); + if (prerelease.length > 0) { + throw new Error('Formal product releases require a stable version'); } for (const [label, manifest] of [ ['Desktop', desktopManifest], @@ -88,7 +88,6 @@ export function resolveProductManifestIdentity({ rootManifest, desktopManifest, return { version, - isPrerelease: prerelease.length > 0, runtimeHostSetupPackage: `maka-agent@${version}`, publicCommands: ['maka'], }; @@ -151,24 +150,25 @@ export function resolveProductReleaseIdentity({ }; } -async function readProductManifests() { +async function readProductManifests(root = repoRoot) { const [rootManifest, desktopManifest, cliManifest] = await Promise.all([ - readFile(join(repoRoot, 'package.json'), 'utf8').then(JSON.parse), - readFile(join(repoRoot, 'apps/desktop/package.json'), 'utf8').then(JSON.parse), - readFile(join(repoRoot, 'packages/cli/package.json'), 'utf8').then(JSON.parse), + readFile(join(root, 'package.json'), 'utf8').then(JSON.parse), + readFile(join(root, 'apps/desktop/package.json'), 'utf8').then(JSON.parse), + readFile(join(root, 'packages/cli/package.json'), 'utf8').then(JSON.parse), ]); return { rootManifest, desktopManifest, cliManifest }; } -export async function readProductManifestIdentity() { - return resolveProductManifestIdentity(await readProductManifests()); +export async function readProductManifestIdentity({ root = repoRoot } = {}) { + return resolveProductManifestIdentity(await readProductManifests(root)); } export async function readProductReleaseIdentity({ sha, sourceReferenceTag = process.env.SOURCE_REFERENCE_TAG, + root = process.env.PRODUCT_MANIFEST_ROOT ?? repoRoot, } = {}) { - const { rootManifest, desktopManifest, cliManifest } = await readProductManifests(); + const { rootManifest, desktopManifest, cliManifest } = await readProductManifests(root); const sourceCommit = sha ?? process.env.GITHUB_SHA ?? @@ -185,7 +185,6 @@ export async function readProductReleaseIdentity({ function githubOutputEntries(identity) { return { version: identity.version, - is_prerelease: identity.isPrerelease, tag: identity.tag, source_commit: identity.sourceCommit, source_reference_tag: identity.sourceReferenceTag, diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index aa9d02eb7c..1c9262a401 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -78,7 +78,6 @@ test('one root version defines every product artifact from one source commit', ( }); assert.equal(identity.version, '1.2.3'); - assert.equal(identity.isPrerelease, false); assert.equal(identity.tag, 'v1.2.3'); assert.equal(identity.sourceCommit, 'a'.repeat(40)); assert.equal(identity.sourceReferenceTag, 'v1.2.3-incubating-rc2'); @@ -144,32 +143,8 @@ printf verified assert.equal(stdout, 'verified'); }); -test('the product identity classifies prereleases once for every publication surface', () => { - const version = '1.2.3-beta.2'; - const identity = resolveProductReleaseIdentity({ - rootManifest: { ...rootManifest, version }, - desktopManifest: { version }, - cliManifest: { version, bin: { maka: './dist/cli.js' } }, - sha: 'a'.repeat(40), - sourceReferenceTag: `v${version}-incubating-rc1`, - }); - - assert.equal(identity.isPrerelease, true); - assert.equal(identity.tag, `v${version}`); -}); - -test('product prereleases use only updater-compatible alpha and beta channels', () => { - for (const version of ['1.2.3-alpha.1', '1.2.3-beta.2']) { - assert.equal( - resolveProductManifestIdentity({ - rootManifest: { ...rootManifest, version }, - desktopManifest: { version }, - cliManifest: { version, bin: { maka: './dist/cli.js' } }, - }).isPrerelease, - true, - ); - } - for (const version of ['1.2.3-rc.1', '1.2.3-dev.1']) { +test('formal product identity rejects every prerelease channel', () => { + for (const version of ['1.2.3-alpha.1', '1.2.3-beta.2', '1.2.3-rc.1', '1.2.3-dev.1']) { assert.throws( () => resolveProductManifestIdentity({ @@ -177,7 +152,7 @@ test('product prereleases use only updater-compatible alpha and beta channels', desktopManifest: { version }, cliManifest: { version, bin: { maka: './dist/cli.js' } }, }), - /prerelease channel must be alpha or beta/u, + /require a stable version/u, ); } }); @@ -721,17 +696,16 @@ test('one product workflow gates one draft release on every required artifact', const publishRelease = jobs.publish.steps.find( (step) => step.name === 'Create or update the draft GitHub Release', ).run; + assert.equal(Object.hasOwn(jobs['release-identity'].outputs, 'is_prerelease'), false); assert.equal( - jobs['release-identity'].outputs.is_prerelease, - '${{ steps.identity.outputs.is_prerelease }}', - ); - assert.equal( - jobs.publish.steps.find((step) => step.name === 'Create or update the draft GitHub Release').env - .IS_PRERELEASE, - '${{ needs.release-identity.outputs.is_prerelease }}', + Object.hasOwn( + jobs.publish.steps.find((step) => step.name === 'Create or update the draft GitHub Release') + .env, + 'IS_PRERELEASE', + ), + false, ); - assert.match(publishRelease, /classification=\(--prerelease=false --latest=false\)/u); - assert.match(publishRelease, /classification=\(--prerelease --latest=false\)/u); + assert.match(publishRelease, /--prerelease=false/u); assert.doesNotMatch(publishRelease, /--latest(?:\s|\\|$)/u); assert.match(publishRelease, /--json isPrerelease/u); assert.doesNotMatch(publishRelease, /gh release delete-asset/u); @@ -753,7 +727,6 @@ test('repository control plane admits only each release phase owner ref', async const environments = config.github.environments; for (const [name, pattern, type] of [ ['release', 'v*-incubating-rc*', 'tag'], - ['npm-release', 'v*', 'tag'], ['product-release', 'main', 'branch'], ]) { assert.deepEqual(environments[name], { @@ -766,6 +739,15 @@ test('repository control plane admits only each release phase owner ref', async }, }); } + assert.deepEqual(environments['npm-publication'], { + required_reviewers: [], + wait_timer: 0, + prevent_self_review: false, + deployment_branch_policy: { + protected_branches: false, + policies: [{ name: 'main', type: 'branch' }], + }, + }); assert.deepEqual( config.github.rulesets.find((ruleset) => ruleset.name === 'Immutable release tags'), { diff --git a/scripts/release-cli-eval-support.mjs b/scripts/release-cli-eval-support.mjs index c2ad4b8441..8cd3665544 100644 --- a/scripts/release-cli-eval-support.mjs +++ b/scripts/release-cli-eval-support.mjs @@ -41,6 +41,10 @@ export function findEvalReleaseTarball( if (platform !== 'linux' || arch !== 'x64') { throw new Error('The real Eval release validation requires Linux x64'); } + return findReleaseTarball(releaseDirectory); +} + +export function findReleaseTarball(releaseDirectory) { const tarballs = readdirSync(releaseDirectory) .filter((name) => /^maka-agent-[^/]+\.tgz$/u.test(name)) .map((name) => join(releaseDirectory, name)); diff --git a/scripts/release-cli-eval-support.test.mjs b/scripts/release-cli-eval-support.test.mjs index 6451ace3a7..c8843e9016 100644 --- a/scripts/release-cli-eval-support.test.mjs +++ b/scripts/release-cli-eval-support.test.mjs @@ -25,6 +25,7 @@ import { describe, test } from 'node:test'; import { createTaskFixture, findEvalReleaseTarball, + findReleaseTarball, isolatedEnvironment, readFrameworkOutputs, } from './release-cli-eval-support.mjs'; @@ -42,6 +43,19 @@ describe('installed Eval release validation support', () => { } }); + test('resolves the one built tarball instead of assuming the checked-in version', () => { + const root = mkdtempSync(join(tmpdir(), 'maka-release-tarball-')); + try { + const tarball = join(root, 'maka-agent-0.2.0-dev.42.20260829.tgz'); + writeFileSync(tarball, 'candidate'); + assert.equal(findReleaseTarball(root), tarball); + writeFileSync(join(root, 'maka-agent-0.2.0.tgz'), 'other candidate'); + assert.throws(() => findReleaseTarball(root), /Expected one release tarball/u); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test('creates the Git fixture without inheriting host Git configuration', () => { const root = mkdtempSync(join(tmpdir(), 'maka-eval-support-git-')); const globalConfig = join(root, 'host.gitconfig'); diff --git a/scripts/release-cli-package.mjs b/scripts/release-cli-package.mjs index da67bac7e9..b471ea8c76 100644 --- a/scripts/release-cli-package.mjs +++ b/scripts/release-cli-package.mjs @@ -38,6 +38,7 @@ import { tmpdir } from 'node:os'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { npmSpawnOptions } from './npm-spawn.mjs'; import { validateCliReleaseArtifactMetrics } from './release-cli-artifact-policy.mjs'; +import { assertProductNightlyVersion } from './release-version.mjs'; import { isCurrentDevelopmentJavaScript, isMakaDevelopmentArtifact, @@ -53,6 +54,7 @@ const repoRoot = resolve(import.meta.dirname, '..'); const cliSource = join(repoRoot, 'packages/cli'); const allowDirty = process.argv.includes('--allow-dirty'); const developmentBuild = process.argv.includes('--development'); +const nightlyVersion = process.env.MAKA_CLI_NIGHTLY_VERSION?.trim(); const preparedTree = process.env.MAKA_CLI_RELEASE_PREPARED_TREE === '1'; const releaseRoot = join(cliSource, 'release'); const artifactRoot = developmentBuild ? createDevelopmentArtifactRoot() : releaseRoot; @@ -93,6 +95,16 @@ try { function main() { validateNodeVersion(); + if (developmentBuild && nightlyVersion) { + throw new Error('A public Nightly cannot be combined with --development'); + } + if (allowDirty && nightlyVersion) { + throw new Error('A public Nightly cannot be built from a dirty worktree'); + } + if (nightlyVersion) { + const productVersion = readJson(join(repoRoot, 'package.json')).version; + assertProductNightlyVersion(nightlyVersion, productVersion); + } if (!developmentBuild) validateReleaseNpmVersion(); if (developmentBuild) { if (allowDirty || preparedTree) { @@ -520,13 +532,24 @@ function copyEvalMirror() { function copyReleaseDocuments() { const readme = readFileSync(join(cliSource, 'README.md'), 'utf8'); + const readmeZhCn = readFileSync(join(cliSource, 'README.zh-CN.md'), 'utf8'); const disclaimer = readFileSync(join(repoRoot, 'DISCLAIMER-WIP'), 'utf8'); - writeFileSync(join(stageRoot, 'README.md'), renderNpmReadme(readme, disclaimer), 'utf8'); - copyFileSync(join(cliSource, 'README.zh-CN.md'), join(stageRoot, 'README.zh-CN.md')); + const nightlyNotice = nightlyVersion + ? '> **Developer snapshot:** This Nightly is not an Apache release and is not intended for production use. Its version is bound to one exact `apache/maka` commit.\n\n' + : ''; + const nightlyNoticeZhCn = nightlyVersion + ? '> **开发快照:**此 Nightly 不是 Apache Release,不用于生产环境;其版本只对应一个精确的 `apache/maka` commit。\n\n' + : ''; + writeFileSync( + join(stageRoot, 'README.md'), + `${nightlyNotice}${renderNpmReadme(readme, disclaimer)}`, + 'utf8', + ); + writeFileSync(join(stageRoot, 'README.zh-CN.md'), `${nightlyNoticeZhCn}${readmeZhCn}`, 'utf8'); copyFileSync(join(repoRoot, 'LICENSE'), join(stageRoot, 'LICENSE')); copyFileSync(join(repoRoot, 'NOTICE'), join(stageRoot, 'NOTICE')); - // Incubator policy: podling releases carry the incubating disclaimer, kept - // next to LICENSE/NOTICE. The npm tarball is a release like the installers. + // Incubator policy: every public podling artifact carries the incubating + // disclaimer next to LICENSE/NOTICE, including developer-only Nightlies. copyFileSync(join(repoRoot, 'DISCLAIMER-WIP'), join(stageRoot, 'DISCLAIMER-WIP')); copyFileSync( join(cliSource, 'THIRD_PARTY_NOTICES.txt'), @@ -653,8 +676,10 @@ function writeReleaseManifest(cli, publishable) { } const manifest = { name: source.name, - version: source.version, - description: 'Apache Maka (Incubating), a local-first agent workspace for the terminal.', + version: publishable && nightlyVersion ? nightlyVersion : source.version, + description: nightlyVersion + ? 'Apache Maka (Incubating) developer snapshot; not an Apache release.' + : 'Apache Maka (Incubating), a local-first agent workspace for the terminal.', license: source.license, type: source.type, exports: {}, @@ -673,7 +698,7 @@ function writeReleaseManifest(cli, publishable) { ? { access: 'public', registry: 'https://registry.npmjs.org/', - tag: source.version.includes('-') ? 'next' : 'latest', + tag: nightlyVersion ? 'nightly' : 'latest', } : { access: 'restricted', diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index d1ae439f6e..82ba186aa8 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -22,12 +22,12 @@ import { basename, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createHash } from 'node:crypto'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; -import { compareProductReleaseVersions, parseProductReleaseVersion } from './release-version.mjs'; +import { assertProductNightlyVersion, parseProductReleaseVersion } from './release-version.mjs'; const PACKAGE_NAME = 'maka-agent'; const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; const REPOSITORY = 'apache/maka'; -const STAGE_WORKFLOW_PATH = '.github/workflows/release-cli-stage.yml'; +const PUBLICATION_WORKFLOW_PATH = '.github/workflows/npm-publication.yml'; const RELEASE_RECORD_KEYS = [ 'schemaVersion', 'packageName', @@ -39,13 +39,26 @@ const RELEASE_RECORD_KEYS = [ 'checksum', 'inventory', 'source', + 'publisher', ]; export function parseCliReleaseVersion(version) { const { prerelease } = parseProductReleaseVersion(version); + if (prerelease.length > 0) { + throw new Error('Formal CLI releases must use a stable product version'); + } + return { + version, + distTag: 'latest', + tarball: `${PACKAGE_NAME}-${version}.tgz`, + }; +} + +export function parseCliNightlyVersion(version, productVersion) { + assertProductNightlyVersion(version, productVersion); return { version, - distTag: prerelease.length > 0 ? 'next' : 'latest', + distTag: 'nightly', tarball: `${PACKAGE_NAME}-${version}.tgz`, }; } @@ -57,17 +70,16 @@ export function validateRegistryChannels({ releaseVersion, releaseDistTag, distT if (distTags[releaseDistTag] !== releaseVersion) { throw new Error(`Registry dist-tag ${releaseDistTag} does not point to ${releaseVersion}`); } +} - const latest = distTags.latest; - const next = distTags.next; - if (releaseDistTag === 'latest' && typeof next !== 'string') { - throw channelLagError({ releaseVersion, releaseDistTag, latest, next }); - } - if (typeof latest === 'string' && typeof next === 'string') { - if (compareProductReleaseVersions(next, latest) < 0) { - throw channelLagError({ releaseVersion, releaseDistTag, latest, next }); - } +export function prepareNightlyRelease({ repoRoot, releaseDirectory, expectedVersion }) { + const cliManifest = readJson(join(repoRoot, 'packages/cli/package.json'), 'CLI manifest'); + if (cliManifest.name !== PACKAGE_NAME) { + throw new Error(`CLI package name must be ${PACKAGE_NAME}`); } + const identity = parseCliNightlyVersion(expectedVersion, cliManifest.version); + const candidate = validateCandidateFiles(releaseDirectory, identity); + return { ...identity, tarballPath: candidate.tarballPath, sha256: candidate.sha256 }; } export function prepareStageRelease({ @@ -76,6 +88,7 @@ export function prepareStageRelease({ expectedVersion, productTag, sourceSha, + publisherSha, runId, runAttempt, repository, @@ -85,25 +98,20 @@ export function prepareStageRelease({ if (cliManifest.name !== PACKAGE_NAME) { throw new Error(`CLI package name must be ${PACKAGE_NAME}`); } - const identity = parseCliReleaseVersion(cliManifest.version); - if (expectedVersion !== identity.version) { - throw new Error( - `Release version confirmation ${expectedVersion} does not match ${identity.version}`, - ); - } + const identity = parseCliReleaseVersion(expectedVersion); if (productTag !== `v${identity.version}`) { throw new Error(`Product tag ${productTag} does not match ${identity.version}`); } - validateSourceIdentity({ - sourceSha, + validateProductSource({ sourceSha, repository }); + validatePublisherIdentity({ + publisherSha, runId, runAttempt, - repository, workflowPath, }); const candidate = validateCandidateFiles(releaseDirectory, identity); const record = { - schemaVersion: 3, + schemaVersion: 4, packageName: PACKAGE_NAME, ...identity, productTag, @@ -112,8 +120,11 @@ export function prepareStageRelease({ inventory: `${identity.tarball}.files.json`, source: { repository, - workflow: workflowPath, commit: sourceSha, + }, + publisher: { + workflow: workflowPath, + commit: publisherSha, runId, runAttempt, }, @@ -134,17 +145,17 @@ export function validateStageRun({ releaseDirectory, expectedVersion, run }) { } if ( !run || - String(run.id) !== record.source.runId || - String(run.run_attempt) !== record.source.runAttempt || - run.path !== record.source.workflow || + String(run.id) !== record.publisher.runId || + String(run.run_attempt) !== record.publisher.runAttempt || + run.path !== record.publisher.workflow || run.event !== 'workflow_dispatch' || - run.head_branch !== record.productTag || - run.head_sha !== record.source.commit || + run.head_branch !== 'main' || + run.head_sha !== record.publisher.commit || run.conclusion !== 'success' || run.head_repository?.full_name !== record.source.repository ) { throw new Error( - 'Release record does not belong to the exact successful product-tag stage workflow run', + 'Release record does not belong to the exact successful main-branch stage workflow run', ); } return record; @@ -250,7 +261,7 @@ export function prepareSignatureAuditTree({ releaseDirectory, auditDirectory }) function loadReleaseRecord(releaseDirectory) { const record = readJson(join(releaseDirectory, 'release.json'), 'release record'); exactKeys(record, RELEASE_RECORD_KEYS, 'release record'); - if (record.schemaVersion !== 3 || record.packageName !== PACKAGE_NAME) { + if (record.schemaVersion !== 4 || record.packageName !== PACKAGE_NAME) { throw new Error('Unsupported CLI release record'); } const identity = parseCliReleaseVersion(record.version); @@ -267,17 +278,17 @@ function loadReleaseRecord(releaseDirectory) { if (!/^[0-9a-f]{64}$/u.test(record.sha256)) { throw new Error('Release record sha256 is invalid'); } - exactKeys( - record.source, - ['repository', 'workflow', 'commit', 'runId', 'runAttempt'], - 'release source', - ); - validateSourceIdentity({ + exactKeys(record.source, ['repository', 'commit'], 'release source'); + validateProductSource({ sourceSha: record.source.commit, - runId: record.source.runId, - runAttempt: record.source.runAttempt, repository: record.source.repository, - workflowPath: record.source.workflow, + }); + exactKeys(record.publisher, ['workflow', 'commit', 'runId', 'runAttempt'], 'release publisher'); + validatePublisherIdentity({ + publisherSha: record.publisher.commit, + runId: record.publisher.runId, + runAttempt: record.publisher.runAttempt, + workflowPath: record.publisher.workflow, }); const candidate = validateCandidateFiles(releaseDirectory, identity); if (candidate.sha256 !== record.sha256) { @@ -306,13 +317,17 @@ function validateCandidateFiles(releaseDirectory, identity) { return { tarballPath, sha256 }; } -function validateSourceIdentity({ sourceSha, runId, runAttempt, repository, workflowPath }) { +function validateProductSource({ sourceSha, repository }) { if (!/^[0-9a-f]{40}$/u.test(sourceSha)) throw new Error('Release source SHA is invalid'); + if (repository !== REPOSITORY) throw new Error(`Release repository must be ${REPOSITORY}`); +} + +function validatePublisherIdentity({ publisherSha, runId, runAttempt, workflowPath }) { + if (!/^[0-9a-f]{40}$/u.test(publisherSha)) throw new Error('Release publisher SHA is invalid'); if (!/^[1-9]\d*$/u.test(runId)) throw new Error('Release workflow run ID is invalid'); if (!/^[1-9]\d*$/u.test(runAttempt)) throw new Error('Release workflow run attempt is invalid'); - if (repository !== REPOSITORY) throw new Error(`Release repository must be ${REPOSITORY}`); - if (workflowPath !== STAGE_WORKFLOW_PATH) { - throw new Error(`Release workflow must be ${STAGE_WORKFLOW_PATH}`); + if (workflowPath !== PUBLICATION_WORKFLOW_PATH) { + throw new Error(`Release workflow must be ${PUBLICATION_WORKFLOW_PATH}`); } } @@ -334,11 +349,11 @@ function parseProvenanceStatement(attestation) { function matchesReleaseProvenance(statement, record) { const repository = `https://github.com/${record.source.repository}`; - const ref = `refs/tags/${record.productTag}`; + const ref = 'refs/heads/main'; const definition = statement?.predicate?.buildDefinition; const workflow = definition?.externalParameters?.workflow; const dependencies = definition?.resolvedDependencies; - const invocationId = `${repository}/actions/runs/${record.source.runId}/attempts/${record.source.runAttempt}`; + const invocationId = `${repository}/actions/runs/${record.publisher.runId}/attempts/${record.publisher.runAttempt}`; return ( statement?._type === 'https://in-toto.io/Statement/v1' && statement?.predicateType === 'https://slsa.dev/provenance/v1' && @@ -346,12 +361,12 @@ function matchesReleaseProvenance(statement, record) { 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1' && workflow?.repository === repository && workflow?.ref === ref && - workflow?.path === record.source.workflow && + workflow?.path === record.publisher.workflow && Array.isArray(dependencies) && dependencies.some( (dependency) => dependency?.uri === `git+${repository}@${ref}` && - dependency?.digest?.gitCommit === record.source.commit, + dependency?.digest?.gitCommit === record.publisher.commit, ) && definition?.internalParameters?.github?.event_name === 'workflow_dispatch' && statement?.predicate?.runDetails?.builder?.id?.startsWith( @@ -361,18 +376,6 @@ function matchesReleaseProvenance(statement, record) { ); } -function channelLagError({ releaseVersion, releaseDistTag, latest, next }) { - const current = typeof next === 'string' ? next : 'missing'; - if (releaseDistTag === 'next') { - return new Error( - `Registry next dist-tag (${current}) is behind latest (${latest}); prerelease ${releaseVersion} cannot advance the next channel`, - ); - } - return new Error( - `Registry next dist-tag (${current}) is behind the latest release. Before finalizing, authenticate interactively with npm and run: npm dist-tag add "${PACKAGE_NAME}@${releaseVersion}" next --registry ${REGISTRY_ORIGIN}/`, - ); -} - async function fetchJson(fetchImpl, url, label, accept = 'application/vnd.npm.install-v1+json') { const response = await fetchImpl(url, { headers: { accept }, @@ -474,12 +477,13 @@ function appendOutputs(path, values) { async function main() { const [command, ...args] = process.argv.slice(2); - if (command === 'prepare-stage' && args.length === 9) { + if (command === 'prepare-stage' && args.length === 10) { const [ releaseDirectory, expectedVersion, productTag, sourceSha, + publisherSha, runId, runAttempt, repository, @@ -492,6 +496,7 @@ async function main() { expectedVersion, productTag, sourceSha, + publisherSha, runId, runAttempt, repository, @@ -504,6 +509,20 @@ async function main() { }); return; } + if (command === 'prepare-nightly' && args.length === 3) { + const [releaseDirectory, expectedVersion, output] = args; + const result = prepareNightlyRelease({ + repoRoot: resolve(import.meta.dirname, '..'), + releaseDirectory: resolve(releaseDirectory), + expectedVersion, + }); + appendOutputs(output, { + version: result.version, + dist_tag: result.distTag, + tarball: result.tarballPath, + }); + return; + } if (command === 'validate-stage-run' && (args.length === 3 || args.length === 4)) { const [releaseDirectory, runPath, expectedVersion, output] = args; const record = validateStageRun({ @@ -545,7 +564,7 @@ async function main() { return; } throw new Error( - `Usage: release-cli-publication.mjs ...`, + `Usage: release-cli-publication.mjs ...`, ); } diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 0600065dd1..cef61dba5c 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -27,7 +27,9 @@ import test from 'node:test'; import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; import { fetchRegistryRelease, + parseCliNightlyVersion, parseCliReleaseVersion, + prepareNightlyRelease, prepareSignatureAuditTree, prepareStageRelease, validateRegistryChannels, @@ -36,75 +38,75 @@ import { } from './release-cli-publication.mjs'; const SOURCE_SHA = 'a'.repeat(40); -const WORKFLOW_PATH = '.github/workflows/release-cli-stage.yml'; +const PUBLISHER_SHA = 'b'.repeat(40); +const WORKFLOW_PATH = '.github/workflows/npm-publication.yml'; const CURRENT_CLI_VERSION = JSON.parse( readFileSync(resolve(import.meta.dirname, '../packages/cli/package.json'), 'utf8'), ).version; -const PRODUCT_TAG = 'v0.1.0-beta.1'; +const PRODUCT_TAG = 'v0.2.0'; const STAGE_RUN = { id: 321, run_attempt: 1, path: WORKFLOW_PATH, event: 'workflow_dispatch', - head_branch: PRODUCT_TAG, - head_sha: SOURCE_SHA, + head_branch: 'main', + head_sha: PUBLISHER_SHA, conclusion: 'success', head_repository: { full_name: 'apache/maka' }, }; -test('release versions map prereleases and stable versions to distinct channels', () => { - assert.deepEqual(parseCliReleaseVersion('0.1.0-beta.1'), { - version: '0.1.0-beta.1', - distTag: 'next', - tarball: 'maka-agent-0.1.0-beta.1.tgz', +test('formal and Nightly versions map to their only public channels', () => { + assert.deepEqual(parseCliReleaseVersion('0.2.0'), { + version: '0.2.0', + distTag: 'latest', + tarball: 'maka-agent-0.2.0.tgz', }); - assert.equal(parseCliReleaseVersion('0.1.0').distTag, 'latest'); + assert.deepEqual(parseCliNightlyVersion('0.2.0-dev.42.20260829', '0.2.0'), { + version: '0.2.0-dev.42.20260829', + distTag: 'nightly', + tarball: 'maka-agent-0.2.0-dev.42.20260829.tgz', + }); + assert.throws(() => parseCliReleaseVersion('0.2.0-beta.1'), /must use a stable/u); + assert.throws( + () => parseCliNightlyVersion('0.2.0-beta.1', '0.2.0'), + /valid Product Nightly version/u, + ); for (const version of ['01.0.0', '0.1', '0.1.0+local', '0.1.0-beta..1', '../0.1.0']) { assert.throws(() => parseCliReleaseVersion(version), /valid product release version/u); } }); -test('release channels never leave next behind latest', () => { - for (const next of ['0.1.0', '0.2.0-beta.1']) { - assert.doesNotThrow(() => - validateRegistryChannels({ - releaseVersion: '0.1.0', - releaseDistTag: 'latest', - distTags: { latest: '0.1.0', next }, - }), - ); - } - - for (const next of [undefined, '0.1.0-beta.1']) { - assert.throws( - () => - validateRegistryChannels({ - releaseVersion: '0.1.0', - releaseDistTag: 'latest', - distTags: { latest: '0.1.0', ...(next ? { next } : {}) }, - }), - /npm dist-tag add "maka-agent@0\.1\.0" next/u, - ); - } - +test('formal finalization requires only the exact latest channel', () => { assert.doesNotThrow(() => validateRegistryChannels({ - releaseVersion: '0.2.0-beta.1', - releaseDistTag: 'next', - distTags: { latest: '0.1.0', next: '0.2.0-beta.1' }, + releaseVersion: '0.2.0', + releaseDistTag: 'latest', + distTags: { latest: '0.2.0', nightly: '0.3.0-dev.42.20260829' }, }), ); assert.throws( () => validateRegistryChannels({ - releaseVersion: '0.1.0-beta.2', - releaseDistTag: 'next', - distTags: { latest: '0.1.0', next: '0.1.0-beta.2' }, + releaseVersion: '0.2.0', + releaseDistTag: 'latest', + distTags: { latest: '0.1.0' }, }), - /cannot advance the next channel/u, + /does not point to 0\.2\.0/u, ); }); +test('Nightly preparation validates only the exact dev candidate', () => { + const fixture = createCandidate('0.2.0-dev.42.20260829', '0.2.0'); + const prepared = prepareNightlyRelease({ + repoRoot: fixture.root, + releaseDirectory: fixture.releaseDirectory, + expectedVersion: fixture.version, + }); + assert.equal(prepared.distTag, 'nightly'); + assert.equal(prepared.tarballPath, fixture.tarballPath); + assert.equal(prepared.sha256, fixture.sha256); +}); + test('stage records bind the checked candidate to one source workflow run', () => { const fixture = createCandidate(); const prepared = prepareStageRelease({ @@ -113,6 +115,7 @@ test('stage records bind the checked candidate to one source workflow run', () = expectedVersion: fixture.version, productTag: PRODUCT_TAG, sourceSha: SOURCE_SHA, + publisherSha: PUBLISHER_SHA, runId: '321', runAttempt: '1', repository: 'apache/maka', @@ -120,11 +123,12 @@ test('stage records bind the checked candidate to one source workflow run', () = }); assert.equal(prepared.record.sha256, fixture.sha256); - assert.equal(prepared.record.schemaVersion, 3); + assert.equal(prepared.record.schemaVersion, 4); assert.equal(prepared.record.productTag, PRODUCT_TAG); assert.equal(prepared.record.source.commit, SOURCE_SHA); - assert.equal(prepared.record.source.runId, '321'); - assert.equal(prepared.record.source.runAttempt, '1'); + assert.equal(prepared.record.publisher.commit, PUBLISHER_SHA); + assert.equal(prepared.record.publisher.runId, '321'); + assert.equal(prepared.record.publisher.runAttempt, '1'); assert.deepEqual( JSON.parse(readFileSync(join(fixture.releaseDirectory, 'release.json'), 'utf8')), prepared.record, @@ -141,6 +145,7 @@ test('stage preparation rejects a product tag that does not match the version', expectedVersion: fixture.version, productTag: 'v9.9.9', sourceSha: SOURCE_SHA, + publisherSha: PUBLISHER_SHA, runId: '321', runAttempt: '1', repository: 'apache/maka', @@ -150,24 +155,8 @@ test('stage preparation rejects a product tag that does not match the version', ); }); -test('stage preparation rejects confirmation and checksum drift', () => { +test('stage preparation rejects checksum drift', () => { const fixture = createCandidate(); - assert.throws( - () => - prepareStageRelease({ - repoRoot: fixture.root, - releaseDirectory: fixture.releaseDirectory, - expectedVersion: '0.1.0-beta.2', - productTag: PRODUCT_TAG, - sourceSha: SOURCE_SHA, - runId: '321', - runAttempt: '1', - repository: 'apache/maka', - workflowPath: WORKFLOW_PATH, - }), - /confirmation/u, - ); - writeFileSync(`${fixture.tarballPath}.sha256`, `${'0'.repeat(64)} ${fixture.tarball}\n`); assert.throws( () => @@ -177,6 +166,7 @@ test('stage preparation rejects confirmation and checksum drift', () => { expectedVersion: fixture.version, productTag: PRODUCT_TAG, sourceSha: SOURCE_SHA, + publisherSha: PUBLISHER_SHA, runId: '321', runAttempt: '1', repository: 'apache/maka', @@ -186,7 +176,7 @@ test('stage preparation rejects confirmation and checksum drift', () => { ); }); -test('finalization accepts only the exact successful product-tag stage run', () => { +test('finalization accepts only the exact successful main-branch stage run', () => { const fixture = createPreparedCandidate(); assert.equal( @@ -201,7 +191,7 @@ test('finalization accepts only the exact successful product-tag stage run', () for (const drift of [ { path: '.github/workflows/other.yml' }, { event: 'pull_request' }, - { head_branch: 'v0.1.0-beta.2' }, + { head_branch: 'other' }, { conclusion: 'failure' }, { head_sha: 'c'.repeat(40) }, { run_attempt: 2 }, @@ -334,7 +324,7 @@ test('signature audit must contain Maka provenance for the finalized version', ( ); }); -test('signature audit binds provenance to the exact tag, source, workflow, and run', () => { +test('signature audit binds provenance to the exact main publisher workflow and run', () => { const fixture = createPreparedCandidate(); const audit = (mutate) => ({ invalid: [], @@ -350,10 +340,10 @@ test('signature audit binds provenance to the exact tag, source, workflow, and r }); for (const mutate of [ (statement) => { - statement.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = 'b'.repeat(40); + statement.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit = 'c'.repeat(40); }, (statement) => { - statement.predicate.buildDefinition.externalParameters.workflow.ref = 'refs/heads/main'; + statement.predicate.buildDefinition.externalParameters.workflow.ref = `refs/tags/${PRODUCT_TAG}`; }, (statement) => { statement.predicate.buildDefinition.externalParameters.workflow.path = @@ -432,6 +422,7 @@ test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { fixture.version, `v${fixture.version}`, SOURCE_SHA, + PUBLISHER_SHA, '321', '1', 'apache/maka', @@ -460,8 +451,8 @@ test('validate-stage-run CLI accepts the canonical staged release identity', () run_attempt: 1, path: WORKFLOW_PATH, event: 'workflow_dispatch', - head_branch: PRODUCT_TAG, - head_sha: SOURCE_SHA, + head_branch: 'main', + head_sha: PUBLISHER_SHA, conclusion: 'success', head_repository: { full_name: 'apache/maka' }, }), @@ -496,6 +487,7 @@ function createPreparedCandidate() { expectedVersion: fixture.version, productTag: `v${fixture.version}`, sourceSha: SOURCE_SHA, + publisherSha: PUBLISHER_SHA, runId: '321', runAttempt: '1', repository: 'apache/maka', @@ -514,14 +506,14 @@ function provenanceBundle(mutate = () => {}) { externalParameters: { workflow: { repository: 'https://github.com/apache/maka', - ref: `refs/tags/${PRODUCT_TAG}`, + ref: 'refs/heads/main', path: WORKFLOW_PATH, }, }, resolvedDependencies: [ { - uri: `git+https://github.com/apache/maka@refs/tags/${PRODUCT_TAG}`, - digest: { gitCommit: SOURCE_SHA }, + uri: 'git+https://github.com/apache/maka@refs/heads/main', + digest: { gitCommit: PUBLISHER_SHA }, }, ], internalParameters: { github: { event_name: 'workflow_dispatch' } }, @@ -547,7 +539,7 @@ function provenanceBundle(mutate = () => {}) { }; } -function createCandidate(version = '0.1.0-beta.1') { +function createCandidate(version = '0.2.0', sourceVersion = version) { const root = mkdtempSync(join(tmpdir(), 'maka-cli-publication-')); const releaseDirectory = join(root, 'packages/cli/release'); const tarball = `maka-agent-${version}.tgz`; @@ -558,7 +550,7 @@ function createCandidate(version = '0.1.0-beta.1') { writeFileSync(join(root, 'package.json'), '{"packageManager":"npm@11.19.0"}\n'); writeFileSync( join(root, 'packages/cli/package.json'), - `${JSON.stringify({ name: 'maka-agent', version })}\n`, + `${JSON.stringify({ name: 'maka-agent', version: sourceVersion })}\n`, ); writeFileSync(tarballPath, bytes); writeFileSync(`${tarballPath}.sha256`, `${sha256} ${tarball}\n`); @@ -582,7 +574,7 @@ function registryFetch({ fixture, bytes = fixture.bytes }) { } if (url === 'https://registry.npmjs.org/maka-agent') { assert.equal(options.headers?.accept, 'application/vnd.npm.install-v1+json'); - return Response.json({ 'dist-tags': { next: fixture.version } }); + return Response.json({ 'dist-tags': { latest: fixture.version } }); } if (url === tarballUrl) return new Response(bytes); return new Response('not found', { status: 404 }); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 0bd25c8f2a..bad2c92acf 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -48,6 +48,7 @@ test('validation consumers download the artifact produced by the build job', () test('stage consumes the validated artifact and makes provenance staging the final step', () => { const workflow = readWorkflow('release-cli-stage.yml'); + assert.match(workflow, /environment:\n\s+name: npm-publication/u); const steps = workflowSteps(workflow); const download = namedStep(steps, 'Download the validated release candidate'); assert.match( @@ -55,9 +56,7 @@ test('stage consumes the validated artifact and makes provenance staging the fin /artifact-ids: \$\{\{ needs\.validate\.outputs\.release_candidate_artifact_id \}\}/u, ); assert.match(workflow, /RELEASE_RUN_ATTEMPT/u); - const guidance = namedStep(steps, 'Record the post-staging approval step'); - assert.match(guidance, /if \[\[ "\$RELEASE_DIST_TAG" == "latest" \]\]/u); - assert.match(guidance, /npm dist-tag add/u); + namedStep(steps, 'Record the post-staging approval step'); const submit = namedStep(steps, 'Submit the candidate to npm staging'); assert.equal(steps.at(-1), submit); assert.match(submit, /product-release-authority\.mjs verify-draft/u); @@ -66,23 +65,36 @@ test('stage consumes the validated artifact and makes provenance staging the fin assert.match(submit, /--provenance/u); }); -test('stage builds the npm candidate from the exact product release commit', () => { +test('stage builds product data without executing it under npm OIDC', () => { const workflow = readWorkflow('release-cli-stage.yml'); - const authorizeSteps = workflowSteps(workflow); - const checkout = authorizeSteps.find((step) => step.includes('uses: actions/checkout@')); - assert.match(checkout, /ref: v\$\{\{ inputs\.version \}\}/u); - assert.match(workflow, /RELEASE_REF.*refs\/tags\/\$PRODUCT_TAG/su); + const authorize = workflow.slice( + workflow.indexOf('\n authorize:'), + workflow.indexOf('\n validate:'), + ); + const authorizeSteps = workflowSteps(authorize); + const checkouts = authorizeSteps.filter((step) => step.includes('uses: actions/checkout@')); + assert.equal(checkouts.length, 2); + assert.match(checkouts[0], /ref: \$\{\{ github\.sha \}\}/u); + assert.match(checkouts[1], /ref: v\$\{\{ inputs\.version \}\}/u); + assert.match(checkouts[1], /path: product-source/u); + assert.match(workflow, /RELEASE_REF.*refs\/heads\/main/su); assert.match(workflow, /source_commit: \$\{\{ steps\.product\.outputs\.source_commit \}\}/u); assert.match( workflow, /needs: authorize\n\s+uses: \.\/\.github\/workflows\/cli-package-validation\.yml/u, ); assert.match(workflow, /source_commit: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); - assert.match(workflow, /ref: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u); + const stageCheckout = namedStep(workflowSteps(workflow), 'Check out trusted staging code'); + assert.match(stageCheckout, /ref: \$\{\{ github\.sha \}\}/u); assert.match(workflow, /product-release-authority\.mjs verify-draft/u); assert.match(workflow, /EXPECTED_PRODUCT_VERSION/u); assert.doesNotMatch(workflow, /EXPECTED_PRODUCT_TAG|EXPECTED_PRODUCT_SOURCE_COMMIT/u); - assert.match(workflow, /RELEASE_SHA: \$\{\{ github\.sha \}\}/u); + assert.match( + workflow, + /PRODUCT_SOURCE_SHA: \$\{\{ needs\.authorize\.outputs\.source_commit \}\}/u, + ); + assert.match(workflow, /PUBLISHER_SHA: \$\{\{ github\.sha \}\}/u); + assert.match(workflow, /RELEASE_WORKFLOW: \.github\/workflows\/npm-publication\.yml/u); const bind = namedStep(workflowSteps(workflow), 'Bind the candidate to this workflow run'); assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); @@ -151,6 +163,7 @@ test('finalize preserves npm evidence and owns the single product publication bo test('release workflows select npm from the root packageManager authority', () => { for (const name of [ 'cli-package-validation.yml', + 'npm-publication.yml', 'release-cli-stage.yml', 'release-cli-finalize.yml', ]) { diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 997ecb7f91..9c4743212d 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -41,6 +41,50 @@ export function parseProductReleaseVersion(version) { }; } +export function assertProductNightlyVersion(version, productVersion) { + const product = parseProductReleaseVersion(productVersion); + if (product.prerelease.length > 0) { + throw new Error('Product Nightly requires a stable checked-in product version'); + } + const nightly = parseProductNightlyVersion(version); + if (nightly.core.some((identifier, index) => identifier !== product.core[index])) { + throw new Error(`Product Nightly version ${version} must be a dev build of ${productVersion}`); + } + return version; +} + +export function parseProductNightlyVersion(version) { + const nightly = parseProductReleaseVersion(version); + if ( + nightly.prerelease.length !== 3 || + nightly.prerelease[0] !== 'dev' || + !/^[1-9]\d*$/u.test(nightly.prerelease[1]) || + !/^\d{8}$/u.test(nightly.prerelease[2]) + ) { + throw new Error(`Expected a valid Product Nightly version; found ${version}`); + } + return nightly; +} + +export function productNightlyRunNumber(version) { + return BigInt(parseProductNightlyVersion(version).prerelease[1]); +} + +export function assertProductNightlyAdvances(candidateVersion, currentVersion, productVersion) { + assertProductNightlyVersion(candidateVersion, productVersion); + const candidateRun = productNightlyRunNumber(candidateVersion); + if (currentVersion === undefined || currentVersion === null || currentVersion === '') { + return candidateVersion; + } + const currentRun = productNightlyRunNumber(currentVersion); + if (candidateRun <= currentRun) { + throw new Error( + `Product Nightly ${candidateVersion} does not advance current run ${currentVersion}`, + ); + } + return candidateVersion; +} + export function compareProductReleaseVersions(left, right) { const a = parseProductReleaseVersion(left); const b = parseProductReleaseVersion(right); diff --git a/scripts/smoke-release-cli-package.mjs b/scripts/smoke-release-cli-package.mjs index d2ea7ca253..256120c2b6 100644 --- a/scripts/smoke-release-cli-package.mjs +++ b/scripts/smoke-release-cli-package.mjs @@ -37,6 +37,7 @@ import { tmpdir } from 'node:os'; import { basename, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { validateCliReleaseArtifactMetrics } from './release-cli-artifact-policy.mjs'; +import { findReleaseTarball } from './release-cli-eval-support.mjs'; import { collectRuntimeHostFailureDiagnostic, renderRuntimeHostFailureDiagnostic, @@ -56,11 +57,8 @@ const INSTALLED_ROOT_ENV = 'MAKA_CLI_RELEASE_INSTALLED_ROOT'; const require = createRequire(import.meta.url); const repoRoot = resolve(import.meta.dirname, '..'); -const cliVersion = JSON.parse( - readFileSync(join(repoRoot, 'packages/cli/package.json'), 'utf8'), -).version; const tarballPath = resolve( - process.argv[2] ?? join(repoRoot, `packages/cli/release/maka-agent-${cliVersion}.tgz`), + process.argv[2] ?? findReleaseTarball(join(repoRoot, 'packages/cli/release')), ); const installedRoot = process.env[INSTALLED_ROOT_ENV]; diff --git a/scripts/verify-macos-arm64-dmg.mjs b/scripts/verify-macos-arm64-dmg.mjs index 96f1a4ccea..1d9fa1f1ac 100644 --- a/scripts/verify-macos-arm64-dmg.mjs +++ b/scripts/verify-macos-arm64-dmg.mjs @@ -33,7 +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 { resolveDesktopBuildVersion, resolveRuntimeHostSetupPackage } from './desktop-nightly.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -162,7 +162,11 @@ export async function verifyPackagedMacApp( await run('spctl', ['--assess', '--type', 'execute', '--verbose=4', appPath]); await run('xcrun', ['stapler', 'validate', appPath]); - const ptyProbe = makePtyProbe('/bin/echo', ['maka-node-pty-ok'], product.runtimeHostSetupPackage); + const ptyProbe = makePtyProbe( + '/bin/echo', + ['maka-node-pty-ok'], + resolveRuntimeHostSetupPackage(product.version, environment), + ); await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], { env: { ELECTRON_RUN_AS_NODE: '1', diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index e420dddc7b..486c6b3fe5 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -24,7 +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 { resolveDesktopBuildVersion, resolveRuntimeHostSetupPackage } from './desktop-nightly.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -253,7 +253,9 @@ export async function verifyPackagedWindowsApp( const ptyProbe = makePtyProbe( process.env.ComSpec || 'cmd.exe', ['/c', 'echo', 'maka-node-pty-ok'], - requiresCurrentContract ? product.runtimeHostSetupPackage : undefined, + requiresCurrentContract + ? resolveRuntimeHostSetupPackage(product.version, environment) + : undefined, ); await run(executable, ['-e', ptyProbe, join(appAsar, 'package.json')], { env: {