diff --git a/.asf.yaml b/.asf.yaml index afff5f4c74..adc2cf51f4 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -110,6 +110,17 @@ github: policies: - name: "v*" type: tag + product-release: + required_reviewers: + - id: M4n5ter + type: User + wait_timer: 0 + prevent_self_review: true + deployment_branch_policy: + protected_branches: false + policies: + - name: main + type: branch notifications: commits: commits@maka.apache.org diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md index 5a1c32ab00..92710f9e74 100644 --- a/.github/RELEASE_CHECKLIST.md +++ b/.github/RELEASE_CHECKLIST.md @@ -19,9 +19,10 @@ # Product release checklist -The `Release` workflow is Maka's convenience-artifact release entry point. Desktop and CLI/TUI are +The IPMC-approved source archive on ASF distribution infrastructure is the Apache release. The +`Release` workflow is Maka's convenience-artifact distribution entry point. Desktop and CLI/TUI are built from the exact IPMC-approved ASF source candidate commit. They share that source commit, the -root product version, one convenience tag, one GitHub Release, one Draft decision, and one release +root product version, one convenience tag, one GitHub Release, one Draft decision, and one distribution gate. The workflow creates no Draft until every required artifact job succeeds. Phase 1 requires: @@ -31,7 +32,7 @@ Phase 1 requires: - the signed, notarized, relocatable Apple Silicon CLI/TUI ZIP; - checksums generated after each artifact reaches its final form. -The ASF Desktop artifacts must not contain a Git runtime, a bundled-Git manifest, or Git/Dugite +The convenience Desktop artifacts must not contain a Git runtime, a bundled-Git manifest, or Git/Dugite redistribution notices. Managed-workspace execution remains unavailable until a separately reviewed, ASF-compatible verified runtime is connected before admission/T1. @@ -60,10 +61,13 @@ must never be exposed to fork or ordinary pull-request jobs. Before the first product release, confirm the checked-in `.asf.yaml` has reconciled the live repository: - the `Immutable release tags` ruleset blocks updates, force-pushes, and deletions of `v*` tags; -- the `release` and `npm-release` Environments accept only their declared tag patterns and require a reviewer other than the triggering user; -- enable immutable releases so assets and the associated tag cannot change after publication. +- 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. -These controls close the check-to-upload and check-to-stage windows. Keep the Release in Draft while assets and acceptance are incomplete; publishing early must make subsequent mutation fail closed. +These controls close the check-to-upload and check-to-stage windows. Finalize uses GitHub Actions +OIDC rather than a stored signing key to attest every convenience artifact. Keep the Release in +Draft while assets and acceptance are incomplete; Desktop rejects downloaded updates whose exact +bytes and expected filename are not covered by that protected workflow identity. ## Create the complete Draft @@ -106,17 +110,21 @@ then rerun. If only the tag exists, the retry creates the missing Draft. Follow [the npm release runbook](../docs/cli-npm-release.md) against the exact product tag and Draft: -1. Run **Stage CLI npm release** from `v` and record its successful run ID and attempt. +1. Record the successful **Release** workflow run ID and attempt that built the Draft assets. Run + **Stage CLI npm release** from `v` and record its successful run ID and attempt. 2. Inspect the staged tarball and provenance, then approve that exact stage with npm 2FA. -3. Run **Finalize CLI npm channel** from `main` and confirm it verifies the public package bytes, - provenance, signature, and release dist-tag. +3. Run **Finalize product release** from `main`. Its first job verifies the public package + bytes, provenance, signature, and release dist-tag. 4. Install the exact public version on each release platform and complete the npm acceptance steps. -Keep the GitHub Release in Draft throughout this sequence. A failed or rejected npm candidate -requires a new product version; never publish the Draft to work around npm state. - -When every npm and cross-machine acceptance check has passed, publish the Draft. Mark a stable -release as Latest at that final publication boundary; prereleases must remain non-Latest. +Keep the GitHub Release in Draft throughout this sequence. The final workflow job waits at the +`product-release` Environment. Approve it only after every npm and cross-machine acceptance check +has passed. It 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 +change the Latest designation manually. A failed or rejected npm candidate requires a new product +version; never publish the Draft to work around npm state. ## Acceptance on another Apple Silicon Mac @@ -155,8 +163,20 @@ Download the installer, Windows Desktop ZIP, and both checksum files through a b 7. Add a clean remote Runtime Host from the packaged Desktop app. Confirm setup installs the exact public `maka-agent@` package and the remote session completes one model turn. -Immediately before publication, reverify that the approved ASF candidate tag and convenience -`v` tag still resolve to the same recorded commit. Publish only after npm Finalize and both -independent-machine acceptance passes. If any required artifact, npm step, or +Immediately before approving the `product-release` Environment, reverify that the approved ASF +candidate tag and convenience `v` tag still resolve to the same recorded commit. Approve +only after npm verification and both independent-machine acceptance passes. If any required artifact, npm step, or acceptance step fails, keep the Draft unpublished, fix the issue, increment the root product version, and run the full workflow again. Never replace an existing release identity. + +After Finalize publishes the convenience Release, download its attestation bundle and verify each +installer or archive independently: + +```sh +gh attestation verify path/to/Maka--mac-arm64.zip \ + --bundle path/to/Maka--attestation.sigstore.json \ + --repo apache/maka \ + --signer-workflow apache/maka/.github/workflows/release-cli-finalize.yml +``` + +Desktop performs the same trust decision before exposing a downloaded update for installation. diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 7909ce03ca..49c7667a1d 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -name: Finalize CLI npm channel +name: Finalize product release on: workflow_dispatch: @@ -28,6 +28,14 @@ on: description: Successful Stage CLI npm release workflow run attempt required: true type: string + release_run_id: + description: Successful Release workflow run ID that built the Draft assets + required: true + type: string + release_run_attempt: + description: Successful Release workflow run attempt that built the Draft assets + required: true + type: string version: description: Exact staged maka-agent product version required: true @@ -38,7 +46,7 @@ permissions: contents: read concurrency: - group: cli-npm-finalize + group: product-release cancel-in-progress: false jobs: @@ -46,13 +54,20 @@ jobs: name: Verify the public npm channel runs-on: ubuntu-24.04 timeout-minutes: 20 + outputs: + product_tag: ${{ steps.release.outputs.product_tag }} + product_version: ${{ steps.release.outputs.version }} + release_run_id: ${{ steps.release-run.outputs.run_id }} + release_run_attempt: ${{ steps.release-run.outputs.run_attempt }} + source_commit: ${{ steps.release.outputs.source_commit }} + source_reference_tag: ${{ steps.authority.outputs.source_reference_tag }} steps: - name: Require main env: RELEASE_REF: ${{ github.ref }} run: | if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "CLI npm finalization must be dispatched from main; found $RELEASE_REF" >&2 + echo "Product finalization must be dispatched from main; found $RELEASE_REF" >&2 exit 1 fi @@ -72,6 +87,25 @@ jobs: fi gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID/attempts/$STAGE_RUN_ATTEMPT" > "$RUNNER_TEMP/stage-run.json" + - name: Load the exact Release workflow run + id: release-run + env: + GH_TOKEN: ${{ github.token }} + RELEASE_RUN_ID: ${{ inputs.release_run_id }} + RELEASE_RUN_ATTEMPT: ${{ inputs.release_run_attempt }} + run: | + if [[ ! "$RELEASE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "Release workflow run ID must be a positive integer" >&2 + exit 1 + fi + if [[ ! "$RELEASE_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "Release workflow run attempt must be a positive integer" >&2 + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RELEASE_RUN_ID/attempts/$RELEASE_RUN_ATTEMPT" > "$RUNNER_TEMP/release-run.json" + echo "run_id=$RELEASE_RUN_ID" >> "$GITHUB_OUTPUT" + echo "run_attempt=$RELEASE_RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + - name: Check out the current release verifier uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -96,6 +130,15 @@ jobs: repository: ${{ github.repository }} run-id: ${{ inputs.stage_run_id }} + - name: Download the publication record + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: product-release-record-${{ inputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release-record + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.release_run_id }} + - name: Verify the stage run and release record id: release env: @@ -108,13 +151,33 @@ jobs: "$GITHUB_OUTPUT" - name: Revalidate the product release authority + id: authority env: GH_TOKEN: ${{ github.token }} PRODUCT_SOURCE_COMMIT: ${{ steps.release.outputs.source_commit }} PRODUCT_TAG: ${{ steps.release.outputs.product_tag }} + RELEASE_RUN_ATTEMPT: ${{ steps.release-run.outputs.run_attempt }} + RELEASE_RUN_ID: ${{ steps.release-run.outputs.run_id }} run: | + node scripts/product-release-authority.mjs verify-build-run \ + "$RUNNER_TEMP/release-run.json" \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" + source_reference_tag="$(jq -r .head_branch "$RUNNER_TEMP/release-run.json")" + node scripts/product-release-artifacts.mjs inspect-record \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$GITHUB_REPOSITORY" \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$source_reference_tag" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" node scripts/product-release-authority.mjs verify-draft \ "$PRODUCT_TAG" "$PRODUCT_SOURCE_COMMIT" "$GITHUB_REPOSITORY" + echo "source_reference_tag=$source_reference_tag" >> "$GITHUB_OUTPUT" - name: Fetch and verify the public registry bytes run: | @@ -141,3 +204,123 @@ jobs: if-no-files-found: error compression-level: 0 retention-days: 30 + + publish: + name: Publish the attested convenience release + needs: inspect + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: + name: product-release + url: https://github.com/apache/maka/releases/tag/${{ needs.inspect.outputs.product_tag }} + permissions: + actions: read + artifact-metadata: write + attestations: write + contents: write + id-token: write + steps: + - name: Check out the current release verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + + - name: Download the exact verified Release run artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: release-*-${{ needs.inspect.outputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release + merge-multiple: true + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ needs.inspect.outputs.release_run_id }} + + - name: Download the publication record + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: product-release-record-${{ needs.inspect.outputs.release_run_attempt }} + path: ${{ runner.temp }}/product-release-record + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ needs.inspect.outputs.release_run_id }} + + - name: Verify the exact publication input + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} + PRODUCT_SOURCE_REFERENCE_TAG: ${{ needs.inspect.outputs.source_reference_tag }} + PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} + RELEASE_RUN_ATTEMPT: ${{ needs.inspect.outputs.release_run_attempt }} + RELEASE_RUN_ID: ${{ needs.inspect.outputs.release_run_id }} + run: | + node scripts/product-release-authority.mjs verify-publication \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "$RUNNER_TEMP/product-release" \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$PRODUCT_SOURCE_REFERENCE_TAG" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" + + - name: Attest the verified convenience artifacts + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 + with: + subject-path: ${{ runner.temp }}/product-release/* + + - name: Verify the issued provenance + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + CERTIFICATE_IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/release-cli-finalize.yml@refs/heads/main + GH_TOKEN: ${{ github.token }} + run: | + verified=0 + while IFS= read -r -d '' artifact; do + gh attestation verify "$artifact" \ + --bundle "$ATTESTATION_BUNDLE" \ + --repo "$GITHUB_REPOSITORY" \ + --cert-identity "$CERTIFICATE_IDENTITY" \ + --cert-oidc-issuer https://token.actions.githubusercontent.com + verified=$((verified + 1)) + done < <(find "$RUNNER_TEMP/product-release" -maxdepth 1 -type f -print0) + if (( verified == 0 )); then + echo "No product release artifacts were verified" >&2 + exit 1 + fi + + - name: Name the offline verification bundle + env: + ATTESTATION_BUNDLE: ${{ steps.attest.outputs.bundle-path }} + PRODUCT_VERSION: ${{ needs.inspect.outputs.product_version }} + run: >- + cp -- "$ATTESTATION_BUNDLE" + "$RUNNER_TEMP/Maka-${PRODUCT_VERSION}-attestation.sigstore.json" + + - name: Publish the verified convenience release + env: + GH_TOKEN: ${{ github.token }} + PRODUCT_SOURCE_COMMIT: ${{ needs.inspect.outputs.source_commit }} + PRODUCT_SOURCE_REFERENCE_TAG: ${{ needs.inspect.outputs.source_reference_tag }} + PRODUCT_TAG: ${{ needs.inspect.outputs.product_tag }} + PRODUCT_VERSION: ${{ needs.inspect.outputs.product_version }} + RELEASE_RUN_ATTEMPT: ${{ needs.inspect.outputs.release_run_attempt }} + RELEASE_RUN_ID: ${{ needs.inspect.outputs.release_run_id }} + run: | + node scripts/product-release-authority.mjs publish-draft \ + "$PRODUCT_TAG" \ + "$PRODUCT_SOURCE_COMMIT" \ + "$GITHUB_REPOSITORY" \ + "$RUNNER_TEMP/product-release" \ + "$RUNNER_TEMP/product-release-record/product-release.json" \ + "$PRODUCT_SOURCE_REFERENCE_TAG" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" \ + "$RUNNER_TEMP/Maka-${PRODUCT_VERSION}-attestation.sigstore.json" diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index bda8058009..169410a7be 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -155,7 +155,7 @@ jobs: echo "## maka-agent@$RELEASE_VERSION staging" echo echo "After this workflow succeeds, review and approve the staged package with 2FA on npmjs.com." - echo "After the package becomes public, run **Finalize CLI npm channel** with:" + echo "After the package becomes public, run **Finalize product release** with:" echo echo "- stage run ID: \`$RELEASE_RUN_ID\`" echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf7e5eb180..73006cb1a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ permissions: contents: read concurrency: - group: release + group: product-release cancel-in-progress: false jobs: @@ -187,6 +187,20 @@ jobs: if: matrix.platform == 'macos' run: npm run verify:macos-arm64 -- "apps/desktop/release/${{ needs.release-identity.outputs.dmg }}" + - name: Build the version-bumped macOS update + if: matrix.platform == 'macos' + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: npm run package:macos-autoupdate-next + + - name: Verify macOS automatic update end to end + if: matrix.platform == 'macos' + run: | + npm run verify:macos-autoupdate -- \ + "apps/desktop/release/Maka-${{ needs.release-identity.outputs.version }}-mac-arm64.zip" \ + apps/desktop/release-autoupdate-next + # Windows has no Authenticode certificate yet, so this build is unsigned # and there is nothing to notarize between packaging and verification. - name: Package the Windows installer and ZIP @@ -242,10 +256,10 @@ jobs: - name: Upload the verified release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-${{ matrix.platform }} + name: release-desktop-${{ matrix.platform }}-${{ github.run_attempt }} path: ${{ runner.temp }}/release-assets if-no-files-found: error - retention-days: 7 + retention-days: 30 - name: Remove temporary release credentials if: always() && matrix.platform == 'macos' @@ -338,10 +352,10 @@ jobs: - name: Upload the verified CLI release assets uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: release-cli-macos-arm64 + name: release-cli-macos-arm64-${{ github.run_attempt }} path: ${{ runner.temp }}/release-assets if-no-files-found: error - retention-days: 7 + retention-days: 30 - name: Remove temporary release credentials if: always() @@ -370,16 +384,47 @@ jobs: - name: Download the verified release assets uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - pattern: release-* + pattern: release-*-${{ github.run_attempt }} path: release-assets merge-multiple: true + - name: Set up the pinned release Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ needs.release-identity.outputs.node_version }} + package-manager-cache: false + + - name: Select the pinned npm release toolchain + run: | + npm install --global --no-audit --no-fund "npm@${{ needs.release-identity.outputs.npm_version }}" + test "$(npm --version)" = "${{ needs.release-identity.outputs.npm_version }}" + + - name: Install the release verifier dependencies + run: npm ci --ignore-scripts --no-audit --no-fund + - name: Verify the exact product artifact manifest + run: node scripts/product-release-artifacts.mjs verify release-assets + + - name: Record the immutable publication evidence + env: + GITHUB_SHA: ${{ needs.release-identity.outputs.source_commit }} + SOURCE_REFERENCE_TAG: ${{ needs.release-identity.outputs.source_reference_tag }} run: | - node scripts/product-release-artifacts.mjs verify release-assets - while IFS= read -r -d '' checksum; do - (cd "$(dirname "$checksum")" && sha256sum -c "$(basename "$checksum")") - done < <(find release-assets -type f -name '*.sha256' -print0) + node scripts/product-release-artifacts.mjs record \ + release-assets \ + "$RUNNER_TEMP/product-release.json" \ + "$GITHUB_REPOSITORY" \ + "$GITHUB_RUN_ID" \ + "$GITHUB_RUN_ATTEMPT" + + - name: Upload the immutable publication evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: product-release-record-${{ github.run_attempt }} + path: ${{ runner.temp }}/product-release.json + if-no-files-found: error + compression-level: 0 + retention-days: 30 - name: Revalidate the live ASF source reference env: @@ -417,7 +462,9 @@ jobs: The Windows build is unsigned: SmartScreen warns on first launch, and the download has to be checked against its .sha256 file. - Known limitations: Computer Use and managed-workspace execution are not included in this release. The ASF Desktop artifacts do not distribute a Git runtime." + These files are convenience binaries built from the approved ASF source release; they are not ASF release artifacts. + + Known limitations: Computer Use and managed-workspace execution are not included in this release. The Desktop convenience artifacts do not distribute a Git runtime." classification=(--prerelease=false --latest=false) if [[ "$IS_PRERELEASE" == "true" ]]; then @@ -493,4 +540,7 @@ jobs: gh release upload "$TAG" "${missing_assets[@]}" fi - echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." >> "$GITHUB_STEP_SUMMARY" + { + echo "Draft release ${TAG} created from ${SOURCE_COMMIT}." + echo "Release workflow run: ${GITHUB_RUN_ID}, attempt: ${GITHUB_RUN_ATTEMPT}." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 6ccc1f76fd..cb40d44dfd 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ apps/desktop/bundled-git.json # Generated desktop release inputs and outputs. apps/desktop/resources/tools/ apps/desktop/release/ +apps/desktop/release-autoupdate-next/ apps/desktop/release-sources/ packages/cli/release/ packages/cli/.development/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7551ebbc5c..464cd0c2d6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,9 @@ "@maka/runtime": "0.1.0", "@maka/runtime-host": "0.1.0", "@maka/storage": "0.1.0", + "@sigstore/bundle": "5.0.0", + "@sigstore/tuf": "5.0.0", + "@sigstore/verify": "4.1.2", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 71ec5e9c50..9ffc332ebd 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -987,6 +987,35 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: @gar/promise-retry@1.0.3 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/wraithgar/node-promise-retry.git + +--- LICENSE --- +Copyright (c) 2011 Tim Koschützki (tim@debuggable.com), Felix Geisendörfer (felix@debuggable.com) +Copyright (c) 2014 IndigoUnited + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +================================================================================ + Package: @iconify/types@2.0.0 Declared license: MIT Selected license: MIT @@ -2096,52 +2125,1102 @@ met: may be used to endorse or promote products derived from this software without specific prior written permission. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + +Package: @protobufjs/utf8@1.1.2 +Declared license: BSD-3-Clause +Selected license: BSD-3-Clause +Repository: https://github.com/protobufjs/protobuf.js.git + +--- LICENSE --- +Copyright (c) 2016, Daniel Wirtz All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of its author, nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +================================================================================ + +Package: @sigstore/bundle@5.0.0 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/core@4.0.1 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/protobuf-specs@0.5.2 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/protobuf-specs.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/tuf@5.0.0 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- LICENSE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Sigstore Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +================================================================================ + +Package: @sigstore/verify@4.1.2 +Declared license: Apache-2.0 +Selected license: Apache-2.0 +Repository: git+https://github.com/sigstore/sigstore-js.git + +--- VERSION-PINNED LICENSE TEXT OVERRIDE --- +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS -================================================================================ + APPENDIX: How to apply the Apache License to your work. -Package: @protobufjs/utf8@1.1.2 -Declared license: BSD-3-Clause -Selected license: BSD-3-Clause -Repository: https://github.com/protobufjs/protobuf.js.git + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. ---- LICENSE --- -Copyright (c) 2016, Daniel Wirtz All rights reserved. + Copyright [yyyy] [name of copyright owner] -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. -* Neither the name of its author, nor the names of its contributors - may be used to endorse or promote products derived from this software - without specific prior written permission. + http://www.apache.org/licenses/LICENSE-2.0 -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. ================================================================================ @@ -2329,6 +3408,66 @@ SOFTWARE. ================================================================================ +Package: @tufjs/canonical-json@2.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + +Package: @tufjs/models@5.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: @types/d3@7.4.3 Declared license: MIT Selected license: MIT @@ -4238,6 +5377,38 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ================================================================================ +Package: balanced-match@4.0.4 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/juliangruber/balanced-match.git + +--- LICENSE.md --- +(MIT) + +Original code Copyright Julian Gruber + +Port to TypeScript Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: bare-addon-resolve@1.10.1 Declared license: Apache-2.0 Selected license: Apache-2.0 @@ -4890,6 +6061,38 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: brace-expansion@5.0.9 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/juliangruber/brace-expansion.git + +--- LICENSE --- +MIT License + +Copyright Julian Gruber + +TypeScript port Copyright Isaac Z. Schlueter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: builder-util-runtime@9.7.0 Declared license: MIT Selected license: MIT @@ -10211,6 +11414,70 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ +Package: minimatch@10.2.5 +Declared license: BlueOak-1.0.0 +Selected license: BlueOak-1.0.0 +Repository: git@github.com:isaacs/minimatch + +--- LICENSE.md --- +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +**_As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim._** + +================================================================================ + Package: minisearch@7.2.0 Declared license: MIT Selected license: MIT @@ -12313,6 +13580,36 @@ PERFORMANCE OF THIS SOFTWARE. ================================================================================ +Package: tuf-js@6.0.0 +Declared license: MIT +Selected license: MIT +Repository: git+https://github.com/theupdateframework/tuf-js.git + +--- LICENSE --- +MIT License + +Copyright (c) 2022 GitHub and the TUF Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +================================================================================ + Package: turndown@7.2.4 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/src/main/__tests__/app-update-attestation.test.ts b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts new file mode 100644 index 0000000000..fae7d8d79a --- /dev/null +++ b/apps/desktop/src/main/__tests__/app-update-attestation.test.ts @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { Bundle } from '@sigstore/bundle'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { verifyDownloadedUpdateAttestation } from '../app-update-attestation.js'; + +function provenanceBundle(name: string, sha256: string): Bundle { + const statement = { + _type: 'https://in-toto.io/Statement/v1', + subject: [{ name, digest: { sha256 } }], + predicateType: 'https://slsa.dev/provenance/v1', + predicate: {}, + }; + return { + mediaType: 'application/vnd.dev.sigstore.bundle.v0.3+json', + verificationMaterial: { + content: undefined, + tlogEntries: [], + timestampVerificationData: undefined, + }, + content: { + $case: 'dsseEnvelope', + dsseEnvelope: { + payloadType: 'application/vnd.in-toto+json', + payload: Buffer.from(JSON.stringify(statement)), + signatures: [], + }, + }, + } as unknown as Bundle; +} + +test('download verification accepts only a trusted exact artifact subject', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-update-attestation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const artifact = join(directory, 'cached-update.zip'); + const bytes = Buffer.from('attested update bytes'); + await writeFile(artifact, bytes); + const digest = createHash('sha256').update(bytes).digest('hex'); + const bundle = provenanceBundle('Maka-1.2.3-mac-arm64.zip', digest); + const bundleBytes = Buffer.from(JSON.stringify({ + mediaType: bundle.mediaType, + verificationMaterial: { + certificate: { rawBytes: Buffer.from('fixture certificate').toString('base64') }, + tlogEntries: [], + }, + dsseEnvelope: { + payloadType: bundle.content.$case === 'dsseEnvelope' + ? bundle.content.dsseEnvelope.payloadType + : '', + payload: bundle.content.$case === 'dsseEnvelope' + ? Buffer.from(bundle.content.dsseEnvelope.payload).toString('base64') + : '', + signatures: [{ sig: Buffer.from('fixture signature').toString('base64') }], + }, + })); + const options = { + downloadedFile: artifact, + version: '1.2.3', + platform: 'darwin' as const, + arch: 'arm64', + trustRootCacheDirectory: join(directory, 'trust'), + fetchBundle: async () => bundleBytes, + verifyBundle: async () => {}, + }; + + await verifyDownloadedUpdateAttestation(options); + await assert.rejects( + verifyDownloadedUpdateAttestation({ ...options, platform: 'win32', arch: 'x64' }), + /does not identify/u, + ); + + await writeFile(artifact, 'different update bytes'); + await assert.rejects(verifyDownloadedUpdateAttestation(options), /does not identify/u); + + await assert.rejects( + verifyDownloadedUpdateAttestation({ + ...options, + verifyBundle: () => { + throw new Error('untrusted workflow identity'); + }, + }), + /untrusted workflow identity/u, + ); +}); diff --git a/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts b/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts index d453f7b05c..2f2f3a2977 100644 --- a/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-focus-check.test.ts @@ -50,6 +50,7 @@ function createHarness(options: { start: number }) { currentVersion: '0.1.8', isPackaged: true, updater, + verifyDownloadedUpdate: async () => {}, prepareInstall: async () => ({ kind: 'prepared', rollback() {} }), clock: { // No scheduled checks in these tests: the timer is a separate trigger and diff --git a/apps/desktop/src/main/__tests__/app-update-service.test.ts b/apps/desktop/src/main/__tests__/app-update-service.test.ts index e42dce6e32..3206ca6608 100644 --- a/apps/desktop/src/main/__tests__/app-update-service.test.ts +++ b/apps/desktop/src/main/__tests__/app-update-service.test.ts @@ -21,12 +21,13 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { describe, test } from 'node:test'; import type { AppUpdater } from 'electron-updater'; +import { resolveUpdateFeedOverride } from '../app-update-test-context.js'; import { createAppUpdateService, - resolveUpdateFeedOverride, type AppUpdateInstallRequest, type AppUpdateStatus, } from '../app-update-service.js'; +import type { DownloadedUpdateAttestationVerifier } from '../app-update-attestation.js'; const FIRST_UPDATE_CHECK_DELAY_MS = 10_000; const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; @@ -129,6 +130,7 @@ function createHarness(input: { mockLatestVersion?: string; mockState?: 'available' | 'downloading' | 'downloaded'; testFeedUrl?: string; + verifyDownloadedUpdate?: DownloadedUpdateAttestationVerifier; } = {}) { const updater = input.updater ?? new FakeUpdater(); const clock = input.clock ?? new FakeClock(); @@ -145,10 +147,15 @@ function createHarness(input: { mockLatestVersion: input.mockLatestVersion, mockState: input.mockState, testFeedUrl: input.testFeedUrl, + verifyDownloadedUpdate: input.verifyDownloadedUpdate ?? (async () => {}), }); return { clock, service, updater }; } +async function settleUpdateVerification(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + describe('AppUpdateService', () => { test('owns one main-process schedule and configures background downloads', async () => { const { clock, service, updater } = createHarness(); @@ -295,11 +302,13 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual(statuses.map((status) => status.state), [ 'checking', 'available', 'downloading', + 'verifying', 'downloaded', ]); assert.deepEqual(service.getStatus(), { @@ -309,6 +318,28 @@ describe('AppUpdateService', () => { }); }); + test('fails closed when downloaded update provenance cannot be verified', async () => { + const { service, updater } = createHarness({ + verifyDownloadedUpdate: async () => { + throw new Error('release provenance did not match'); + }, + }); + updater.emit('update-downloaded', { + ...updateInfo('1.1.0'), + downloadedFile: '/tmp/maka-update.zip', + }); + await settleUpdateVerification(); + + assert.deepEqual(service.getStatus(), { + state: 'error', + currentVersion: '1.0.0', + latestVersion: '1.1.0', + operation: 'download', + message: 'release provenance did not match', + }); + assert.equal(updater.quitAndInstallCalls, 0); + }); + test('cancels a stalled auto-download before retrying it', async () => { const updater = new FakeUpdater(); const statuses: AppUpdateStatus[] = []; @@ -390,6 +421,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await service.installUpdate({ allowInterruptActiveTasks: false }), @@ -408,6 +440,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await idle.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true }, @@ -430,6 +463,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual(await service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true, @@ -452,6 +486,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await synchronous.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: false, reason: 'install_failed' }, @@ -479,6 +514,7 @@ describe('AppUpdateService', () => { ...updateInfo('1.1.0'), downloadedFile: '/tmp/maka-update.zip', }); + await settleUpdateVerification(); assert.deepEqual( await asynchronous.service.installUpdate({ allowInterruptActiveTasks: false }), { ok: true }, diff --git a/apps/desktop/src/main/app-update-attestation.ts b/apps/desktop/src/main/app-update-attestation.ts new file mode 100644 index 0000000000..ea0e550dbc --- /dev/null +++ b/apps/desktop/src/main/app-update-attestation.ts @@ -0,0 +1,217 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { bundleFromJSON, type Bundle } from '@sigstore/bundle'; +import { getTrustedRoot } from '@sigstore/tuf'; +import { toSignedEntity, toTrustMaterial, Verifier } from '@sigstore/verify'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; + +const PRODUCT_REPOSITORY = 'apache/maka'; +const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release-cli-finalize.yml'; +const PRODUCT_RELEASE_SIGNER = new RegExp( + `^https://github\\.com/${PRODUCT_REPOSITORY.replace('/', '\\/')}/${PRODUCT_RELEASE_WORKFLOW.replaceAll('.', '\\.')}` + + '@refs/heads/main$', + 'u', +); +const GITHUB_ACTIONS_OIDC_ISSUER = 'https://token.actions.githubusercontent.com'; +const IN_TOTO_STATEMENT_V1 = 'https://in-toto.io/Statement/v1'; +const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1'; +const MAX_ATTESTATION_BYTES = 5 * 1024 * 1024; + +type AttestationSubject = { + readonly name?: unknown; + readonly digest?: unknown; +}; + +type AttestationStatement = { + readonly _type?: unknown; + readonly predicateType?: unknown; + readonly subject?: unknown; +}; + +export type DownloadedUpdateAttestationInput = { + readonly downloadedFile: string; + readonly version: string; +}; + +export type DownloadedUpdateAttestationVerifier = ( + input: DownloadedUpdateAttestationInput, +) => Promise; + +type VerifyDownloadedUpdateAttestationOptions = DownloadedUpdateAttestationInput & { + readonly trustRootCacheDirectory: string; + readonly platform?: NodeJS.Platform; + readonly arch?: string; + readonly fetchBundle?: (url: string) => Promise; + readonly verifyBundle?: (bundle: Bundle) => Promise; +}; + +function exactDesktopUpdateArtifactName( + version: string, + platform: NodeJS.Platform, + arch: string, +): string { + if (platform === 'darwin' && arch === 'arm64') return `Maka-${version}-mac-arm64.zip`; + if (platform === 'win32' && arch === 'x64') return `Maka-${version}-win-x64.exe`; + throw new Error(`Automatic updates are unsupported on ${platform}/${arch}`); +} + +function productReleaseAttestationName(version: string): string { + if (!/^[0-9A-Za-z][0-9A-Za-z.+-]*$/u.test(version)) { + throw new Error('Update version cannot identify a product attestation'); + } + return `Maka-${version}-attestation.sigstore.json`; +} + +function productReleaseAttestationUrl(version: string): string { + const tag = `v${version}`; + const name = productReleaseAttestationName(version); + return `https://github.com/${PRODUCT_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; +} + +async function sha256File(path: string): Promise { + const hash = createHash('sha256'); + const stream = createReadStream(path); + for await (const chunk of stream) hash.update(chunk); + return hash.digest('hex'); +} + +async function fetchBytesCapped(url: string): Promise { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + redirect: 'follow', + signal: AbortSignal.timeout(20_000), + }); + if (!response.ok || !response.body) { + throw new Error(`Update attestation download failed with HTTP ${response.status}`); + } + const declaredLength = Number(response.headers.get('content-length')); + if (Number.isFinite(declaredLength) && declaredLength > MAX_ATTESTATION_BYTES) { + throw new Error('Update attestation is larger than the accepted limit'); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + length += value.byteLength; + if (length > MAX_ATTESTATION_BYTES) { + await reader.cancel(); + throw new Error('Update attestation is larger than the accepted limit'); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function parseBundle(bytes: Uint8Array): Bundle { + let serialized: unknown; + try { + serialized = JSON.parse(new TextDecoder().decode(bytes)); + } catch (error) { + throw new Error('Update attestation is not valid JSON', { cause: error }); + } + try { + return bundleFromJSON(serialized as Parameters[0]); + } catch (error) { + throw new Error('Update attestation is not a valid Sigstore bundle', { cause: error }); + } +} + +function statementFromBundle(bundle: Bundle): AttestationStatement { + if (bundle.content.$case !== 'dsseEnvelope') { + throw new Error('Update attestation must contain an in-toto statement'); + } + const envelope = bundle.content.dsseEnvelope; + if (envelope.payloadType !== 'application/vnd.in-toto+json') { + throw new Error('Update attestation payload type is invalid'); + } + try { + return JSON.parse(Buffer.from(envelope.payload).toString('utf8')) as AttestationStatement; + } catch (error) { + throw new Error('Update attestation statement is not valid JSON', { cause: error }); + } +} + +function assertProductReleaseAttestationSubject( + bundle: Bundle, + expectedName: string, + expectedSha256: string, +): void { + const statement = statementFromBundle(bundle); + if (statement._type !== IN_TOTO_STATEMENT_V1 || statement.predicateType !== SLSA_PROVENANCE_V1) { + throw new Error('Update attestation is not SLSA provenance v1'); + } + if (!Array.isArray(statement.subject)) { + throw new Error('Update attestation has no subjects'); + } + const exact = (statement.subject as AttestationSubject[]).some((subject) => { + if (subject?.name !== expectedName || !subject.digest || typeof subject.digest !== 'object') { + return false; + } + return (subject.digest as Record).sha256 === expectedSha256; + }); + if (!exact) throw new Error('Update attestation does not identify the downloaded artifact'); +} + +export async function verifyDownloadedUpdateAttestation( + options: VerifyDownloadedUpdateAttestationOptions, +): Promise { + const version = options.version.trim().replace(/^v/iu, ''); + const expectedName = exactDesktopUpdateArtifactName( + version, + options.platform ?? process.platform, + options.arch ?? process.arch, + ); + const [artifactSha256, bundleBytes] = await Promise.all([ + sha256File(options.downloadedFile), + (options.fetchBundle ?? fetchBytesCapped)(productReleaseAttestationUrl(version)), + ]); + const bundle = parseBundle(bundleBytes); + + if (options.verifyBundle) { + await options.verifyBundle(bundle); + } else { + const trustedRoot = await getTrustedRoot({ + cachePath: options.trustRootCacheDirectory, + timeout: 10_000, + }); + const verifier = new Verifier(toTrustMaterial(trustedRoot)); + verifier.verify(toSignedEntity(bundle), { + subjectAlternativeName: PRODUCT_RELEASE_SIGNER, + extensions: { issuer: GITHUB_ACTIONS_OIDC_ISSUER }, + }); + } + + assertProductReleaseAttestationSubject(bundle, expectedName, artifactSha256); +} diff --git a/apps/desktop/src/main/app-update-service.ts b/apps/desktop/src/main/app-update-service.ts index b6d82672a2..2cf9c93743 100644 --- a/apps/desktop/src/main/app-update-service.ts +++ b/apps/desktop/src/main/app-update-service.ts @@ -20,6 +20,8 @@ import electronUpdater from 'electron-updater'; import type { AppUpdater, UpdateCheckResult } from 'electron-updater'; import type { ProgressInfo, UpdateInfo } from 'electron-updater'; +import type { DownloadedUpdateAttestationVerifier } from './app-update-attestation.js'; +import { resolveUpdateFeedOverride } from './app-update-test-context.js'; export type AppUpdateProgress = { percent: number; @@ -43,6 +45,7 @@ export type AppUpdateStatus = latestVersion: string; progress: AppUpdateProgress; } + | { state: 'verifying'; currentVersion: string; latestVersion: string } | { state: 'downloaded'; currentVersion: string; @@ -94,6 +97,7 @@ interface AppUpdateServiceDeps { mockLatestVersion?: string; mockState?: 'available' | 'downloading' | 'downloaded'; onStatusChange?: (status: AppUpdateStatus) => void; + verifyDownloadedUpdate: DownloadedUpdateAttestationVerifier; prepareInstall: ( input: AppUpdateInstallRequest, ) => Promise< @@ -136,39 +140,9 @@ const UPDATE_CHECK_ON_FOCUS_MIN_INTERVAL_MS = 15 * 60 * 1000; * capability across any privilege boundary. Loopback-only keeps even that * same-user surface minimal: the feed must be a process listening on this * machine. With the variable unset the feed configuration is byte-identical - * to production, and update signature verification (once a certificate - * exists) applies to overridden feeds exactly as it does to the GitHub feed — - * nothing here relaxes it. + * to production. The application composition root deliberately bypasses + * release provenance only for this synthetic-byte transport harness. */ -export function resolveUpdateFeedOverride( - raw: string | undefined, -): { provider: 'generic'; url: string } | undefined { - if (raw === undefined || raw === '') return undefined; - let url: URL; - try { - url = new URL(raw); - } catch { - throw new TypeError( - `MAKA_UPDATE_TEST_FEED is not a URL: ${JSON.stringify(raw)}`, - ); - } - if ( - url.protocol !== 'http:' || - url.hostname !== '127.0.0.1' || - url.port === '' || - url.username !== '' || - url.password !== '' || - url.search !== '' || - url.hash !== '' - ) { - throw new TypeError( - 'MAKA_UPDATE_TEST_FEED must be http://127.0.0.1:[/path] ' + - `(got ${JSON.stringify(raw)})`, - ); - } - return { provider: 'generic', url: url.toString() }; -} - function normalizeVersion(version: string): string { return version.trim().replace(/^v/i, ''); } @@ -244,6 +218,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer cancellationToken?: UpdateCheckResult['cancellationToken']; cancelledForRetry: boolean; } | undefined; + let activeVerification: Promise | undefined; let checkTimer: unknown; let installHandoff: { rollback(): void } | undefined; let started = false; @@ -268,7 +243,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer const currentStatus = (): AppUpdateStatus => status; const latestVersion = () => - status.state === 'available' || status.state === 'downloading' || status.state === 'downloaded' || + status.state === 'available' || status.state === 'downloading' || status.state === 'verifying' || status.state === 'downloaded' || status.state === 'installing' || status.state === 'error' ? status.latestVersion : updateInfoVersion(latestInfo); @@ -352,16 +327,35 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer }); updater.on('update-downloaded', (event) => { latestInfo = event; - publish({ - state: 'downloaded', - currentVersion: deps.currentVersion, - latestVersion: updateInfoVersion(event) ?? latestVersion() ?? deps.currentVersion, - }); + const version = updateInfoVersion(event) ?? latestVersion() ?? deps.currentVersion; + publish({ state: 'verifying', currentVersion: deps.currentVersion, latestVersion: version }); + const verification = Promise.resolve().then(() => + deps.verifyDownloadedUpdate({ + downloadedFile: event.downloadedFile, + version, + }), + ); + activeVerification = verification; + void verification + .then(() => { + if (activeVerification !== verification) return; + publish({ + state: 'downloaded', + currentVersion: deps.currentVersion, + latestVersion: version, + }); + }) + .catch((error) => { + if (activeVerification === verification) publishError('download', error); + }) + .finally(() => { + if (activeVerification === verification) activeVerification = undefined; + }); }); updater.on('error', (error) => { const operation = status.state === 'installing' ? 'install' - : status.state === 'available' || status.state === 'downloading' + : status.state === 'available' || status.state === 'downloading' || status.state === 'verifying' ? 'download' : 'check'; if (operation === 'install') rollbackInstallHandoff(); @@ -375,15 +369,17 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer if (!deps.isPackaged) { return publish({ state: 'not-available', currentVersion: deps.currentVersion }); } - if (status.state === 'downloaded' || status.state === 'installing') return status; + if (status.state === 'verifying' || status.state === 'downloaded' || status.state === 'installing') return status; if (status.state === 'downloading' && !allowDuringDownload) return status; if (activeDownload && !allowDuringDownload) return status; if (checkInFlight) return checkInFlight; lastCheckStartedAt = now(); checkInFlight = updater .checkForUpdates() - .then((result) => { + .then(async (result) => { trackAutoDownload(result); + const verification = activeVerification; + if (verification) await verification.catch(() => undefined); return status; }) .catch((error) => status.state === 'error' ? status : publishError('check', error)) @@ -423,7 +419,7 @@ export function createAppUpdateService(deps: AppUpdateServiceDeps): AppUpdateSer if (deps.mockLatestVersion) { return publish(mockStatus(deps.currentVersion, deps.mockLatestVersion, 'downloaded')); } - if (!deps.isPackaged || status.state === 'downloaded' || status.state === 'installing') { + if (!deps.isPackaged || status.state === 'verifying' || status.state === 'downloaded' || status.state === 'installing') { return status; } if (checkInFlight) await checkInFlight; diff --git a/apps/desktop/src/main/app-update-test-context.ts b/apps/desktop/src/main/app-update-test-context.ts new file mode 100644 index 0000000000..6700140002 --- /dev/null +++ b/apps/desktop/src/main/app-update-test-context.ts @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFileSync } from 'node:fs'; +import { basename, dirname, isAbsolute, join } from 'node:path'; + +/** Exact loopback-only feed accepted by packaged auto-update E2E tests. */ +export function resolveUpdateFeedOverride( + raw: string | undefined, +): { provider: 'generic'; url: string } | undefined { + if (raw === undefined || raw === '') return undefined; + let url: URL; + try { + url = new URL(raw); + } catch { + throw new TypeError(`MAKA_UPDATE_TEST_FEED is not a URL: ${JSON.stringify(raw)}`); + } + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + url.port === '' || + url.username !== '' || + url.password !== '' || + url.search !== '' || + url.hash !== '' + ) { + throw new TypeError( + 'MAKA_UPDATE_TEST_FEED must be http://127.0.0.1:[/path] ' + + `(got ${JSON.stringify(raw)})`, + ); + } + return { provider: 'generic', url: url.toString() }; +} + +/** + * Keeps both sides of a macOS replacement in one disposable profile. + * + * The candidate receives an explicit directory paired with the loopback feed. + * Squirrel.Mac does not preserve that environment when it relaunches, so the + * test-only successor carries `makaUpdateTestProfile: true` in package.json and + * derives the same directory from its unchanged bundle location. + */ +export function resolveUpdateTestUserDataDirectory({ + feedUrl, + explicitDirectory, + isPackaged, + appPath, + executablePath, +}: { + feedUrl?: string; + explicitDirectory?: string; + isPackaged: boolean; + appPath: string; + executablePath: string; +}): string | undefined { + if (explicitDirectory) { + if (!resolveUpdateFeedOverride(feedUrl)) { + throw new TypeError('MAKA_UPDATE_TEST_USER_DATA_DIR requires MAKA_UPDATE_TEST_FEED'); + } + if (!isAbsolute(explicitDirectory)) { + throw new TypeError('MAKA_UPDATE_TEST_USER_DATA_DIR must be an absolute path'); + } + return explicitDirectory; + } + if (!isPackaged || process.platform !== 'darwin') return undefined; + + let manifest: unknown; + try { + manifest = JSON.parse(readFileSync(join(appPath, 'package.json'), 'utf8')); + } catch { + return undefined; + } + if ( + !manifest || + typeof manifest !== 'object' || + !('makaUpdateTestProfile' in manifest) || + manifest.makaUpdateTestProfile !== true + ) { + return undefined; + } + const bundle = dirname(dirname(dirname(executablePath))); + if (basename(bundle) !== 'Maka.app') { + throw new TypeError(`Update-test executable is not inside Maka.app: ${executablePath}`); + } + return join(dirname(bundle), '.maka-update-test-user-data'); +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 6b0405cd58..1780b0c4c2 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -26,6 +26,7 @@ import { import { app, clipboard, dialog, ipcMain } from 'electron'; import { join } from 'node:path'; import { resolveBuildInfo } from './build-info.js'; +import { resolveUpdateTestUserDataDirectory } from './app-update-test-context.js'; import { captureDesktopDiagnosticEnvironment, copyDesktopDiagnosticReport, @@ -56,6 +57,15 @@ installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDi // path logic. See https://github.com/maka-agent/maka-agent/issues/2252. app.setName(app.isPackaged ? 'Maka' : 'Maka Dev'); +const updateTestUserData = resolveUpdateTestUserDataDirectory({ + feedUrl: process.env.MAKA_UPDATE_TEST_FEED, + explicitDirectory: process.env.MAKA_UPDATE_TEST_USER_DATA_DIR, + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + executablePath: process.execPath, +}); +if (updateTestUserData) app.setPath('userData', updateTestUserData); + // E2E isolation: redirect userData BEFORE the single-instance lock so the // lock judges the throwaway dir, not the real user data — otherwise a // developer with Maka open makes the E2E process exit as a "second instance". diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 9440a70e3f..2132d23afb 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -62,6 +62,7 @@ import { resolveStorageRoot } from "@maka/storage/root-authority"; import { createMcpOAuthController } from "./mcp-oauth-controller.js"; import { registerAppClientIpc, registerAppIpc } from "./app-ipc-main.js"; import { createAppQuitCoordinator } from "./app-quit-coordinator.js"; +import { verifyDownloadedUpdateAttestation } from "./app-update-attestation.js"; import { createAppUpdateService } from "./app-update-service.js"; import { createAttachmentApprovalRegistry } from "./attachment-approval.js"; import { renderAttachmentPreview, resizeImageForAttachment } from "./attachment-resize-native.js"; @@ -656,14 +657,27 @@ const updateMockState = process.env.MAKA_UPDATE_MOCK_STATE === "downloaded" ? process.env.MAKA_UPDATE_MOCK_STATE : undefined; +const updateTestFeed = process.env.MAKA_UPDATE_TEST_FEED; const updateService = createAppUpdateService({ currentVersion: app.getVersion(), isPackaged: app.isPackaged, - testFeedUrl: process.env.MAKA_UPDATE_TEST_FEED, + testFeedUrl: updateTestFeed, mockLatestVersion: process.env.MAKA_UPDATE_MOCK_VERSION, mockState: updateMockState, onStatusChange: (status) => mainWindowController.send("app:updateStatusChanged", status), + // The loopback-only upgrade harness owns synthetic bytes that cannot carry + // a GitHub Actions identity, so it tests updater mechanics rather than + // provenance. Ordinary packaged launches have no override and always reach + // the Sigstore verifier below. + verifyDownloadedUpdate: updateTestFeed + ? async () => {} + : ({ downloadedFile, version }) => + verifyDownloadedUpdateAttestation({ + downloadedFile, + version, + trustRootCacheDirectory: join(userDataDir, "update-trust", "sigstore"), + }), prepareInstall: async (input) => { if (!runtimeHostManager) throw new Error("Runtime Host manager is unavailable"); const retirement = await runtimeHostManager.retireOwnedLocalHost( diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index c28197801a..01de26dab3 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -270,6 +270,7 @@ export type AppUpdateStatus = total?: number; }; } + | { state: 'verifying'; currentVersion: string; latestVersion: string } | { state: 'downloaded'; currentVersion: string; diff --git a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts index 86399bcba6..fc4b8b91ec 100644 --- a/apps/desktop/src/renderer/locales/settings-preferences-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-preferences-copy.ts @@ -238,6 +238,7 @@ export type SettingsPreferencesCopy = { updateNotAvailable: string; updateAvailable: (version: string) => string; updateDownloading: (version: string, percent: number) => string; + updateVerifying: (version: string) => string; updateDownloaded: (version: string) => string; updateInstalling: (version: string) => string; updateCheckFailed: string; @@ -336,6 +337,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { updateNotAvailable: '已是最新版本。', updateAvailable: (version) => `发现新版本 v${version},正在准备下载…`, updateDownloading: (version, percent) => `正在下载 v${version}(${percent}%)…`, + updateVerifying: (version) => `正在验证 v${version} 的发布来源…`, updateDownloaded: (version) => `v${version} 已下载,可在侧栏选择重启安装。`, updateInstalling: (version) => `正在安装 v${version}…`, updateCheckFailed: '检查更新失败', @@ -387,6 +389,7 @@ const SETTINGS_PREFERENCES_COPY_BY_LOCALE = { updateNotAvailable: 'You are on the latest version.', updateAvailable: (version) => `Version v${version} is available and will download shortly…`, updateDownloading: (version, percent) => `Downloading v${version} (${percent}%)…`, + updateVerifying: (version) => `Verifying the release provenance for v${version}…`, updateDownloaded: (version) => `v${version} is ready. Restart from the sidebar to install.`, updateInstalling: (version) => `Installing v${version}…`, updateCheckFailed: 'Could not check for updates', diff --git a/apps/desktop/src/renderer/settings/about-update-status.ts b/apps/desktop/src/renderer/settings/about-update-status.ts index 005e63ad8d..ca550af56e 100644 --- a/apps/desktop/src/renderer/settings/about-update-status.ts +++ b/apps/desktop/src/renderer/settings/about-update-status.ts @@ -36,6 +36,7 @@ export function aboutUpdateStatusDetail( if (status.state === 'downloading') { return copy.updateDownloading(status.latestVersion, Math.round(status.progress.percent)); } + if (status.state === 'verifying') return copy.updateVerifying(status.latestVersion); if (status.state === 'downloaded') return copy.updateDownloaded(status.latestVersion); if (status.state === 'installing') return copy.updateInstalling(status.latestVersion); return copy.updateCheckFailedDetail(status.message); diff --git a/docs/cli-distribution.md b/docs/cli-distribution.md index 8207d0aabf..af6e49bc90 100644 --- a/docs/cli-distribution.md +++ b/docs/cli-distribution.md @@ -17,9 +17,11 @@ under the License. --> -# CLI/TUI distribution contract +# CLI/TUI convenience distribution contract -Maka ships its CLI/TUI as a required artifact of the same product release as Desktop. Phase 1 +Maka's Apache release is the IPMC-approved source archive on ASF distribution infrastructure. The +CLI/TUI ZIP and Desktop installers are required convenience artifacts built from that exact source +identity. Phase 1 publishes one signed and notarized Apple Silicon artifact: `Maka--cli-mac-arm64.zip` @@ -74,7 +76,8 @@ Root `package.json` is the sole version authority. Desktop and CLI manifests mus packaging. Desktop, CLI/TUI, and source jobs build independently from one commit; one publish job collects their verified outputs and creates one Draft GitHub Release. -The GitHub Release ZIP is the immutable standalone distribution source. npm keeps its +The GitHub Release ZIP is the standalone convenience distribution source. Its exact bytes are +covered by a Sigstore provenance bundle signed with the protected Finalize workflow identity. npm keeps its installer-specific tarball, OIDC, staged-publishing, and 2FA approval flow, but may start only after the product `v` tag and GitHub Release exist. It checks out that tag's exact commit and derives the same version, runtime closure, file policy, notices, and source identity. It does not @@ -86,8 +89,8 @@ consume the standalone ZIP. | Question | Decision | Enforced by | | --- | --- | --- | | Which file owns the product version? | Root `package.json`; Desktop and CLI manifests must match it. | `product-release-identity.mjs` and release contract tests | -| Which event defines a product release? | One `v` tag from `main`, one source commit, and one Draft GitHub Release. An interrupted Draft upload may retry only that exact commit. | `release.yml` identity and publish jobs plus the exact-tag helper | -| Which artifacts are required? | macOS and Windows Desktop installers and update assets, the macOS arm64 standalone CLI ZIP, and bundled source. | The exact manifest from `product-release-identity.mjs`, enforced by each artifact job and the publish job | +| Which event defines the Apache release? | The approved source archive and vote result. The `v` tag and Draft GitHub Release identify convenience distributions built from that source commit. | ASF source-release workflow plus `release.yml` identity and exact-tag checks | +| Which convenience artifacts are required? | macOS and Windows Desktop installers and update assets plus the macOS arm64 standalone CLI ZIP. | The exact manifest from `product-release-identity.mjs`, enforced by each artifact job and the publish job | | Is npm another release authority? | No. It is an optional install channel whose Stage ref, source, workflow identity, and provenance all resolve to the existing product tag commit. | Tag-dispatched OIDC staging and read-only finalization; no npm-specific tag or GitHub Release | | Does the standalone CLI define another package policy? | No. It derives the workspace closure, third-party pruning, notices, and Eval runtime assets from their current manifests and shared policy. | Packaging and artifact contract tests | | Which commands are public? | `maka` only; TUI is its default mode. | CLI manifest, help tests, wrapper, and release metadata | diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index ce8aa89af2..6322344b85 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -23,6 +23,10 @@ This runbook is the operational authority for publishing the `maka-agent` npm installation channel. The root `package.json` remains the sole Maka product-version authority, and `packages/cli/package.json` must match it. Every public npm version must come from the exact tarball validated by the Stage workflow. +The IPMC-approved source archive on ASF distribution infrastructure is the Apache release. npm, +Desktop installers, and GitHub Release assets are convenience packages built from that approved +source identity; they are not additional ASF release artifacts. + The source-RC [npm preflight](../.github/ASF_NPM_RELEASE.md) is an earlier, credential-free compatibility check. Its tarball is not carried into publication. After source approval, Stage rebuilds from the final product tag at the same approved commit and becomes the byte authority for @@ -32,11 +36,12 @@ retaining Maka's stronger protected-Environment, staged-publishing, 2FA, and Fin ## Release invariants - Dispatch the product Release workflow only from the exact approved ASF source candidate tag. - Dispatch npm Stage only from the resulting product `v` tag and npm Finalize from `main`. + Dispatch 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. -- Do not create an npm-specific Git tag or GitHub Release. The product `v` tag and GitHub Release are owned only by the `Release` workflow, and must already exist before npm staging. +- Do not create an npm-specific Git tag or GitHub Release. The `Release` workflow creates the + product `v` tag and Draft before npm staging; Finalize is the sole publisher of that Draft. - Keep that GitHub Release in Draft until npm Finalize and Desktop remote Runtime Host acceptance succeed. The Draft supplies npm's product identity; its publication is the final product action. - Do not run `npm publish`. GitHub Actions may only run `npm stage publish`; a human package @@ -50,24 +55,32 @@ The two workflow boundaries are: tag and GitHub Release, checks out that exact product commit, builds and validates one immutable tarball, records that single tag commit and workflow run, enters the protected `npm-release` Environment, and submits it to npm staging through OIDC. -2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) accepts only the exact successful Stage run and attempt, then verifies the public registry bytes, signature, provenance, and dist-tag. It creates no tag or GitHub Release. +2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) accepts only the exact + successful Stage run, Release build run, and self-contained publication record. The current + reviewed verifier on `main` checks the public registry bytes, signature, provenance, dist-tag, + immutable build artifacts, and live Draft digests, then waits at the protected `product-release` + Environment. After independent Desktop acceptance, approval attests the exact convenience + artifacts with the protected workflow identity, publishes the GitHub Release, and applies its + Stable/Latest classification in one operation. ## One-time control-plane configuration ### GitHub Environment -The checked-in `.asf.yaml` is the authority for the `npm-release` Environment. After it reaches +The checked-in `.asf.yaml` is the authority for the `npm-release` and `product-release` +Environments. After it reaches `main`, confirm ASF reconciliation produced: -- a selected deployment tag rule matching `v*`, with no branch rule; +- a selected `v*` tag rule for `npm-release` and a selected `main` branch rule for + `product-release`; - `M4n5ter` as the required reviewer; - self-review disabled; -- administrator bypass disabled where repository policy permits it; -- no environment secrets or variables. +- administrator bypass disabled where repository policy permits it. Repository administration permission is required to inspect or repair reconciliation. Do not maintain -a second manual Environment policy in GitHub. The workflow itself uses GitHub OIDC and does not read -an npm token. +a second manual Environment policy in GitHub. Finalize uses GitHub Actions OIDC to create Sigstore +provenance for the exact convenience artifacts and stores the offline verification bundle beside +them. It requires no repository-administration credential, signing key, or npm token. ### npm Trusted Publisher @@ -189,15 +202,21 @@ Do not change `next` when it already points to a newer version such as `0.2.0-be intentionally manual: npm Trusted Publishing authenticates `npm publish` and `npm stage publish`, not dist-tag mutations, and the release workflows must not gain a long-lived npm token. -## Finalize the public npm channel +## Finalize the product release After npm reports the version as public: -1. Open **Actions → Finalize CLI npm channel → Run workflow** on `main`. -2. Enter the successful Stage run ID, its exact run attempt, and the version. +1. Open **Actions → Finalize product release → Run workflow** on `main`. +2. Enter the successful Stage run ID and attempt, the successful Release build run ID and attempt, + and the version. 3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. -4. Confirm the workflow preserved the verified public package as an Actions artifact and did not create or modify any Git tag or GitHub Release. +4. While the publication job waits for `product-release` approval, complete the product checklist's + cross-machine acceptance against the Draft. +5. Approve the Environment. Confirm the workflow matches every live Draft digest to the exact + Release attempt's publication record, creates and uploads + `Maka--attestation.sigstore.json`, publishes the convenience Release, and makes a stable + release Latest without a separate manual action. Check the resulting registry state: @@ -211,8 +230,8 @@ Finally, install the exact public version on each release platform and complete turn. On the supported Eval host, complete at least one real experiment cell and inspect score, usage, cost, and artifacts. -Return to the [product release checklist](../.github/RELEASE_CHECKLIST.md) and exercise remote Runtime -Host setup from the packaged Desktop apps before publishing the GitHub Release. +The [product release checklist](../.github/RELEASE_CHECKLIST.md) remains the authority for the +acceptance evidence required before approving publication. ## Failure recovery @@ -243,10 +262,15 @@ Reject the stage, fix the problem on `main`, increment the product version, crea ### npm approval succeeded but Finalize failed The npm version is already immutable. Do not publish or approve it again. Preserve the Stage run ID, -attempt, version, and artifacts. If the package bytes and provenance are valid, fix the current -Finalize verifier on `main` and rerun Finalize against that same successful Stage identity. - -Finalize is read-only with respect to product release state. If the npm package version, bytes, dist-tag, signature, provenance, or recorded Stage identity differ, stop and investigate; do not modify the product tag or GitHub Release to make npm verification pass. +attempt, version, and the Release run ID, attempt, publication record, and artifacts. If those bytes +and provenance are valid, fix the Finalize verifier on `main` and rerun it against the same immutable +Stage and Release evidence. + +The inspection job is read-only. Only the protected publication job may perform the single +Draft-to-published transition and attest its bytes. If npm identity, build evidence, or the Draft differs, stop and +investigate; do not modify the product tag or GitHub Release to make verification pass. If failure is +reported after the publication request, inspect the exact Release first: a successful publication +must not be repeated. ### The public version is defective diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index 9deaa1d35f..76cefee8f4 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -23,6 +23,9 @@ 本文档是发布 `maka-agent` npm 安装渠道的操作权威。根目录 `package.json` 仍是 Maka 唯一产品版本权威,`packages/cli/package.json` 必须与其一致。每个公开 npm 版本都必须来自 Stage workflow 验证过的同一个精确 tarball。 +ASF 分发基础设施上经 IPMC 批准的源码归档才是 Apache release。npm、Desktop 安装包和 +GitHub Release assets 都是从该源码身份构建的便利包,不是额外的 ASF release artifacts。 + source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行、且不持有发布凭据的兼容性检查;其 tarball 不会进入正式发布。source release 获批后,Stage 从位于同一获批 commit 的最终产品 tag 重新构建,并成为 npm staging 与 registry 验证所使用的字节权威。这与 Apache OpenDAL 孵化期的实践一致,同时保留了 Maka 更严格的受保护 Environment、staged publishing、2FA 与 Finalize 控制。 ## 发布不变量 @@ -30,7 +33,8 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 - 产品 Release workflow 只能从已批准的 ASF source candidate tag dispatch;npm Stage 只能从随后创建的产品 `v` tag dispatch,npm Finalize 只能从 `main` dispatch; - 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 更新的预发布版本时,两个 tag 都指向稳定版; -- 不创建 npm 专属 Git tag 或 GitHub Release;产品 `v` tag 与 GitHub Release 只由 `Release` workflow 管理,并且必须先于 npm staging 存在; +- 不创建 npm 专属 Git tag 或 GitHub Release;`Release` workflow 在 npm staging 前创建产品 + `v` tag 与 Draft,Finalize 是该 Draft 唯一的发布者; - 在 npm Finalize 与 Desktop 远程 Runtime Host 验收成功前,GitHub Release 必须保持 Draft; Draft 为 npm 提供产品身份,发布 Draft 是最终的产品发布动作; - 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package @@ -42,23 +46,27 @@ source RC 阶段的 [npm 预检](../.github/ASF_NPM_RELEASE.md) 是更早执行 1. [Stage CLI npm release](../.github/workflows/release-cli-stage.yml) 解析已有的产品 tag 与 GitHub Release,checkout 该产品的精确 commit,构建并验证一个 immutable tarball,记录这个唯一的 tag commit 与 workflow run,进入受保护的 `npm-release` Environment,然后通过 OIDC 提交到 npm staging; -2. [Finalize CLI npm channel](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 Stage run 和 attempt,并验证公共 registry 字节、signature、provenance 和 dist-tag;它不创建 tag 或 GitHub Release。 +2. [Finalize product release](../.github/workflows/release-cli-finalize.yml) 只接受精确的成功 + Stage run、Release build run 及其自包含 publication record;`main` 上当前已审查的 verifier + 验证公共 registry 字节、signature、provenance、dist-tag、不可变 build artifacts 与 live Draft + digest,然后等待受保护的 `product-release` Environment;独立 Desktop 验收完成并批准后,它会 + 用受保护 workflow 的身份证明精确便利包,并在同一个操作中发布 GitHub Release 及其 Stable/Latest 分类。 ## 一次性控制面配置 ### GitHub Environment -仓库中的 `.asf.yaml` 是 `npm-release` Environment 的权威。该配置进入 `main` 后,确认 ASF +仓库中的 `.asf.yaml` 是 `npm-release` 和 `product-release` Environment 的权威。该配置进入 `main` 后,确认 ASF 同步出的 live 配置满足: -- 使用匹配 `v*` 的 selected deployment tag rule,不配置 branch rule; +- `npm-release` 使用 selected `v*` tag rule,`product-release` 使用 selected `main` branch rule; - required reviewer 为 `M4n5ter`; - 禁止 self-review; -- 仓库策略允许时禁用 administrator bypass; -- 不配置 environment secret 或 variable。 +- 仓库策略允许时禁用 administrator bypass。 检查或修复同步结果需要仓库 administration 权限;不要再在 GitHub UI 中维护第二套手工 -Environment policy。workflow 使用 GitHub OIDC,不读取 npm token。 +Environment policy。Finalize 使用 GitHub Actions OIDC 为精确便利包生成 Sigstore provenance, +并把离线验证 bundle 与便利包放在一起;不需要仓库 administration credential、签名私钥或 npm token。 ### npm Trusted Publisher @@ -171,15 +179,19 @@ npm dist-tag add "maka-agent@$version" next --registry https://registry.npmjs.or npm Trusted Publishing 只认证 `npm publish` 和 `npm stage publish`,不认证 dist-tag 变更,而 release workflow 不得获得长期 npm token。 -## Finalize 公共 npm 渠道 +## Finalize 产品发布 npm 显示该版本已经公开后: -1. 在 `main` 上打开 **Actions → Finalize CLI npm channel → Run workflow**; -2. 输入成功 Stage 的 run ID、精确 run attempt 和 version; +1. 在 `main` 上打开 **Actions → Finalize product release → Run workflow**; +2. 输入成功 Stage 的 run ID 与精确 attempt、成功 Release build 的 run ID 与精确 attempt,以及 + version; 3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; -4. 确认 workflow 将验证后的公开包保存为 Actions artifact,且没有创建或修改任何 Git tag 或 GitHub Release。 +4. publication job 等待 `product-release` 批准期间,针对 Draft 完成产品检查清单中的跨机器验收; +5. 批准 Environment,并确认 workflow 将每个 live Draft digest 与精确 Release attempt 的 + publication record 对比,生成并上传 `Maka--attestation.sigstore.json`,发布便利包 + Release;stable release 会同时成为 Latest,不再需要单独人工操作。 检查最终 registry 状态: @@ -192,8 +204,7 @@ npm view maka-agent dist-tags --json 最后,在每个发布平台安装精确的公共版本,并完成一次真实的 TUI/model turn。在支持的 Eval host 上完成至少一个真实 experiment cell,检查 score、usage、cost 和 artifacts。 -回到[产品发布检查清单](../.github/RELEASE_CHECKLIST.md),使用打包后的 Desktop 应用完成远程 -Runtime Host setup 验收,再发布 GitHub Release。 +[产品发布检查清单](../.github/RELEASE_CHECKLIST.md)仍是批准发布前所需验收证据的权威。 ## 失败恢复 @@ -222,11 +233,14 @@ npm stage reject "$stage_id" --registry https://registry.npmjs.org/ ### npm approval 成功,但 Finalize 失败 -npm 版本此时已经 immutable,不要再次 publish 或 approve。保留 Stage run ID、attempt、version -和 artifacts。如果 package 字节与 provenance 有效,在 `main` 修复当前 Finalize verifier, -然后针对同一个成功 Stage identity 重新运行 Finalize。 +npm 版本此时已经 immutable,不要再次 publish 或 approve。保留 Stage run ID、attempt、version, +以及 Release run ID、attempt、publication record 和 artifacts。如果这些字节与 provenance +有效,在 `main` 修复 Finalize verifier,然后针对同一组不可变 Stage 与 Release 证据重新运行。 -Finalize 对产品发布状态只读。如果 npm 包版本、字节、dist-tag、签名、provenance 或记录的 Stage identity 不一致,立即停止并调查;不要修改产品 tag 或 GitHub Release 来让 npm 验证通过。 +inspection job 只读;只有受保护的 publication job 可以执行一次 Draft-to-published 转换并证明其字节。如果 +npm identity、build evidence 或 Draft 不一致,立即停止并调查;不要修改产品 tag 或 GitHub +Release 来让验证通过。如果错误发生在 publication request 之后,先检查精确 Release;已经成功 +完成的发布不得重复执行。 ### 公共版本存在缺陷 diff --git a/package-lock.json b/package-lock.json index e508644b95..262c8d4ef9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,9 @@ "@maka/runtime": "0.1.0", "@maka/runtime-host": "0.1.0", "@maka/storage": "0.1.0", + "@sigstore/bundle": "5.0.0", + "@sigstore/tuf": "5.0.0", + "@sigstore/verify": "4.1.2", "electron-updater": "^6.8.9", "node-pty": "^1.2.0-beta.15", "qrcode": "^1.5.4", @@ -2159,6 +2162,15 @@ "integrity": "sha512-j8cUmOJzVgkHuS0QiQ6ga76UIoLOFSAMWhs7aZJztH3aAdCOAE6vpC8KVvFB4cU10ON0y2/5oOVmPJ43s2lTwA==", "license": "MIT" }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.17", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", @@ -3736,6 +3748,63 @@ } } }, + "node_modules/@sigstore/bundle": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-5.0.0.tgz", + "integrity": "sha512-wefjygudENbzbQMks1t5u34EP0fFoD0XvaEP7DOUP/sXKvogzEJYFw5E6pegGyp3onGWzVEYKVa3bNZWyTYX+A==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@sigstore/core": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-4.0.1.tgz", + "integrity": "sha512-9v5hRjujn5NXq8o7XFEUgLyAtdr5Iisb4pzM05u3K61IS5q3hP3luWAndk0RkPPLTUFoTbg7Vb84UQ1ZQeajWQ==", + "license": "Apache-2.0", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.2.tgz", + "integrity": "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-5.0.0.tgz", + "integrity": "sha512-Zyqg9tcHps3uRAlKHLNmsW4ohsUZAjb9G+31r7lg0ICh/JOcadzmJsIRdjKljlRHpaR0K4aJ2kXXIdywdcdMlA==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^6.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-4.1.2.tgz", + "integrity": "sha512-BfD9eLrz3A/DG58aSgfgZYmIR6V9Yw96QVN/frtu2bEH7ctSTk2TDvHU12un3JsDPBTqTNkqkIkucjxljpOFqQ==", + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^5.0.0", + "@sigstore/core": "^4.0.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -4008,6 +4077,28 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-5.0.0.tgz", + "integrity": "sha512-U4mVcdFGOi6pt8n38LdWZp67Svn7ppnU1Pj8SGOVaBi1X4gm+G4ztQlLfkoJbKSHfjA6WeaiJp2A4V83AJF6nQ==", + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.2.1" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -5502,7 +5593,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -5672,7 +5762,6 @@ "version": "5.0.9", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -10465,7 +10554,6 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -13492,6 +13580,20 @@ "dev": true, "license": "0BSD" }, + "node_modules/tuf-js": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-6.0.0.tgz", + "integrity": "sha512-zlJVOIO68hmgo1//X4ENEcTGfuOTAtDPi8PsTsG+FyxD85E/ww1ZnwBbWo/yCEExGpI+Kilg7Z3qCdHX2BoJTQ==", + "license": "MIT", + "dependencies": { + "@gar/promise-retry": "^1.0.3", + "@tufjs/models": "5.0.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/turndown": { "version": "7.2.4", "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", diff --git a/package.json b/package.json index 2d5a2528b3..3d960adda0 100644 --- a/package.json +++ b/package.json @@ -62,16 +62,18 @@ "check:product-release-identity": "node scripts/product-release-identity.mjs", "package:cli:macos-arm64": "node scripts/package-macos-arm64-cli.mjs", "verify:cli:macos-arm64": "node scripts/verify-macos-arm64-cli.mjs", - "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs", + "test:product-release": "node --test scripts/product-release.test.mjs scripts/product-release-authority.test.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", "generate:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs", "check:runtime-host-peer-dependencies": "node scripts/generate-runtime-host-peer-dependencies.mjs --check", "generate:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs", "check:runtime-host-peer-notices": "node scripts/generate-runtime-host-peer-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-artifacts.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && npm run check:model-metadata && npm run check:product-release-identity && npm run check:asf-npm && node --test scripts/product-release.test.mjs scripts/product-release-authority.test.mjs scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-runtime-host-diagnostics.test.mjs scripts/release-cli-workflow-policy.test.mjs scripts/verify-packaged-app.test.mjs scripts/third-party-closure.test.mjs scripts/generate-third-party-notices.test.mjs scripts/source-legal-inventory.test.mjs scripts/sync-model-metadata.test.mjs scripts/windows-package-source-closure.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", + "package:macos-autoupdate-next": "node scripts/package-macos-autoupdate-next.mjs", + "verify:macos-autoupdate": "node scripts/verify-macos-autoupdate.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", "verify:windows-x64": "node scripts/verify-windows-x64.mjs", "verify:windows-installer": "node scripts/verify-windows-installer-lifecycle.mjs", diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index 4e28e4b0b1..9e4f034f5b 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -35,6 +35,7 @@ const FULL_SUITE_FILES = new Set([ ]); const RELEASE_CONTRACT_FILES = new Set([ + 'apps/desktop/src/main/app-update-test-context.ts', 'apps/desktop/build/entitlements.mac.inherit.plist', 'apps/desktop/build/entitlements.mac.plist', 'apps/desktop/bundled-tools.json', @@ -47,6 +48,7 @@ const RELEASE_CONTRACT_FILES = new Set([ '.github/workflows/release.yml', '.github/workflows/release-windows-check.yml', 'scripts/package-macos-arm64.mjs', + 'scripts/package-macos-autoupdate-next.mjs', 'scripts/package-macos-arm64-cli.mjs', 'scripts/package-windows-autoupdate-next.mjs', 'scripts/package-windows-x64.mjs', @@ -58,6 +60,8 @@ const RELEASE_CONTRACT_FILES = new Set([ 'scripts/release-version.mjs', 'scripts/verify-macos-arm64-cli.mjs', 'scripts/verify-macos-arm64-dmg.mjs', + 'scripts/verify-macos-autoupdate.mjs', + 'scripts/desktop-update-contract.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-windows-autoupdate.mjs', 'scripts/verify-windows-installer-lifecycle.mjs', diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 24a100987e..b082b88ff4 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -130,6 +130,7 @@ test('source legal authority and generated provenance select the ASF source gate test('release authority changes select their dedicated contract gate', () => { for (const path of [ + 'apps/desktop/src/main/app-update-test-context.ts', 'apps/desktop/build/entitlements.mac.plist', 'apps/desktop/electron-builder.config.mjs', 'apps/desktop/package.json', @@ -138,11 +139,11 @@ test('release authority changes select their dedicated contract gate', () => { '.github/workflows/release-cli-stage.yml', '.github/workflows/release.yml', 'scripts/package-macos-arm64.mjs', + 'scripts/package-macos-autoupdate-next.mjs', 'scripts/package-macos-arm64-cli.mjs', 'scripts/package-windows-x64.mjs', 'scripts/prepare-windows-upgrade-baseline.mjs', 'scripts/product-release-artifacts.mjs', - 'scripts/product-release-artifacts.test.mjs', 'scripts/product-release-authority.mjs', 'scripts/product-release-authority.test.mjs', 'scripts/product-release-identity.mjs', @@ -153,6 +154,8 @@ test('release authority changes select their dedicated contract gate', () => { 'scripts/release-cli-publication.test.mjs', 'scripts/verify-macos-arm64-cli.mjs', 'scripts/verify-macos-arm64-dmg.mjs', + 'scripts/verify-macos-autoupdate.mjs', + 'scripts/desktop-update-contract.mjs', 'scripts/verify-packaged-app.mjs', 'scripts/verify-windows-x64.mjs', 'scripts/windows-upgrade-baseline.json', diff --git a/scripts/desktop-update-contract.mjs b/scripts/desktop-update-contract.mjs new file mode 100644 index 0000000000..35b5004732 --- /dev/null +++ b/scripts/desktop-update-contract.mjs @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { parseProductReleaseVersion } from './release-version.mjs'; + +export const DESKTOP_UPDATE_PROVIDER = Object.freeze({ + provider: 'github', + owner: 'apache', + repo: 'maka', + updaterCacheDirName: '@makadesktop-updater', +}); + +/** A stable successor lets stable, alpha, and beta candidates use one feed contract. */ +export function bumpedAutoupdateVersion(candidateVersion) { + const { core, prerelease } = parseProductReleaseVersion(candidateVersion); + const [major, minor, patch] = core; + return prerelease.length > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}.${patch + 1n}`; +} + +async function readYaml(path, read = readFile) { + const [source, { parse }] = await Promise.all([read(path, 'utf8'), import('yaml')]); + return parse(source); +} + +function requireExactObject(actual, expected, subject) { + const exact = + actual && + typeof actual === 'object' && + !Array.isArray(actual) && + Object.keys(actual).length === Object.keys(expected).length && + Object.entries(expected).every(([key, value]) => actual[key] === value); + if (!exact) { + throw new Error( + `${subject} is ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, + ); + } +} + +/** Proves that a packaged client points at the one production release authority. */ +export async function assertPackagedUpdateConfiguration(resourcesPath, { read = readFile } = {}) { + const path = join(resourcesPath, 'app-update.yml'); + let configuration; + try { + configuration = await readYaml(path, read); + } catch (error) { + throw new Error(`Packaged update configuration is unreadable: ${path}`, { cause: error }); + } + requireExactObject(configuration, DESKTOP_UPDATE_PROVIDER, 'Packaged update configuration'); + return configuration; +} + +/** + * Validates the update metadata against the bytes that will be published. + * The release is single-platform and single-architecture, so accepting extra + * payloads here would create an unverified update path. + */ +export async function verifyDesktopUpdateArtifacts({ + directory, + metadataName, + version, + artifactName, +}) { + const metadataPath = join(directory, metadataName); + let metadata; + try { + metadata = await readYaml(metadataPath); + } catch (error) { + throw new Error(`Desktop update metadata is unreadable: ${metadataPath}`, { cause: error }); + } + if (metadata?.version !== version) { + throw new Error( + `${metadataName} advertises version ${JSON.stringify(metadata?.version)}, expected ${version}`, + ); + } + if (metadata.path !== artifactName || metadata.files?.length !== 1) { + throw new Error(`${metadataName} must advertise only ${artifactName}`); + } + const file = metadata.files[0]; + if (file?.url !== artifactName || file.sha512 !== metadata.sha512) { + throw new Error(`${metadataName} has inconsistent payload identity for ${artifactName}`); + } + const artifactPath = join(directory, artifactName); + const artifact = await stat(artifactPath); + if (!artifact.isFile()) throw new Error(`Desktop update payload is not a file: ${artifactPath}`); + const sha512 = await new Promise((resolvePromise, reject) => { + const hash = createHash('sha512'); + const stream = createReadStream(artifactPath); + stream.once('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.once('end', () => resolvePromise(hash.digest('base64'))); + }); + if (metadata.sha512 !== sha512 || file.sha512 !== sha512) { + throw new Error(`${metadataName} sha512 does not match ${artifactName}`); + } + if (file.size !== artifact.size) { + throw new Error( + `${metadataName} records ${artifactName} size ${JSON.stringify(file.size)}, expected ${artifact.size}`, + ); + } + const blockmapPath = join(directory, `${artifactName}.blockmap`); + if (!(await stat(blockmapPath)).isFile()) { + throw new Error(`Desktop update blockmap is not a file: ${blockmapPath}`); + } + return { artifactName, metadata, metadataName, version }; +} + +/** + * Exact loopback replica of the generic feed used by the packaged E2E tests. + * Mapped-but-absent files intentionally return 404: that is how the updater + * probes an unavailable previous blockmap before falling back to a full file. + */ +export async function startDesktopUpdateFeed(files) { + const requests = []; + let unexpectedRequests = 0; + const bodies = new Map(); + for (const [name, filePath] of files) { + try { + bodies.set(`/${name}`, await readFile(filePath)); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + } + } + const knownPaths = new Set([...files.keys()].map((name) => `/${name}`)); + const server = createServer((request, response) => { + const method = request.method ?? 'GET'; + const target = request.url ?? '/'; + const queryIndex = target.indexOf('?'); + const path = queryIndex === -1 ? target : target.slice(0, queryIndex); + const record = { method, path, target, status: 0 }; + requests.push(record); + if ((method !== 'GET' && method !== 'HEAD') || !knownPaths.has(path)) { + unexpectedRequests += 1; + record.status = 404; + response.writeHead(404).end(); + return; + } + const body = bodies.get(path); + if (body === undefined) { + record.status = 404; + response.writeHead(404).end(); + return; + } + const range = /^bytes=(\d+)-(\d*)$/u.exec(request.headers.range ?? ''); + if (range) { + const start = Number(range[1]); + const end = range[2] === '' ? body.length - 1 : Math.min(Number(range[2]), body.length - 1); + if (start > end || start >= body.length) { + record.status = 416; + response.writeHead(416, { 'Content-Range': `bytes */${body.length}` }).end(); + return; + } + record.status = 206; + response.writeHead(206, { + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${body.length}`, + 'Content-Type': 'application/octet-stream', + }); + response.end(method === 'HEAD' ? undefined : body.subarray(start, end + 1)); + return; + } + record.status = 200; + response.writeHead(200, { + 'Accept-Ranges': 'bytes', + 'Content-Length': body.length, + 'Content-Type': 'application/octet-stream', + }); + response.end(method === 'HEAD' ? undefined : body); + }); + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolvePromise); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Could not start the loopback update feed.'); + } + return { + url: `http://127.0.0.1:${address.port}`, + requests, + unexpectedCount: () => unexpectedRequests, + close: () => + new Promise((resolvePromise) => { + server.close(() => resolvePromise()); + server.closeAllConnections?.(); + }), + }; +} + +export function feedServed(feed, name) { + return feed.requests.some( + (request) => + request.method === 'GET' && + request.path === `/${name}` && + (request.status === 200 || request.status === 206), + ); +} diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs index d406b3dba0..82cf73ee56 100644 --- a/scripts/generate-third-party-notices.mjs +++ b/scripts/generate-third-party-notices.mjs @@ -104,7 +104,10 @@ const LICENSE_METADATA_OVERRIDES = new Map([ ]); // The published tarball omits the repository LICENSE; package.json declares Apache-2.0. // Keyed by exact version so a bump re-checks the license rather than inheriting this. -const APACHE_TEXT_OVERRIDE_KEYS = new Set(['@ai-sdk/provider-utils@5.0.28']); +const APACHE_TEXT_OVERRIDE_KEYS = new Set([ + '@ai-sdk/provider-utils@5.0.28', + '@sigstore/verify@4.1.2', +]); const EMBEDDED_COMPONENT_LICENSES = new Map([ [ '@ai-sdk/code-mode', diff --git a/scripts/package-macos-autoupdate-next.mjs b/scripts/package-macos-autoupdate-next.mjs new file mode 100644 index 0000000000..169734599c --- /dev/null +++ b/scripts/package-macos-autoupdate-next.mjs @@ -0,0 +1,94 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { access, readFile, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + bumpedAutoupdateVersion, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; +import { runCommand } from './verify-packaged-app.mjs'; + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const desktopRoot = join(repoRoot, 'apps', 'desktop'); + +/** Builds only the signed ZIP needed to prove the candidate's next update. */ +export async function packageMacosAutoupdateNext({ + platform = process.platform, + arch = process.arch, + run = runCommand, + env = process.env, +} = {}) { + if (platform !== 'darwin' || arch !== 'arm64') { + throw new Error('The macOS auto-update build requires an Apple Silicon macOS host.'); + } + const manifest = JSON.parse(await readFile(join(desktopRoot, 'package.json'), 'utf8')); + const nextVersion = bumpedAutoupdateVersion(manifest.version); + const outputDirectory = join(desktopRoot, 'release-autoupdate-next'); + const zipName = `Maka-${nextVersion}-mac-arm64.zip`; + + await access(join(desktopRoot, 'dist')); + await access(join(desktopRoot, 'release', `Maka-${manifest.version}-mac-arm64.zip`)); + await rm(outputDirectory, { recursive: true, force: true }); + + const args = [ + '--workspace', + '@maka/desktop', + 'exec', + '--', + 'electron-builder', + '--config', + 'electron-builder.config.mjs', + '--mac', + 'zip', + '--arm64', + '--publish', + 'never', + `-c.extraMetadata.version=${nextVersion}`, + '-c.extraMetadata.makaUpdateTestProfile=true', + '-c.mac.notarize=false', + '-c.directories.output=release-autoupdate-next', + ]; + if (env.MAKA_LOCAL_UPDATE_SIGNING_IDENTITY) { + args.push(`-c.mac.identity=${env.MAKA_LOCAL_UPDATE_SIGNING_IDENTITY}`); + args.push('-c.mac.timestamp=none'); + args.push('-c.mac.hardenedRuntime=false'); + } + await run('npm', args, { cwd: repoRoot, env }); + + await verifyDesktopUpdateArtifacts({ + directory: outputDirectory, + metadataName: 'latest-mac.yml', + version: nextVersion, + artifactName: zipName, + }); + await rm(join(outputDirectory, 'mac-arm64'), { recursive: true, force: true }); + return { + version: nextVersion, + zipPath: join(outputDirectory, zipName), + metadataPath: join(outputDirectory, 'latest-mac.yml'), + blockmapPath: join(outputDirectory, `${zipName}.blockmap`), + directory: outputDirectory, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + console.log(JSON.stringify(await packageMacosAutoupdateNext())); +} diff --git a/scripts/package-windows-autoupdate-next.mjs b/scripts/package-windows-autoupdate-next.mjs index e23461a557..c48b783ab6 100644 --- a/scripts/package-windows-autoupdate-next.mjs +++ b/scripts/package-windows-autoupdate-next.mjs @@ -17,12 +17,14 @@ * under the License. */ -import { createHash } from 'node:crypto'; import { access, readFile, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { runCommand } from './package-windows-x64.mjs'; -import { parseProductReleaseVersion } from './release-version.mjs'; +import { + bumpedAutoupdateVersion, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const desktopRoot = join(repoRoot, 'apps', 'desktop'); @@ -33,22 +35,6 @@ const desktopRoot = join(repoRoot, 'apps', 'desktop'); * channel behavior: a prerelease advances to its stable core, while a stable * candidate advances to the next patch. */ -export function bumpedAutoupdateVersion(candidateVersion) { - const { core, prerelease } = parseProductReleaseVersion(candidateVersion); - const [major, minor, patch] = core; - return prerelease.length > 0 ? `${major}.${minor}.${patch}` : `${major}.${minor}.${patch + 1n}`; -} - -/** - * Minimal parser for the handful of flat keys the assertions need. electron- - * builder writes plain `key: value` lines for these; a format drift makes the - * assertions below fail loudly rather than letting a malformed feed pass. - */ -function readFlatYamlValue(source, key) { - const match = new RegExp(`^${key}:\\s*(.+)\\s*$`, 'm').exec(source); - return match ? match[1].trim() : undefined; -} - /** * Build the version-bumped NSIS installer the auto-update harness serves as * the "next" release. @@ -104,26 +90,12 @@ export async function packageWindowsAutoupdateNext({ // Assert the properties the harness depends on, not just process exit 0. await access(exePath); await access(blockmapPath); - const latestYml = await readFile(latestYmlPath, 'utf8'); - const feedVersion = readFlatYamlValue(latestYml, 'version'); - if (feedVersion !== nextVersion) { - throw new Error( - `latest.yml advertises version ${JSON.stringify(feedVersion)}, expected ${nextVersion}`, - ); - } - const feedPath = readFlatYamlValue(latestYml, 'path'); - if (feedPath !== exeName) { - throw new Error(`latest.yml path points at ${JSON.stringify(feedPath)}, expected ${exeName}`); - } - const feedSha512 = readFlatYamlValue(latestYml, 'sha512'); - const actualSha512 = createHash('sha512') - .update(await readFile(exePath)) - .digest('base64'); - if (feedSha512 !== actualSha512) { - throw new Error( - 'latest.yml sha512 does not match the built installer; the feed would fail integrity checks', - ); - } + await verifyDesktopUpdateArtifacts({ + directory: outputDirectory, + metadataName: 'latest.yml', + version: nextVersion, + artifactName: exeName, + }); return { version: nextVersion, exePath, latestYmlPath, blockmapPath, directory: outputDirectory }; } diff --git a/scripts/product-release-artifacts.mjs b/scripts/product-release-artifacts.mjs index e8e66d6967..5f2e645d2d 100644 --- a/scripts/product-release-artifacts.mjs +++ b/scripts/product-release-artifacts.mjs @@ -17,14 +17,40 @@ * under the License. */ -import { copyFile, mkdir, readdir } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { copyFile, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { readProductReleaseIdentity } from './product-release-identity.mjs'; +import { verifyDesktopUpdateArtifacts } from './desktop-update-contract.mjs'; +import { + parseAsfSourceReferenceTag, + readProductReleaseIdentity, +} from './product-release-identity.mjs'; +import { parseProductTag } from './product-release-tag.mjs'; + +const PRODUCT_RELEASE_WORKFLOW = '.github/workflows/release.yml'; +const PUBLICATION_RECORD_KEYS = [ + 'schemaVersion', + 'repository', + 'workflow', + 'runId', + 'runAttempt', + 'sourceReferenceTag', + 'sourceCommit', + 'tag', + 'version', + 'prerelease', + 'assets', +]; + +export function compareProductReleaseNames(left, right) { + return left < right ? -1 : left > right ? 1 : 0; +} export function assertExactArtifactSet(actualNames, expectedNames) { - const actual = [...new Set(actualNames)].sort(); - const expected = [...new Set(expectedNames)].sort(); + const actual = [...new Set(actualNames)].sort(compareProductReleaseNames); + const expected = [...new Set(expectedNames)].sort(compareProductReleaseNames); const actualSet = new Set(actual); const expectedSet = new Set(expected); const missing = expected.filter((name) => !actualSet.has(name)); @@ -71,14 +97,203 @@ export async function verifyProductReleaseArtifactDirectory(directory, expectedN return assertExactArtifactSet(await regularFileNames(directory), expectedNames); } +function digestFile(path, algorithm = 'sha256') { + return new Promise((resolvePromise, reject) => { + const hash = createHash(algorithm); + const stream = createReadStream(path); + stream.once('error', reject); + stream.on('data', (chunk) => hash.update(chunk)); + stream.once('end', () => resolvePromise(hash.digest('hex'))); + }); +} + +async function artifactRecords(directory, names) { + return Promise.all( + [...names].sort(compareProductReleaseNames).map(async (name) => { + const path = join(directory, name); + const [details, digest] = await Promise.all([stat(path), digestFile(path)]); + if (!details.isFile()) { + throw new Error(`Product release artifact must be a regular file: ${name}`); + } + return { name, size: details.size, digest: `sha256:${digest}` }; + }), + ); +} + +export async function verifyProductReleaseArtifactIntegrity(directory, identity) { + await verifyProductReleaseArtifactDirectory(directory, allArtifactNames(identity)); + const checksumNames = allArtifactNames(identity).filter((name) => name.endsWith('.sha256')); + for (const checksumName of checksumNames) { + const artifactName = checksumName.slice(0, -'.sha256'.length); + const source = await readFile(join(directory, checksumName), 'utf8'); + const match = /^([0-9a-f]{64}) {2}([^\r\n]+)\r?\n?$/u.exec(source); + if (!match || match[2] !== artifactName) { + throw new Error(`Product release checksum is malformed: ${checksumName}`); + } + const digest = await digestFile(join(directory, artifactName)); + if (digest !== match[1]) { + throw new Error(`Product release checksum does not match: ${artifactName}`); + } + } + await Promise.all([ + verifyDesktopUpdateArtifacts({ + directory, + metadataName: 'latest-mac.yml', + version: identity.version, + artifactName: `Maka-${identity.version}-mac-arm64.zip`, + }), + verifyDesktopUpdateArtifacts({ + directory, + metadataName: 'latest.yml', + version: identity.version, + artifactName: identity.exe, + }), + ]); + return allArtifactNames(identity); +} + function allArtifactNames(identity) { return Object.values(identity.artifacts).flat(); } +function exactKeys(value, expected, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const actual = Object.keys(value).sort(); + const keys = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(keys)) { + throw new Error(`${label} fields are invalid`); + } +} + +function assertRunIdentity(runId, runAttempt) { + if ( + typeof runId !== 'string' || + typeof runAttempt !== 'string' || + !/^[1-9]\d*$/u.test(runId) || + !/^[1-9]\d*$/u.test(runAttempt) + ) { + throw new Error('Product release run ID and attempt must be positive integers'); + } +} + +function assertRepository(repository) { + if (typeof repository !== 'string' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository)) { + throw new Error(`Product repository must be an exact owner/name; found ${repository}`); + } +} + +export function assertProductReleasePublicationRecord(record, expected = {}) { + exactKeys(record, PUBLICATION_RECORD_KEYS, 'Product release publication record'); + if (record.schemaVersion !== 1) { + throw new Error('Unsupported product release publication record'); + } + assertRepository(record.repository); + if (record.workflow !== PRODUCT_RELEASE_WORKFLOW) { + throw new Error(`Product release workflow must be ${PRODUCT_RELEASE_WORKFLOW}`); + } + assertRunIdentity(record.runId, record.runAttempt); + if (!/^[0-9a-f]{40}$/u.test(record.sourceCommit)) { + throw new Error('Product release source commit must be an exact SHA'); + } + const product = parseProductTag(record.tag); + const source = parseAsfSourceReferenceTag(record.sourceReferenceTag); + if ( + record.version !== product.version || + source.version !== product.version || + record.prerelease !== product.prerelease.length > 0 + ) { + throw new Error('Product release publication identity is inconsistent'); + } + for (const [key, value] of Object.entries(expected)) { + if (value !== undefined && record[key] !== value) { + throw new Error(`Product release publication record ${key} does not match`); + } + } + if (!Array.isArray(record.assets) || record.assets.length === 0) { + throw new Error('Product release publication record must contain assets'); + } + const names = new Set(); + for (const asset of record.assets) { + exactKeys(asset, ['name', 'size', 'digest'], 'Product release publication asset'); + if ( + typeof asset.name !== 'string' || + asset.name.length === 0 || + asset.name.includes('/') || + asset.name.includes('\\') || + names.has(asset.name) || + !Number.isSafeInteger(asset.size) || + asset.size < 0 || + !/^sha256:[0-9a-f]{64}$/u.test(asset.digest) + ) { + throw new Error('Product release publication asset is invalid'); + } + names.add(asset.name); + } + if ( + JSON.stringify(record.assets.map(({ name }) => name)) !== + JSON.stringify([...names].sort(compareProductReleaseNames)) + ) { + throw new Error('Product release publication assets must be sorted'); + } + return record; +} + +export async function createProductReleasePublicationRecord({ + artifactDirectory, + identity, + repository, + runId, + runAttempt, +}) { + assertRepository(repository); + assertRunIdentity(runId, runAttempt); + await verifyProductReleaseArtifactIntegrity(artifactDirectory, identity); + return assertProductReleasePublicationRecord({ + schemaVersion: 1, + repository, + workflow: PRODUCT_RELEASE_WORKFLOW, + runId, + runAttempt, + sourceReferenceTag: identity.sourceReferenceTag, + sourceCommit: identity.sourceCommit, + tag: identity.tag, + version: identity.version, + prerelease: identity.isPrerelease, + assets: await artifactRecords(artifactDirectory, allArtifactNames(identity)), + }); +} + +export async function verifyProductReleasePublicationRecord({ + artifactDirectory, + record, + expected, +}) { + assertProductReleasePublicationRecord(record, expected); + const names = record.assets.map(({ name }) => name); + await verifyProductReleaseArtifactDirectory(artifactDirectory, names); + const actual = await artifactRecords(artifactDirectory, names); + if (JSON.stringify(actual) !== JSON.stringify(record.assets)) { + throw new Error('Product release artifacts do not match the immutable publication record'); + } + return record; +} + +export async function readProductReleasePublicationRecord(path, expected) { + let record; + try { + record = JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + throw new Error('Product release publication record is not valid JSON', { cause: error }); + } + return assertProductReleasePublicationRecord(record, expected); +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [command, ...args] = process.argv.slice(2); - const identity = await readProductReleaseIdentity(); if (command === 'stage') { + const identity = await readProductReleaseIdentity(); const [group, sourceDirectory, targetDirectory] = args; const expectedNames = identity.artifacts[group]; if (!expectedNames || !sourceDirectory || !targetDirectory) { @@ -89,15 +304,64 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await stageProductReleaseArtifactGroup({ sourceDirectory, targetDirectory, expectedNames }); console.log(`Staged exact ${group} product artifacts in ${targetDirectory}`); } else if (command === 'verify') { + const identity = await readProductReleaseIdentity(); const [directory] = args; if (!directory) { throw new Error('usage: product-release-artifacts.mjs verify '); } - await verifyProductReleaseArtifactDirectory(directory, allArtifactNames(identity)); - console.log(`Verified exact product release artifacts in ${directory}`); + await verifyProductReleaseArtifactIntegrity(directory, identity); + console.log(`Verified exact product release artifact bytes in ${directory}`); + } else if (command === 'record') { + const identity = await readProductReleaseIdentity(); + const [directory, recordPath, repository, runId, runAttempt] = args; + if (!directory || !recordPath || !repository || !runId || !runAttempt) { + throw new Error( + 'usage: product-release-artifacts.mjs record ', + ); + } + const record = await createProductReleasePublicationRecord({ + artifactDirectory: directory, + identity, + repository, + runId, + runAttempt, + }); + await writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + mode: 0o644, + }); + console.log(`Recorded immutable product release evidence in ${recordPath}`); + } else if (command === 'inspect-record') { + const [recordPath, repository, tag, sourceCommit, sourceReferenceTag, runId, runAttempt] = args; + if ( + !recordPath || + !repository || + !tag || + !sourceCommit || + !sourceReferenceTag || + !runId || + !runAttempt + ) { + throw new Error( + 'usage: product-release-artifacts.mjs inspect-record ', + ); + } + await readProductReleasePublicationRecord(recordPath, { + repository, + tag, + sourceCommit, + sourceReferenceTag, + runId, + runAttempt, + }); + console.log(`Verified immutable product release evidence for ${tag}`); } else if (command === 'list' && args.length === 0) { + const identity = await readProductReleaseIdentity(); console.log(JSON.stringify(identity.artifacts, null, 2)); } else { - throw new Error('usage: product-release-artifacts.mjs ...'); + throw new Error( + 'usage: product-release-artifacts.mjs ...', + ); } } diff --git a/scripts/product-release-artifacts.test.mjs b/scripts/product-release-artifacts.test.mjs deleted file mode 100644 index 7a2f9877db..0000000000 --- a/scripts/product-release-artifacts.test.mjs +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import test from 'node:test'; -import { - assertExactArtifactSet, - stageProductReleaseArtifactGroup, -} from './product-release-artifacts.mjs'; - -test('product release artifact validation rejects missing and unexpected files', () => { - assert.deepEqual(assertExactArtifactSet(['b.zip', 'a.dmg'], ['a.dmg', 'b.zip']), [ - 'a.dmg', - 'b.zip', - ]); - assert.throws(() => assertExactArtifactSet(['a.dmg'], ['a.dmg', 'b.zip']), /missing b\.zip/u); - assert.throws( - () => assertExactArtifactSet(['a.dmg', 'debug.log'], ['a.dmg']), - /unexpected debug\.log/u, - ); -}); - -test('artifact staging publishes exactly one manifest group', async (t) => { - const root = await mkdtemp(join(tmpdir(), 'maka-release-artifacts-')); - t.after(() => rm(root, { recursive: true, force: true })); - const sourceDirectory = join(root, 'source'); - const targetDirectory = join(root, 'target'); - await mkdir(sourceDirectory); - await Promise.all([ - writeFile(join(sourceDirectory, 'a.dmg'), 'dmg'), - writeFile(join(sourceDirectory, 'a.dmg.sha256'), 'checksum'), - ]); - - await stageProductReleaseArtifactGroup({ - sourceDirectory, - targetDirectory, - expectedNames: ['a.dmg', 'a.dmg.sha256'], - }); - - assert.equal(await readFile(join(targetDirectory, 'a.dmg'), 'utf8'), 'dmg'); - assert.equal(await readFile(join(targetDirectory, 'a.dmg.sha256'), 'utf8'), 'checksum'); -}); - -test('artifact staging refuses to replace an existing target directory', async (t) => { - const root = await mkdtemp(join(tmpdir(), 'maka-release-artifacts-existing-')); - t.after(() => rm(root, { recursive: true, force: true })); - const sourceDirectory = join(root, 'source'); - const targetDirectory = join(root, 'target'); - await Promise.all([mkdir(sourceDirectory), mkdir(targetDirectory)]); - await Promise.all([ - writeFile(join(sourceDirectory, 'a.dmg'), 'dmg'), - writeFile(join(targetDirectory, 'keep.txt'), 'keep'), - ]); - - await assert.rejects( - stageProductReleaseArtifactGroup({ - sourceDirectory, - targetDirectory, - expectedNames: ['a.dmg'], - }), - /target directory must be empty/u, - ); - assert.equal(await readFile(join(targetDirectory, 'keep.txt'), 'utf8'), 'keep'); -}); diff --git a/scripts/product-release-authority.mjs b/scripts/product-release-authority.mjs index eff55e0687..52b2dde783 100644 --- a/scripts/product-release-authority.mjs +++ b/scripts/product-release-authority.mjs @@ -18,31 +18,128 @@ */ import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { createReadStream } from 'node:fs'; +import { readFile, stat } from 'node:fs/promises'; +import { basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; +import { + compareProductReleaseNames, + readProductReleasePublicationRecord, + verifyProductReleasePublicationRecord, +} from './product-release-artifacts.mjs'; +import { parseAsfSourceReferenceTag } from './product-release-identity.mjs'; import { parseProductTag, remoteProductTagCommit } from './product-release-tag.mjs'; const execFileAsync = promisify(execFile); function expectedReleaseIdentity(tag) { - const { prerelease } = parseProductTag(tag); - return { tag, isPrerelease: prerelease.length > 0 }; + const { prerelease, version } = parseProductTag(tag); + return { + tag, + version, + isPrerelease: prerelease.length > 0, + attestationName: `Maka-${version}-attestation.sigstore.json`, + }; } export function assertDraftProductRelease(release, tag) { const expected = expectedReleaseIdentity(tag); - if (!release || release.tagName !== expected.tag) { + if ( + !release || + !Number.isSafeInteger(release.id) || + release.id < 1 || + release.tag !== expected.tag + ) { throw new Error(`GitHub Release does not identify product tag ${tag}`); } - if (release.isDraft !== true) { + if (release.draft !== true) { throw new Error(`GitHub Release ${tag} must remain a Draft`); } - if (release.isPrerelease !== expected.isPrerelease) { + if (release.prerelease !== expected.isPrerelease) { throw new Error(`GitHub Release ${tag} prerelease state must be ${expected.isPrerelease}`); } return release; } +export function assertPublishedProductRelease(release, tag, releaseId, expectedAssets) { + const expected = 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 (JSON.stringify(release.assets) !== JSON.stringify(expectedAssets)) { + throw new Error(`GitHub Release ${tag} assets changed during publication`); + } + return release; +} + +function releaseSnapshotFromGhView(value) { + return { + id: value?.databaseId, + tag: value?.tagName, + draft: value?.isDraft, + prerelease: value?.isPrerelease, + assets: (value?.assets ?? []) + .map(({ name, size, digest }) => ({ name, size, digest })) + .sort((left, right) => compareProductReleaseNames(left.name, right.name)), + }; +} + +function releaseSnapshotFromRest(value) { + return { + id: value?.id, + tag: value?.tag_name, + draft: value?.draft, + prerelease: value?.prerelease, + assets: (value?.assets ?? []) + .map(({ name, size, digest }) => ({ name, size, digest })) + .sort((left, right) => compareProductReleaseNames(left.name, right.name)), + }; +} + +async function localAssetRecord(path) { + const details = await stat(path); + if (!details.isFile() || details.size === 0) { + throw new Error('Product release attestation bundle must be a non-empty regular file'); + } + const hash = createHash('sha256'); + for await (const chunk of createReadStream(path)) hash.update(chunk); + return { name: basename(path), size: details.size, digest: `sha256:${hash.digest('hex')}` }; +} + +export function assertProductReleaseWorkflowRun({ + run, + tag, + sourceCommit, + repository, + runId, + runAttempt, +}) { + if (!/^[1-9]\d*$/u.test(String(runId)) || !/^[1-9]\d*$/u.test(String(runAttempt))) { + throw new Error('Release workflow run ID and attempt must be positive integers'); + } + const product = parseProductTag(tag); + const source = parseAsfSourceReferenceTag(run?.head_branch); + const exact = + String(run?.id) === String(runId) && + String(run?.run_attempt) === String(runAttempt) && + run?.path === '.github/workflows/release.yml' && + run?.event === 'workflow_dispatch' && + run?.status === 'completed' && + run?.conclusion === 'success' && + run?.head_sha === sourceCommit && + run?.head_repository?.full_name === repository && + source.version === product.version; + if (!exact) { + throw new Error('Release workflow run does not match the approved product source'); + } + return run; +} + export async function verifyDraftProductRelease({ tag, sourceCommit, @@ -73,7 +170,15 @@ export async function verifyDraftProductRelease({ const release = await run( 'gh', - ['release', 'view', tag, '--repo', repository, '--json', 'tagName,isDraft,isPrerelease'], + [ + 'release', + 'view', + tag, + '--repo', + repository, + '--json', + 'databaseId,tagName,isDraft,isPrerelease,assets', + ], { cwd }, ); let parsedRelease; @@ -82,17 +187,222 @@ export async function verifyDraftProductRelease({ } catch (error) { throw new Error(`GitHub returned an invalid Release record for ${tag}`, { cause: error }); } - return assertDraftProductRelease(parsedRelease, tag); + return assertDraftProductRelease(releaseSnapshotFromGhView(parsedRelease), tag); +} + +export async function publishDraftProductRelease({ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + attestationBundlePath, + cwd = process.cwd(), + run = execFileAsync, + pause = (milliseconds) => + new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)), +}) { + const releaseIdentity = expectedReleaseIdentity(tag); + const attestation = await localAssetRecord(attestationBundlePath); + if (attestation.name !== releaseIdentity.attestationName) { + throw new Error(`Product release attestation must be named ${releaseIdentity.attestationName}`); + } + const { draft, evidence } = await verifyDraftProductReleasePublication({ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + cwd, + run, + }); + + await run( + 'gh', + ['release', 'upload', tag, attestationBundlePath, '--repo', repository, '--clobber'], + { cwd }, + ); + const attestedDraft = await verifyDraftProductRelease({ + tag, + sourceCommit, + repository, + cwd, + run, + }); + const expectedAssets = [...evidence.assets, attestation].sort((left, right) => + compareProductReleaseNames(left.name, right.name), + ); + if (JSON.stringify(attestedDraft.assets) !== JSON.stringify(expectedAssets)) { + throw new Error('Draft GitHub Release does not contain the exact attestation bundle'); + } + + const isPrerelease = releaseIdentity.isPrerelease; + const published = await run( + 'gh', + [ + 'api', + '--header', + 'X-GitHub-Api-Version: 2026-03-10', + '--method', + 'PATCH', + `repos/${repository}/releases/${draft.id}`, + '-F', + 'draft=false', + '-F', + `prerelease=${isPrerelease}`, + '-f', + `make_latest=${isPrerelease ? 'false' : 'true'}`, + ], + { cwd }, + ); + let record; + try { + record = releaseSnapshotFromRest(JSON.parse(published.stdout)); + } catch (error) { + throw new Error(`GitHub returned an invalid publication result for ${tag}`, { cause: error }); + } + 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 }); + } + } + 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; +} + +export async function verifyDraftProductReleasePublication({ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + cwd = process.cwd(), + run = execFileAsync, +}) { + const releaseIdentity = expectedReleaseIdentity(tag); + const evidence = await readProductReleasePublicationRecord(publicationRecordPath, { + repository, + tag, + sourceCommit, + sourceReferenceTag, + runId: releaseRunId, + runAttempt: releaseRunAttempt, + }); + await verifyProductReleasePublicationRecord({ + artifactDirectory, + record: evidence, + expected: { + repository, + tag, + sourceCommit, + sourceReferenceTag, + runId: releaseRunId, + runAttempt: releaseRunAttempt, + }, + }); + const draft = await verifyDraftProductRelease({ tag, sourceCommit, repository, cwd, run }); + const remoteAssets = (draft.assets ?? []) + .map(({ name, size, digest }) => ({ name, size, digest })) + .sort((left, right) => compareProductReleaseNames(left.name, right.name)); + const allowedDraftAssets = remoteAssets.filter( + ({ name }) => name !== releaseIdentity.attestationName, + ); + const unexpectedAttestations = remoteAssets.filter( + ({ name }) => + name.endsWith('-attestation.sigstore.json') && name !== releaseIdentity.attestationName, + ); + if ( + unexpectedAttestations.length > 0 || + JSON.stringify(allowedDraftAssets) !== JSON.stringify(evidence.assets) + ) { + throw new Error('Draft GitHub Release assets do not match the verified Release run artifacts'); + } + return { draft, evidence }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const [command, tag, sourceCommit, repository = process.env.GITHUB_REPOSITORY] = - process.argv.slice(2); - if (command !== 'verify-draft' || !tag || !sourceCommit || !repository) { - throw new Error( - 'usage: product-release-authority.mjs verify-draft ', - ); - } - await verifyDraftProductRelease({ tag, sourceCommit, repository }); - console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); + const [command, ...args] = process.argv.slice(2); + const usage = + 'usage: product-release-authority.mjs verify-build-run | verify-draft | verify-publication | publish-draft '; + if (command === 'verify-build-run' && args.length === 6) { + const [runPath, tag, sourceCommit, repository, runId, runAttempt] = args; + const run = JSON.parse(await readFile(runPath, 'utf8')); + assertProductReleaseWorkflowRun({ run, tag, sourceCommit, repository, runId, runAttempt }); + console.log(`Verified Release workflow run ${runId}/${runAttempt} for ${tag}`); + } else if (command === 'verify-draft' && args.length === 3) { + const [tag, sourceCommit, repository] = args; + await verifyDraftProductRelease({ tag, sourceCommit, repository }); + console.log(`Verified Draft product Release ${tag} at ${sourceCommit}`); + } else if (command === 'verify-publication' && args.length === 8) { + const [ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + ] = args; + await verifyDraftProductReleasePublication({ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + }); + console.log(`Verified exact publication input for ${tag}`); + } else if (command === 'publish-draft' && args.length === 9) { + const [ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + attestationBundlePath, + ] = args; + await publishDraftProductRelease({ + tag, + sourceCommit, + repository, + artifactDirectory, + publicationRecordPath, + sourceReferenceTag, + releaseRunId, + releaseRunAttempt, + attestationBundlePath, + }); + console.log(`Published product Release ${tag} from ${sourceCommit}`); + } else { + throw new Error(usage); + } } diff --git a/scripts/product-release-authority.test.mjs b/scripts/product-release-authority.test.mjs index 0fb14cf3c5..f8bf6d7070 100644 --- a/scripts/product-release-authority.test.mjs +++ b/scripts/product-release-authority.test.mjs @@ -18,92 +18,157 @@ */ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; -import { - assertDraftProductRelease, - verifyDraftProductRelease, -} from './product-release-authority.mjs'; +import { publishDraftProductRelease } from './product-release-authority.mjs'; +import { createProductReleasePublicationRecord } from './product-release-artifacts.mjs'; -test('Draft state and prerelease classification are one product Release contract', () => { - for (const [tag, isPrerelease] of [ - ['v1.2.3', false], - ['v1.2.3-beta.1', true], - ]) { - assert.equal( - assertDraftProductRelease({ tagName: tag, isDraft: true, isPrerelease }, tag).tagName, - tag, - ); - } - assert.throws( - () => - assertDraftProductRelease( - { tagName: 'v1.2.3', isDraft: false, isPrerelease: false }, - 'v1.2.3', - ), - /must remain a Draft/u, - ); - assert.throws( - () => - assertDraftProductRelease( - { tagName: 'v1.2.3-beta.1', isDraft: true, isPrerelease: false }, - 'v1.2.3-beta.1', - ), - /prerelease state must be true/u, - ); -}); +function updateMetadata(version, artifactName, bytes) { + const sha512 = createHash('sha512').update(bytes).digest('base64'); + return [ + `version: ${version}`, + 'files:', + ` - url: ${artifactName}`, + ` sha512: ${sha512}`, + ` size: ${bytes.length}`, + `path: ${artifactName}`, + `sha512: ${sha512}`, + '', + ].join('\n'); +} -test('the live authority verifier binds the tag, main ancestry, and Draft Release', async () => { +test('publication verifies live asset digests before one Stable/Latest mutation', async (t) => { + const directory = await mkdtemp(join(tmpdir(), 'maka-publish-authority-')); + const recordDirectory = await mkdtemp(join(tmpdir(), 'maka-publish-record-')); + t.after(() => rm(directory, { recursive: true, force: true })); + t.after(() => rm(recordDirectory, { recursive: true, force: true })); + const version = '1.2.3'; + const macZip = `Maka-${version}-mac-arm64.zip`; + const exe = `Maka-${version}-win-x64.exe`; + const macBytes = Buffer.from('mac application'); + const windowsBytes = Buffer.from('windows application'); + const names = [ + macZip, + `${macZip}.blockmap`, + 'latest-mac.yml', + exe, + `${exe}.blockmap`, + 'latest.yml', + ]; + await Promise.all([ + writeFile(join(directory, macZip), macBytes), + writeFile(join(directory, `${macZip}.blockmap`), 'mac blockmap'), + writeFile(join(directory, 'latest-mac.yml'), updateMetadata(version, macZip, macBytes)), + writeFile(join(directory, exe), windowsBytes), + writeFile(join(directory, `${exe}.blockmap`), 'windows blockmap'), + writeFile(join(directory, 'latest.yml'), updateMetadata(version, exe, windowsBytes)), + ]); const sourceCommit = 'a'.repeat(40); + const record = await createProductReleasePublicationRecord({ + artifactDirectory: directory, + identity: { + version, + isPrerelease: false, + tag: `v${version}`, + sourceReferenceTag: `v${version}-incubating-rc1`, + sourceCommit, + exe, + artifacts: { test: names }, + }, + repository: 'apache/maka', + runId: '123', + runAttempt: '2', + }); + const attestationContents = Buffer.from('sigstore bundle'); + const attestationBundlePath = join(recordDirectory, 'Maka-1.2.3-attestation.sigstore.json'); + await writeFile(attestationBundlePath, attestationContents); + const attestation = { + name: 'Maka-1.2.3-attestation.sigstore.json', + size: attestationContents.length, + digest: `sha256:${createHash('sha256').update(attestationContents).digest('hex')}`, + }; + const publicationRecordPath = join(recordDirectory, 'product-release.json'); + await writeFile(publicationRecordPath, `${JSON.stringify(record)}\n`); const calls = []; + let latestReads = 0; + let attestationUploaded = false; const run = async (command, args) => { calls.push([command, args]); - if (args[0] === 'ls-remote') { + if (command === 'git' && args[0] === 'ls-remote') { return { stdout: `${sourceCommit}\trefs/tags/v1.2.3\n` }; } - if (command === 'gh') { + if (command === 'gh' && args[0] === 'release' && args[1] === 'upload') { + attestationUploaded = true; + return { stdout: '' }; + } + if (command === 'gh' && args[0] === 'release') { return { stdout: JSON.stringify({ + databaseId: 42, tagName: 'v1.2.3', isDraft: true, isPrerelease: false, + assets: [...record.assets, ...(attestationUploaded ? [attestation] : [])], }), }; } + if (command === 'gh' && args.includes('PATCH')) { + return { + stdout: JSON.stringify({ + id: 42, + tag_name: 'v1.2.3', + draft: false, + prerelease: false, + assets: [...record.assets, attestation], + }), + }; + } + if (command === 'gh' && args.includes('repos/apache/maka/releases/latest')) { + latestReads += 1; + return { stdout: JSON.stringify({ tag_name: latestReads === 1 ? 'v1.2.2' : 'v1.2.3' }) }; + } return { stdout: '' }; }; - await verifyDraftProductRelease({ + const publication = { tag: 'v1.2.3', sourceCommit, repository: 'apache/maka', - run, - }); - - assert.deepEqual( - calls.map(([command, args]) => [command, args[0]]), - [ - ['git', 'ls-remote'], - ['git', 'fetch'], - ['git', 'merge-base'], - ['gh', 'release'], - ], - ); - assert.deepEqual(calls.at(-1)[1].slice(-2), ['--json', 'tagName,isDraft,isPrerelease']); -}); - -test('the live authority verifier rejects product tag drift before later checks', async () => { - const calls = []; + artifactDirectory: directory, + publicationRecordPath, + sourceReferenceTag: 'v1.2.3-incubating-rc1', + releaseRunId: '123', + releaseRunAttempt: '2', + attestationBundlePath, + pause: async () => {}, + }; + await writeFile(join(directory, macZip), 'tampered mac application'); await assert.rejects( - verifyDraftProductRelease({ - tag: 'v1.2.3', - sourceCommit: 'a'.repeat(40), - repository: 'apache/maka', - run: async (command, args) => { - calls.push([command, args]); - return { stdout: `${'b'.repeat(40)}\trefs/tags/v1.2.3\n` }; + publishDraftProductRelease({ + ...publication, + run: async () => { + throw new Error('network must not be reached for invalid local evidence'); }, }), - /points to .* instead of/u, + /do not match the immutable publication record/u, ); - assert.equal(calls.length, 1); + await writeFile(join(directory, macZip), macBytes); + + await publishDraftProductRelease({ + ...publication, + run, + }); + + const patchCall = calls.find(([, args]) => args.includes('PATCH')); + assert.ok(patchCall); + assert.ok(patchCall[1].includes('draft=false')); + assert.ok(patchCall[1].includes('prerelease=false')); + assert.ok(patchCall[1].includes('make_latest=true')); + const uploadCall = calls.find(([, args]) => args[0] === 'release' && args[1] === 'upload'); + assert.ok(uploadCall); + assert.ok(calls.indexOf(uploadCall) < calls.indexOf(patchCall)); + assert.equal(latestReads, 2); }); diff --git a/scripts/product-release-identity.mjs b/scripts/product-release-identity.mjs index b44b9c71f8..3e868d9b2a 100644 --- a/scripts/product-release-identity.mjs +++ b/scripts/product-release-identity.mjs @@ -68,6 +68,10 @@ export function parseAsfSourceReferenceTag(tag) { export function resolveProductManifestIdentity({ rootManifest, desktopManifest, cliManifest }) { const { version, prerelease } = parseProductReleaseVersion(rootManifest.version); + const channel = prerelease[0]; + if (channel !== undefined && channel !== 'alpha' && channel !== 'beta') { + throw new Error('Product prerelease channel must be alpha or beta'); + } for (const [label, manifest] of [ ['Desktop', desktopManifest], ['CLI', cliManifest], diff --git a/scripts/product-release.test.mjs b/scripts/product-release.test.mjs index 4a8bfc4fd6..5252bbca7a 100644 --- a/scripts/product-release.test.mjs +++ b/scripts/product-release.test.mjs @@ -157,6 +157,30 @@ test('the product identity classifies prereleases once for every publication sur assert.equal(identity.tag, `v${version}`); }); +test('product prereleases use only updater-compatible alpha and beta channels', () => { + for (const version of ['1.2.3-alpha.1', '1.2.3-beta.2']) { + assert.equal( + resolveProductManifestIdentity({ + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + }).isPrerelease, + true, + ); + } + for (const version of ['1.2.3-rc.1', '1.2.3-dev.1']) { + assert.throws( + () => + resolveProductManifestIdentity({ + rootManifest: { ...rootManifest, version }, + desktopManifest: { version }, + cliManifest: { version, bin: { maka: './dist/cli.js' } }, + }), + /prerelease channel must be alpha or beta/u, + ); + } +}); + test('Desktop packaging derives the Runtime Host setup package from product manifests', async () => { const manifestIdentity = resolveProductManifestIdentity({ rootManifest, @@ -233,7 +257,7 @@ test('platform package verifiers keep Git checks out of current artifacts', asyn ); assert.match( windowsSource, - /if \(requiresCurrentContract\) await assertPackagedDependencyClosure\(resources\);\s*else await requirePath\(join\(resources, ['"]git['"]/u, + /if \(requiresCurrentContract\) \{\s*await assertPackagedUpdateConfiguration\(resources\);\s*await assertPackagedDependencyClosure\(resources\);\s*\}\s*else await requirePath\(join\(resources, ['"]git['"]/u, ); const macosSource = await readFile( @@ -677,7 +701,6 @@ test('one product workflow gates one draft release on every required artifact', ).run; assert.match(verifyArtifacts, /product-release-artifacts\.mjs verify release-assets/u); assert.doesNotMatch(verifyArtifacts, /required=\(|Maka-\*|latest\*\.yml/u); - const commands = Object.values(jobs) .flatMap((job) => job.steps ?? []) .map((step) => step.run) @@ -722,13 +745,14 @@ test('one product workflow gates one draft release on every required artifact', assert.doesNotMatch(commands, /cli-v|npm (?:stage )?publish/u); }); -test('repository control plane admits only reviewed immutable release tags', async () => { +test('repository control plane admits only each release phase owner ref', async () => { const config = parseYaml(await readFile(new URL('../.asf.yaml', import.meta.url), 'utf8')); assert.deepEqual(config.github.protected_branches.main.required_status_checks.contexts, ['test']); const environments = config.github.environments; - for (const [name, tagPattern] of [ - ['release', 'v*-incubating-rc*'], - ['npm-release', 'v*'], + for (const [name, pattern, type] of [ + ['release', 'v*-incubating-rc*', 'tag'], + ['npm-release', 'v*', 'tag'], + ['product-release', 'main', 'branch'], ]) { assert.deepEqual(environments[name], { required_reviewers: [{ id: 'M4n5ter', type: 'User' }], @@ -736,7 +760,7 @@ test('repository control plane admits only reviewed immutable release tags', asy prevent_self_review: true, deployment_branch_policy: { protected_branches: false, - policies: [{ name: tagPattern, type: 'tag' }], + policies: [{ name: pattern, type }], }, }); } diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index d694d8ed0d..d1ae439f6e 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -515,6 +515,7 @@ async function main() { appendOutputs(output, { product_tag: record.productTag, source_commit: record.source.commit, + version: record.version, }); } return; diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 917a5be3e9..0600065dd1 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -484,6 +484,7 @@ test('validate-stage-run CLI accepts the canonical staged release identity', () assert.deepEqual(readFileSync(output, 'utf8').trim().split('\n'), [ `product_tag=${PRODUCT_TAG}`, `source_commit=${SOURCE_SHA}`, + `version=${fixture.version}`, ]); }); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 1144dc2458..0bd25c8f2a 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -87,7 +87,7 @@ test('stage builds the npm candidate from the exact product release commit', () assert.match(bind, /PRODUCT_TAG: \$\{\{ needs\.authorize\.outputs\.product_tag \}\}/u); }); -test('finalize validates one exact stage attempt before running the current verifier', () => { +test('finalize runs the current verifier from reviewed main against exact build evidence', () => { const workflow = readWorkflow('release-cli-finalize.yml'); const steps = workflowSteps(workflow); assert.match(workflow, /stage_run_attempt:[\s\S]*?required: true/u); @@ -95,8 +95,12 @@ test('finalize validates one exact stage attempt before running the current veri const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); assert.ok(loadIndex >= 0 && checkoutIndex > loadIndex); assert.match(workflow, /actions\/runs\/\$STAGE_RUN_ID\/attempts\/\$STAGE_RUN_ATTEMPT/u); + assert.match(workflow, /release_run_attempt:[\s\S]*?required: true/u); + assert.match(workflow, /actions\/runs\/\$RELEASE_RUN_ID\/attempts\/\$RELEASE_RUN_ATTEMPT/u); const checkout = namedStep(steps, 'Check out the current release verifier'); assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); + const requireMain = namedStep(steps, 'Require main'); + assert.match(requireMain, /refs\/heads\/main/u); }); test('finalize revalidates the live product release before trusting public npm bytes', () => { @@ -106,17 +110,42 @@ test('finalize revalidates the live product release before trusting public npm b assert.match(record, /id: release/u); assert.match(record, /"\$GITHUB_OUTPUT"/u); const authority = namedStep(steps, 'Revalidate the product release authority'); + assert.match(authority, /product-release-authority\.mjs verify-build-run/u); + assert.match(authority, /product-release-artifacts\.mjs inspect-record/u); assert.match(authority, /product-release-authority\.mjs verify-draft/u); assert.ok( workflow.indexOf(authority) < workflow.indexOf('Fetch and verify the public registry bytes'), ); }); -test('finalize preserves verified npm bytes without creating another product release', () => { +test('finalize preserves npm evidence and owns the single product publication boundary', () => { const workflow = readWorkflow('release-cli-finalize.yml'); + const steps = workflowSteps(workflow); assert.match(workflow, /name: Preserve the verified public npm package/u); assert.match(workflow, /path: \$\{\{ runner\.temp \}\}\/registry-release/u); - assert.doesNotMatch(workflow, /cli-v|contents: write/u); + assert.doesNotMatch(workflow, /cli-v/u); + assert.match(workflow, /name: product-release/u); + assert.match(workflow, /contents: write/u); + assert.match(workflow, /id-token: write/u); + assert.match(workflow, /attestations: write/u); + assert.match(workflow, /product-release-authority\.mjs publish-draft/u); + assert.match(workflow, /actions\/attest@[0-9a-f]{40}/u); + const artifacts = namedStep(steps, 'Download the exact verified Release run artifacts'); + assert.match(artifacts, /run-id: \$\{\{ needs\.inspect\.outputs\.release_run_id \}\}/u); + const preflight = namedStep(steps, 'Verify the exact publication input'); + const attest = steps.find((step) => step.includes('uses: actions/attest@')); + const verify = namedStep(steps, 'Verify the issued provenance'); + const publish = namedStep(steps, 'Publish the verified convenience release'); + assert.ok(attest); + assert.ok( + workflow.indexOf(preflight) < workflow.indexOf(attest) && + workflow.indexOf(attest) < workflow.indexOf(verify) && + workflow.indexOf(verify) < workflow.indexOf(publish), + ); + assert.match(preflight, /product-release-authority\.mjs verify-publication/u); + assert.match(verify, /gh attestation verify/u); + assert.match(verify, /@refs\/heads\/main/u); + assert.doesNotMatch(workflow.slice(workflow.indexOf('\n publish:')), /\$\{\{ inputs\./u); }); test('release workflows select npm from the root packageManager authority', () => { diff --git a/scripts/verify-macos-arm64-dmg.mjs b/scripts/verify-macos-arm64-dmg.mjs index 2608beb9b1..d3c9fa77f1 100644 --- a/scripts/verify-macos-arm64-dmg.mjs +++ b/scripts/verify-macos-arm64-dmg.mjs @@ -32,6 +32,7 @@ import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { FILESYSTEM_WORKER_PROTOCOL_VERSION } from '../packages/runtime/dist/filesystem-worker/protocol.js'; import { readProductManifestIdentity } from './product-release-identity.mjs'; +import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -147,6 +148,7 @@ export async function verifyPackagedMacApp( await requirePath(executable); await assertPackagedResources(resources, { requirePath, forbidPath }); + await assertPackagedUpdateConfiguration(resources); await assertPackagedDependencyClosure(resources); const executableArchitectures = await run('lipo', ['-archs', executable]); diff --git a/scripts/verify-macos-autoupdate.mjs b/scripts/verify-macos-autoupdate.mjs new file mode 100644 index 0000000000..4a548bb790 --- /dev/null +++ b/scripts/verify-macos-autoupdate.mjs @@ -0,0 +1,330 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { access, mkdir, mkdtemp, readFile, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { parse as parseYaml } from 'yaml'; +import { + assertPackagedUpdateConfiguration, + feedServed, + startDesktopUpdateFeed, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; +import { + evaluateInRenderer, + findRendererTarget, + isolatedUserEnv, + runCommand, + smokePackagedRenderer, + stopChild, + waitForDevToolsPort, + waitForUsableRenderer, +} from './verify-packaged-app.mjs'; +import { compareProductReleaseVersions } from './release-version.mjs'; + +const delay = (milliseconds) => + new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds)); +const step = (label) => console.log(`[verify-macos-autoupdate] ${label}`); +const { extractFile } = createRequire(import.meta.url)('@electron/asar'); + +function packagedManifest(appPath) { + const archive = join(appPath, 'Contents', 'Resources', 'app.asar'); + return JSON.parse(extractFile(archive, 'package.json').toString('utf8')); +} + +async function readBundleVersion(appPath, run) { + const { stdout } = await run('plutil', [ + '-extract', + 'CFBundleShortVersionString', + 'raw', + '-o', + '-', + join(appPath, 'Contents', 'Info.plist'), + ]); + return stdout.trim(); +} + +async function signingRequirement(appPath, run) { + const { stdout, stderr } = await run('codesign', ['-d', '-r-', appPath]); + const requirement = `${stdout}\n${stderr}` + .split(/\r?\n/u) + .map((line) => line.trim()) + .find((line) => line.startsWith('designated =>')); + if (!requirement) throw new Error(`Could not read the signing requirement from ${appPath}`); + return requirement; +} + +async function waitForExit(child, timeoutMs) { + if (child.exitCode !== null) return; + const exited = await Promise.race([ + new Promise((resolvePromise) => child.once('exit', resolvePromise)).then(() => true), + delay(timeoutMs).then(() => false), + ]); + if (!exited) + throw new Error(`Candidate process ${child.pid} did not exit for the update handoff.`); +} + +async function processIdsForExecutable(executable, run) { + const { stdout } = await run('ps', ['-axo', 'pid=,command=']); + return ( + stdout + .split(/\r?\n/u) + .map((line) => /^(\s*\d+)\s+(.+)$/u.exec(line)) + // Electron child processes reuse the app executable with extra arguments. + // Only the argument-free command is the application Squirrel relaunched. + .filter((match) => match?.[2] === executable) + .map((match) => Number(match[1])) + ); +} + +function processExists(processId) { + try { + process.kill(processId, 0); + return true; + } catch (error) { + if (error?.code === 'ESRCH') return false; + throw error; + } +} + +async function stopProcess(processId) { + if (!processExists(processId)) return; + process.kill(processId, 'SIGTERM'); + const deadline = Date.now() + 5_000; + while (processExists(processId) && Date.now() < deadline) await delay(100); + if (!processExists(processId)) return; + process.kill(processId, 'SIGKILL'); + while (processExists(processId) && Date.now() < deadline + 5_000) await delay(100); + if (processExists(processId)) throw new Error(`Updated process ${processId} did not exit.`); +} + +/** check → download → Squirrel.Mac replacement → automatic relaunch → smoke. */ +export async function verifyMacosAutoupdate( + candidateInput, + nextDirectoryInput, + { + platform = process.platform, + arch = process.arch, + run = runCommand, + makeTemporaryDirectory = () => mkdtemp(join(tmpdir(), 'maka-macos-autoupdate-')), + smokeRenderer = smokePackagedRenderer, + } = {}, +) { + if (platform !== 'darwin' || arch !== 'arm64') { + throw new Error('macOS auto-update verification requires an Apple Silicon macOS host.'); + } + if (!candidateInput || !nextDirectoryInput) { + throw new Error( + 'Usage: npm run verify:macos-autoupdate -- ', + ); + } + + const candidateZip = resolve(candidateInput); + const nextDirectory = resolve(nextDirectoryInput); + await access(candidateZip); + const metadata = parseYaml(await readFile(join(nextDirectory, 'latest-mac.yml'), 'utf8')); + const nextVersion = metadata?.version; + const nextZipName = metadata?.path; + if (typeof nextVersion !== 'string' || typeof nextZipName !== 'string') { + throw new Error(`latest-mac.yml in ${nextDirectory} has no update identity.`); + } + await verifyDesktopUpdateArtifacts({ + directory: nextDirectory, + metadataName: 'latest-mac.yml', + version: nextVersion, + artifactName: nextZipName, + }); + + const temporaryDirectory = await makeTemporaryDirectory(); + const candidateRoot = join(temporaryDirectory, 'candidate'); + const nextRoot = join(temporaryDirectory, 'next'); + const candidateApp = join(candidateRoot, 'Maka.app'); + const nextApp = join(nextRoot, 'Maka.app'); + let executable = join(candidateApp, 'Contents', 'MacOS', 'Maka'); + let feed; + let child; + let relaunchedPid; + let primaryError; + + try { + await Promise.all([mkdir(candidateRoot), mkdir(nextRoot)]); + step('extracting and verifying both signed application bundles'); + await run('ditto', ['-x', '-k', candidateZip, candidateRoot]); + await run('ditto', ['-x', '-k', join(nextDirectory, nextZipName), nextRoot]); + await Promise.all([ + run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', candidateApp]), + run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', nextApp]), + assertPackagedUpdateConfiguration(join(candidateApp, 'Contents', 'Resources')), + assertPackagedUpdateConfiguration(join(nextApp, 'Contents', 'Resources')), + ]); + if (packagedManifest(candidateApp).makaUpdateTestProfile !== undefined) { + throw new Error('The production candidate must not carry the update-test profile marker.'); + } + if (packagedManifest(nextApp).makaUpdateTestProfile !== true) { + throw new Error('The test successor must carry the isolated relaunch profile marker.'); + } + // macOS reports /private/var in process commands even when mkdtemp handed + // the harness /var. Compare the filesystem identity, not either spelling. + executable = await realpath(executable); + const [candidateRequirement, nextRequirement] = await Promise.all([ + signingRequirement(candidateApp, run), + signingRequirement(nextApp, run), + ]); + if (candidateRequirement !== nextRequirement) { + throw new Error('Candidate and next app do not have the same macOS signing identity.'); + } + const candidateVersion = await readBundleVersion(candidateApp, run); + if (compareProductReleaseVersions(nextVersion, candidateVersion) <= 0) { + throw new Error(`The served version ${nextVersion} is not newer than ${candidateVersion}.`); + } + + feed = await startDesktopUpdateFeed( + new Map([ + ['latest-mac.yml', join(nextDirectory, 'latest-mac.yml')], + [nextZipName, join(nextDirectory, nextZipName)], + [`${nextZipName}.blockmap`, join(nextDirectory, `${nextZipName}.blockmap`)], + [`${basename(candidateZip)}.blockmap`, `${candidateZip}.blockmap`], + ]), + ); + + const home = join(temporaryDirectory, 'home'); + const userData = join(candidateRoot, '.maka-update-test-user-data'); + await Promise.all([mkdir(home), mkdir(userData)]); + const childEnv = { + ...process.env, + ...isolatedUserEnv(home), + MAKA_SKIP_SHELL_ENV: '1', + MAKA_UPDATE_TEST_FEED: feed.url, + MAKA_UPDATE_TEST_USER_DATA_DIR: userData, + }; + delete childEnv.MAKA_UPDATE_MOCK_VERSION; + delete childEnv.MAKA_UPDATE_MOCK_STATE; + step(`launching candidate ${candidateVersion} against the loopback feed`); + child = spawn( + executable, + ['--remote-debugging-port=0', `--user-data-dir=${userData}`, '--enable-logging=stderr'], + { cwd: temporaryDirectory, env: childEnv, stdio: ['ignore', 'ignore', 'pipe'] }, + ); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk}`.slice(-32_768); + }); + const port = await waitForDevToolsPort(child); + const target = await findRendererTarget(port, child); + await waitForUsableRenderer(target.webSocketDebuggerUrl, child, { + description: 'Candidate renderer', + }); + + step('driving the real update check and download'); + await evaluateInRenderer(target.webSocketDebuggerUrl, 'window.maka.app.checkForUpdates()', { + awaitPromise: true, + timeoutMs: 30_000, + }); + const downloadDeadline = Date.now() + 180_000; + let status; + for (;;) { + status = await evaluateInRenderer( + target.webSocketDebuggerUrl, + 'window.maka.app.updateStatus()', + { awaitPromise: true }, + ); + if (status?.state === 'error') { + throw new Error(`Update ${status.operation ?? 'flow'} failed: ${status.message}`); + } + if (status?.state === 'downloaded') break; + if (child.exitCode !== null) throw new Error(`Candidate exited during download.\n${stderr}`); + if (Date.now() >= downloadDeadline) { + throw new Error(`Update never reached downloaded: ${JSON.stringify(status)}\n${stderr}`); + } + await delay(500); + } + if (status.currentVersion !== candidateVersion || status.latestVersion !== nextVersion) { + throw new Error( + `Downloaded update reported ${status.currentVersion} -> ${status.latestVersion}, ` + + `expected ${candidateVersion} -> ${nextVersion}.`, + ); + } + if (!feedServed(feed, 'latest-mac.yml') || !feedServed(feed, nextZipName)) { + throw new Error(`The update did not use the loopback feed: ${JSON.stringify(feed.requests)}`); + } + if (feed.unexpectedCount() > 0) { + throw new Error(`The app requested unexpected feed paths: ${JSON.stringify(feed.requests)}`); + } + + step('handing off to Squirrel.Mac and waiting for automatic relaunch'); + try { + const result = await evaluateInRenderer( + target.webSocketDebuggerUrl, + 'window.maka.app.installUpdate({ allowInterruptActiveTasks: true })', + { awaitPromise: true, timeoutMs: 30_000 }, + ); + if (result?.ok === false) throw new Error(`installUpdate refused: ${JSON.stringify(result)}`); + } catch (error) { + if (child.exitCode === null && !String(error.message).includes('WebSocket')) throw error; + } + await waitForExit(child, 60_000); + + const installDeadline = Date.now() + 180_000; + let installedVersion; + while (Date.now() < installDeadline) { + try { + installedVersion = await readBundleVersion(candidateApp, run); + const pids = await processIdsForExecutable(executable, run); + relaunchedPid = pids.find((pid) => pid !== child.pid); + if (installedVersion === nextVersion && relaunchedPid) break; + } catch { + // Squirrel replaces the bundle through a short path-missing window. + } + await delay(500); + } + if (installedVersion !== nextVersion || !relaunchedPid) { + throw new Error( + `Squirrel.Mac did not replace and relaunch ${candidateVersion} as ${nextVersion}; ` + + `installed=${installedVersion ?? 'unreadable'}, pid=${relaunchedPid ?? 'missing'}.`, + ); + } + await stopProcess(relaunchedPid); + relaunchedPid = undefined; + await run('codesign', ['--verify', '--deep', '--strict', '--verbose=2', candidateApp]); + await smokeRenderer(executable, { workingDirectory: temporaryDirectory }); + step(`verified ${candidateVersion} -> ${nextVersion} replacement and relaunch`); + return { candidateVersion, nextVersion, requests: feed.requests }; + } catch (error) { + primaryError = error; + throw error; + } finally { + const cleanupErrors = []; + if (child) await stopChild(child).catch((error) => cleanupErrors.push(error)); + if (relaunchedPid) await stopProcess(relaunchedPid).catch((error) => cleanupErrors.push(error)); + if (feed) await feed.close().catch((error) => cleanupErrors.push(error)); + await rm(temporaryDirectory, { recursive: true, force: true, maxRetries: 5 }).catch((error) => + cleanupErrors.push(error), + ); + if (!primaryError && cleanupErrors.length > 0) throw cleanupErrors[0]; + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await verifyMacosAutoupdate(process.argv[2], process.argv[3]); +} diff --git a/scripts/verify-windows-autoupdate.mjs b/scripts/verify-windows-autoupdate.mjs index da558a154d..fe0a1f477d 100644 --- a/scripts/verify-windows-autoupdate.mjs +++ b/scripts/verify-windows-autoupdate.mjs @@ -20,10 +20,14 @@ import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { access, mkdir, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; -import { createServer } from 'node:http'; import { tmpdir } from 'node:os'; import { basename, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; +import { + feedServed, + startDesktopUpdateFeed, + verifyDesktopUpdateArtifacts, +} from './desktop-update-contract.mjs'; import { evaluateInRenderer, findRendererTarget, @@ -46,6 +50,7 @@ import { powerShellLiteral, verifyPackagedWindowsApp, } from './verify-windows-x64.mjs'; +import { compareProductReleaseVersions } from './release-version.mjs'; const uninstallExecutableName = 'Uninstall Maka.exe'; const executableName = 'Maka.exe'; @@ -58,113 +63,6 @@ function step(label) { console.log(`[verify-windows-autoupdate] ${label}`); } -function compareStableVersions(left, right) { - const parse = (value) => value.split('.').map(Number); - const [lMaj, lMin, lPat] = parse(left); - const [rMaj, rMin, rPat] = parse(right); - if (lMaj !== rMaj) return lMaj - rMaj; - if (lMin !== rMin) return lMin - rMin; - return lPat - rPat; -} - -/** - * Loopback static file server for the update feed. Serves exactly the mapped - * root-level paths (`/latest.yml`, `/`, …) — the *full* request - * path is matched, so a nested `/x/latest.yml` counts as unexpected; a wrong - * updater request shape must surface, not be absorbed. A mapped path whose - * file is absent 404s without counting: the updater legitimately probes the - * *previous* version's blockmap for a differential download and falls back to - * the full installer when the feed does not have it. Bodies are read once at - * startup — electron-updater issues many ranged requests against the - * multi-hundred-megabyte installer, and re-reading it per request could push - * the download stage past its deadline. Supports single-range GETs because - * differential downloads use ranged requests. - */ -async function startFeedServer(files) { - const requests = []; - let unexpectedRequests = 0; - const bodies = new Map(); - for (const [name, filePath] of files) { - try { - bodies.set(`/${name}`, await readFile(filePath)); - } catch { - // Mapped but absent (the previous blockmap): served as an expected 404. - } - } - const server = createServer((request, response) => { - const method = request.method ?? 'GET'; - // The raw path segment is matched without decoding, so `/%6catest.yml` - // cannot alias `/latest.yml`. The query is allowed and recorded, not - // matched: electron-updater cache-busts its channel request with - // `?noCache=`, so rejecting queries rejects the updater's own - // documented request shape (the first live run proved exactly that). - const target = request.url ?? '/'; - const queryIndex = target.indexOf('?'); - const pathName = queryIndex === -1 ? target : target.slice(0, queryIndex); - const record = { method, path: pathName, target, status: 0 }; - requests.push(record); - const known = [...files.keys()].some((name) => `/${name}` === pathName); - if ((method !== 'GET' && method !== 'HEAD') || !known) { - unexpectedRequests += 1; - record.status = 404; - response.writeHead(404).end(); - return; - } - const body = bodies.get(pathName); - if (body === undefined) { - // Known path, absent file: the expected 404 shape (previous blockmap). - record.status = 404; - response.writeHead(404).end(); - return; - } - const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.range ?? ''); - if (range) { - const start = Number(range[1]); - const end = range[2] === '' ? body.length - 1 : Math.min(Number(range[2]), body.length - 1); - if (start > end || start >= body.length) { - record.status = 416; - response.writeHead(416, { 'Content-Range': `bytes */${body.length}` }).end(); - return; - } - record.status = 206; - response.writeHead(206, { - 'Content-Type': 'application/octet-stream', - 'Content-Range': `bytes ${start}-${end}/${body.length}`, - 'Content-Length': end - start + 1, - 'Accept-Ranges': 'bytes', - }); - response.end(method === 'HEAD' ? undefined : body.subarray(start, end + 1)); - return; - } - record.status = 200; - response.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Content-Length': body.length, - 'Accept-Ranges': 'bytes', - }); - response.end(method === 'HEAD' ? undefined : body); - }); - await new Promise((resolvePromise, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', resolvePromise); - }); - const address = server.address(); - if (!address || typeof address === 'string') { - server.close(); - throw new Error('Could not start the loopback update feed.'); - } - return { - url: `http://127.0.0.1:${address.port}`, - requests, - unexpectedCount: () => unexpectedRequests, - close: () => - new Promise((resolvePromise) => { - server.close(() => resolvePromise()); - server.closeAllConnections?.(); - }), - }; -} - export async function waitForInstalledProductVersion( executablePath, { @@ -237,15 +135,19 @@ export async function verifyWindowsAutoupdate( if (!nextVersion) { throw new Error(`latest.yml in ${nextDirectory} does not advertise a version.`); } - if (compareStableVersions(nextVersion, candidateVersion) <= 0) { + if (compareProductReleaseVersions(nextVersion, candidateVersion) <= 0) { throw new Error( `The served version ${nextVersion} must be newer than the candidate ${candidateVersion}.`, ); } const nextInstallerName = `Maka-${nextVersion}-win-x64.exe`; installerVersion(join(nextDirectory, nextInstallerName)); - await access(join(nextDirectory, nextInstallerName)); - await access(join(nextDirectory, `${nextInstallerName}.blockmap`)); + await verifyDesktopUpdateArtifacts({ + directory: nextDirectory, + metadataName: 'latest.yml', + version: nextVersion, + artifactName: nextInstallerName, + }); const temporaryDirectory = await makeTemporaryDirectory(); const installDirectory = join(temporaryDirectory, 'installed'); @@ -264,7 +166,7 @@ export async function verifyWindowsAutoupdate( // a differential download. If the file is missing next to the candidate // installer, the mapped-but-absent 404 makes the updater fall back to the // full download — both are valid production shapes. - feed = await startFeedServer( + feed = await startDesktopUpdateFeed( new Map([ ['latest.yml', join(nextDirectory, 'latest.yml')], [nextInstallerName, join(nextDirectory, nextInstallerName)], @@ -366,19 +268,12 @@ export async function verifyWindowsAutoupdate( step('asserting the download really came from the loopback feed'); // Exact root-level paths, matching the server's own allowlist shape. - const served = (name) => - feed.requests.some( - (request) => - request.method === 'GET' && - request.path === `/${name}` && - (request.status === 200 || request.status === 206), - ); - if (!served('latest.yml')) { + if (!feedServed(feed, 'latest.yml')) { throw new Error( `The app never fetched latest.yml from the loopback feed: ${JSON.stringify(feed.requests)}`, ); } - if (!served(nextInstallerName)) { + if (!feedServed(feed, nextInstallerName)) { throw new Error( `The app never downloaded the installer from the loopback feed: ${JSON.stringify(feed.requests)}`, ); diff --git a/scripts/verify-windows-harness.test.mjs b/scripts/verify-windows-harness.test.mjs index 81231b14c6..8aaae12534 100644 --- a/scripts/verify-windows-harness.test.mjs +++ b/scripts/verify-windows-harness.test.mjs @@ -25,7 +25,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; import { after, describe, it } from 'node:test'; -import { bumpedAutoupdateVersion } from './package-windows-autoupdate-next.mjs'; +import { bumpedAutoupdateVersion } from './desktop-update-contract.mjs'; import { validateWindowsUpgradeBaseline } from './prepare-windows-upgrade-baseline.mjs'; import { diffTreeManifests, diff --git a/scripts/verify-windows-x64.mjs b/scripts/verify-windows-x64.mjs index 219a7c7072..e46cf03d8a 100644 --- a/scripts/verify-windows-x64.mjs +++ b/scripts/verify-windows-x64.mjs @@ -23,6 +23,7 @@ import { tmpdir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { readProductManifestIdentity } from './product-release-identity.mjs'; +import { assertPackagedUpdateConfiguration } from './desktop-update-contract.mjs'; import { assertMissing, assertPackagedDependencyClosure, @@ -148,8 +149,10 @@ export async function verifyPackagedWindowsApp( requireAppIconCatalog: requiresCurrentContract, requireDirectPeerArtifact: requiresCurrentContract, }); - if (requiresCurrentContract) await assertPackagedDependencyClosure(resources); - else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); + if (requiresCurrentContract) { + await assertPackagedUpdateConfiguration(resources); + await assertPackagedDependencyClosure(resources); + } else await requirePath(join(resources, 'git', 'cmd', 'git.exe')); step('reading the executable architecture'); const machine = await readMachine(executable);