From e381a4e6f301ecdb9fbb54e9f2307f6f62b35d98 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 10 Jul 2026 14:29:05 -0700 Subject: [PATCH 1/3] revert: "revert: swift package release workflow and follow-ups (#404)" --- .github/workflows/npm_release.yml | 450 +++++++++++++++--- build_all_ios.sh | 1 + build_all_vision.sh | 1 + build_npm_ios.sh | 61 ++- build_npm_vision.sh | 24 +- build_spm_artifacts.sh | 83 ++++ .../project.pbxproj | 35 +- .../internal/nativescript-build.xcconfig | 7 +- .../project.pbxproj | 35 +- .../internal/nativescript-build.xcconfig | 5 +- scripts/stamp-spm-release.mjs | 138 ++++++ scripts/stamp-template-local-spm.mjs | 72 +++ scripts/stamp-template-version.mjs | 33 ++ 13 files changed, 836 insertions(+), 109 deletions(-) create mode 100755 build_spm_artifacts.sh create mode 100644 scripts/stamp-spm-release.mjs create mode 100644 scripts/stamp-template-local-spm.mjs create mode 100644 scripts/stamp-template-version.mjs diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml index 6d3f89db..36e8f93f 100644 --- a/.github/workflows/npm_release.yml +++ b/.github/workflows/npm_release.yml @@ -1,21 +1,112 @@ on: push: branches: - - main + - main # -> prerelease published under the "next" dist-tag tags: - - "v*" + - "v*" # -> release published under the "latest" dist-tag + workflow_dispatch: + inputs: + version: + description: "Release version to cut, e.g. 9.1.0 (creates tag v and publishes 'latest'). Leave empty for a manual 'next' build." + required: false + default: "" env: NPM_TAG: "next" XCODE_VERSION: "^15.0" +# Minimal default token permissions for every job. Jobs that need more declare it +# locally (github-release: contents:write; publish: id-token:write). Cross-repo +# writes to ios-spm use a GitHub App token, not GITHUB_TOKEN. +permissions: + contents: read + +# The runtime xcframeworks are no longer shipped inside the npm packages. Each +# build publishes them as GitHub Release assets and points the SwiftPM manifest +# (github.com/NativeScript/ios-spm) at them via binaryTarget(url:checksum:). The +# slim npm package only carries the Xcode project template + metadata generator. +# +# Flow: setup → build (ios, visionos) → test → github-release (assets) → +# spm-update (stamp + tag ios-spm) → publish (npm) → verify-spm +# +# The release half (github-release, spm-update, publish, verify-spm) is gated on +# repo variable ENABLE_SPM_RELEASE. Until it is 'true' (and the ios-spm App +# credentials are set), the workflow runs setup/build/test only and publishes NOTHING — so the +# slim package can never ship before its matching ios-spm tag exists. Turn the +# variable on as part of the cutover. jobs: + setup: + name: Resolve version + runs-on: ubuntu-latest + outputs: + npm_version: ${{ steps.out.outputs.NPM_VERSION }} + npm_tag: ${{ steps.out.outputs.NPM_TAG }} + build_matrix: ${{ steps.out.outputs.BUILD_MATRIX }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22 + - name: Install deps for version scripts + run: npm install --no-audit --no-fund + - name: Compute version and tag + id: out + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ -n "$INPUT_VERSION" ]; then + # manual release: cut tag v and publish (latest unless prerelease) + NPM_VERSION="${INPUT_VERSION#v}" + elif [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then + # tag push (v*): the tag is authoritative. Strip the leading 'v' and + # assert it matches package.json so the release can't drift from its tag. + NPM_VERSION="${GITHUB_REF#refs/tags/}" + NPM_VERSION="${NPM_VERSION#v}" + PKG_VERSION=$(node -e "console.log(require('./package.json').version);") + if [ "$NPM_VERSION" != "$PKG_VERSION" ]; then + echo "::error::Tag v$NPM_VERSION does not match package.json version $PKG_VERSION. Bump package.json before tagging." >&2 + exit 1 + fi + else + # branch push (main) or manual run without a version -> "next" prerelease + NPM_VERSION=$(node ./scripts/get-next-version.js) + fi + NPM_TAG=$(NPM_VERSION=$NPM_VERSION node ./scripts/get-npm-tag.js) + echo "NPM_VERSION=$NPM_VERSION" >> $GITHUB_OUTPUT + echo "NPM_TAG=$NPM_TAG" >> $GITHUB_OUTPUT + # Target matrix: ios always builds/publishes. visionos is built and + # published ONLY for real releases (a v* tag push, or a manual dispatch + # with a version), never for the rolling "next" channel that every push + # to main produces. The discriminator is the resolved dist-tag: pushes + # to main (and dispatch without a version) -> "next" -> ios only. + if [ "$NPM_TAG" = "next" ]; then + BUILD_MATRIX='{"include":[{"target":"ios","script":"build-ios"}]}' + else + BUILD_MATRIX='{"include":[{"target":"ios","script":"build-ios"},{"target":"visionos","script":"build-vision"}]}' + fi + echo "BUILD_MATRIX=$BUILD_MATRIX" >> $GITHUB_OUTPUT + echo "Resolved $NPM_VERSION (tag: $NPM_TAG); build targets: $BUILD_MATRIX" + build: - name: Build + name: Build ${{ matrix.target }} runs-on: macos-14 - outputs: - npm_version: ${{ steps.npm_version_output.outputs.NPM_VERSION }} - npm_tag: ${{ steps.npm_version_output.outputs.NPM_TAG }} + needs: setup + strategy: + fail-fast: false + # Computed in `setup`: ios always; visionos only for real releases + # (npm_tag != "next"), never for the rolling "next" channel. Each entry + # carries `target` (package identity, @nativescript/) and `script` + # (the npm build script; note the vision script is build-vision). + matrix: ${{ fromJSON(needs.setup.outputs.build_matrix) }} + env: + NPM_VERSION: ${{ needs.setup.outputs.npm_version }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -55,34 +146,26 @@ jobs: sudo mkdir -p /usr/local/bin sudo ln -sf "$(command -v cmake)" /usr/local/bin/cmake fi - - name: Get Current Version - run: | - NPM_VERSION=$(node -e "console.log(require('./package.json').version);") - echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV - - name: Bump version for dev release - if: ${{ !contains(github.ref, 'refs/tags/') }} - run: | - NPM_VERSION=$(node ./scripts/get-next-version.js) - echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV - npm version $NPM_VERSION --no-git-tag-version - - name: Output NPM Version and tag - id: npm_version_output - run: | - NPM_TAG=$(node ./scripts/get-npm-tag.js) - echo NPM_VERSION=$NPM_VERSION >> $GITHUB_OUTPUT - echo NPM_TAG=$NPM_TAG >> $GITHUB_OUTPUT - - name: Build - run: npm run build-ios + - name: Set package version + run: npm version $NPM_VERSION --no-git-tag-version --allow-same-version + - name: Build (${{ matrix.target }}) + run: npm run ${{ matrix.script }} - name: Upload npm package artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: npm-package - path: dist/nativescript-ios-${{steps.npm_version_output.outputs.NPM_VERSION}}.tgz + name: npm-package-${{ matrix.target }} + path: dist/nativescript-*-${{ env.NPM_VERSION }}.tgz + - name: Upload SwiftPM artifacts (xcframework zips + checksums) + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: spm-artifacts-${{ matrix.target }} + path: dist/artifacts/* - name: Upload dSYMs artifact uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: NativeScript-dSYMs + name: dSYMs-${{ matrix.target }} path: dist/dSYMs + test: name: Test runs-on: macos-14 @@ -155,18 +238,172 @@ jobs: with: name: test-results path: ${{env.TEST_FOLDER}}/test_results.xcresult + + # Publish the xcframework zips + dSYMs as a GitHub Release asset for EVERY + # version (prerelease unless 'latest'), because the SwiftPM binaryTarget url + # must resolve for `next`/`pr` consumers too — not only tagged releases. + github-release: + name: GitHub Release (SPM assets) + runs-on: ubuntu-latest + # Master switch for the SPM release flow. When unset, the workflow runs + # setup/build/test only — nothing is released or published, so landing this + # on main before the token is configured is a no-op for consumers. + if: ${{ vars.ENABLE_SPM_RELEASE == 'true' }} + permissions: + contents: write + needs: + - setup + - build + - test + env: + NPM_VERSION: ${{ needs.setup.outputs.npm_version }} + NPM_TAG: ${{ needs.setup.outputs.npm_tag }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22 + - name: Setup + run: npm install --no-audit --no-fund + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: spm-artifacts-* + path: spm-artifacts + merge-multiple: true + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: dSYMs-* + path: dist/dSYMs + merge-multiple: true + - name: Zip dSYMs + working-directory: dist/dSYMs + run: find . -maxdepth 1 -name '*.dSYM' -print | xargs -I@ zip -r @.zip @ || true + - name: Partial Changelog + run: npx conventional-changelog -p angular -r2 > body.md + - name: Create / update release with SPM artifacts + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + with: + tag: "v${{ env.NPM_VERSION }}" + name: "v${{ env.NPM_VERSION }}" + commit: ${{ github.sha }} + artifacts: "spm-artifacts/*.xcframework.zip,dist/dSYMs/*.zip" + bodyFile: "body.md" + prerelease: ${{ needs.setup.outputs.npm_tag != 'latest' }} + allowUpdates: true + + # Stamp + tag github.com/NativeScript/ios-spm to point at this release's assets. + # Cross-repo write uses a short-lived GitHub App installation token, minted per + # run and scoped to ios-spm only (no long-lived PAT to rotate). Enable by setting + # repo variable ENABLE_SPM_RELEASE=true plus the App credentials + # (variable APP_CLIENT_ID + secret APP_PRIVATE_KEY); the App must be installed on + # NativeScript/ios-spm with Contents: Read and write. + spm-update: + name: Update ios-spm manifest + runs-on: ubuntu-latest + if: ${{ vars.ENABLE_SPM_RELEASE == 'true' }} + needs: + - setup + - build + - test + - github-release + env: + NPM_VERSION: ${{ needs.setup.outputs.npm_version }} + NPM_TAG: ${{ needs.setup.outputs.npm_tag }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + - name: Mint ios-spm App token (short-lived, scoped to ios-spm) + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: NativeScript + repositories: ios-spm + permission-contents: write + - name: Checkout ios (for stamping script) + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + path: ios + persist-credentials: false + - name: Checkout ios-spm + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + repository: NativeScript/ios-spm + token: ${{ steps.app-token.outputs.token }} + path: ios-spm + persist-credentials: false + - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: 22 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: spm-artifacts-* + path: spm-artifacts + merge-multiple: true + - name: Stamp Package.swift + run: | + STRICT="" + if [ "$NPM_TAG" = "latest" ]; then STRICT="--strict"; fi + CHECKSUM_ARGS="" + for f in spm-artifacts/checksums-*.env; do + [ -f "$f" ] && CHECKSUM_ARGS="$CHECKSUM_ARGS --checksums $f" + done + echo "Using checksum files:$CHECKSUM_ARGS" + if [ -z "$CHECKSUM_ARGS" ]; then echo "No checksum files found" >&2; exit 1; fi + node ios/scripts/stamp-spm-release.mjs \ + --package ios-spm/Package.swift \ + --version "$NPM_VERSION" \ + $CHECKSUM_ARGS $STRICT + - name: Commit and tag ios-spm + working-directory: ios-spm + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + git config user.name "NativeScript Bot" + git config user.email "oss@nativescript.org" + # persist-credentials:false keeps the token out of .git/config; push to a + # token-authenticated URL instead (the App token is masked in logs). + REMOTE="https://x-access-token:${GH_TOKEN}@github.com/NativeScript/ios-spm.git" + git add Package.swift + git commit -m "release: $NPM_VERSION" || echo "no changes to commit" + git tag -f "$NPM_VERSION" + git push "$REMOTE" HEAD:main + git push -f "$REMOTE" "refs/tags/$NPM_VERSION" + publish: + name: Publish ${{ matrix.target }} runs-on: ubuntu-latest environment: npm-publish + # Gated + ordered after spm-update so a slim package is never published before + # its matching ios-spm tag exists (which would make it unresolvable). + if: ${{ vars.ENABLE_SPM_RELEASE == 'true' }} needs: + - setup - build - test + - spm-update permissions: contents: read id-token: write + strategy: + fail-fast: false + # Same targets as the build matrix (computed in `setup`): visionos is + # published only for real releases, never for the "next" channel. The + # unused `script` key on each entry is harmless here. + matrix: ${{ fromJSON(needs.setup.outputs.build_matrix) }} env: - NPM_VERSION: ${{needs.build.outputs.npm_version}} - NPM_TAG: ${{needs.build.outputs.npm_tag}} + NPM_VERSION: ${{ needs.setup.outputs.npm_version }} + NPM_TAG: ${{ needs.setup.outputs.npm_tag }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -178,7 +415,7 @@ jobs: registry-url: "https://registry.npmjs.org" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: npm-package + name: npm-package-${{ matrix.target }} path: dist - name: Update npm (required for OIDC trusted publishing) run: | @@ -186,65 +423,142 @@ jobs: corepack install -g npm@11.5.1 test "$(npm --version)" = "11.5.1" test "$(npx --version)" = "11.5.1" + - name: Resolve package name + run: | + if [ "${{ matrix.target }}" = "ios" ]; then echo "PKG=ios" >> $GITHUB_ENV; else echo "PKG=visionos" >> $GITHUB_ENV; fi - name: Publish package (OIDC trusted publishing) if: ${{ vars.USE_NPM_TOKEN != 'true' }} run: | - echo "Publishing @nativescript/ios@$NPM_VERSION to NPM with tag $NPM_TAG via OIDC trusted publishing..." + echo "Publishing @nativescript/$PKG@$NPM_VERSION to NPM with tag $NPM_TAG via OIDC trusted publishing..." unset NODE_AUTH_TOKEN if [ -n "${NPM_CONFIG_USERCONFIG:-}" ]; then rm -f "$NPM_CONFIG_USERCONFIG" fi - npm publish ./dist/nativescript-ios-${{env.NPM_VERSION}}.tgz --tag $NPM_TAG --access public --provenance + npm publish ./dist/nativescript-${PKG}-${NPM_VERSION}.tgz --tag $NPM_TAG --access public --provenance env: NODE_AUTH_TOKEN: "" - - name: Publish package (granular token) if: ${{ vars.USE_NPM_TOKEN == 'true' }} run: | - echo "Publishing @nativescript/ios@$NPM_VERSION to NPM with tag $NPM_TAG via granular token..." - npm publish ./dist/nativescript-ios-${{env.NPM_VERSION}}.tgz --tag $NPM_TAG --access public --provenance + echo "Publishing @nativescript/$PKG@$NPM_VERSION to NPM with tag $NPM_TAG via granular token..." + npm publish ./dist/nativescript-${PKG}-${NPM_VERSION}.tgz --tag $NPM_TAG --access public --provenance env: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} - github-release: - runs-on: ubuntu-latest - # only runs on tagged commits - if: ${{ contains(github.ref, 'refs/tags/') }} - permissions: - contents: write + + # Post-publish smoke test: resolve ios-spm at the released tag and verify the + # binary artifact downloads + checksum-validate against the real Release. + # + # Channel-aware, mirroring the build/publish matrix: `next` builds iOS only, so + # its Release has no visionos assets. `swift package resolve` eagerly downloads + # EVERY binaryTarget in a resolved package (not just the ones a referenced + # product needs), so resolving the full ios-spm manifest on a `next` version + # would try to fetch the visionos zips and 404. For `next` we therefore probe + # an iOS-only package; only real releases (v* tag / manual version) resolve the + # full manifest and thus also verify the visionos artifacts. + verify-spm: + name: Verify SPM resolution + runs-on: macos-14 + if: ${{ vars.ENABLE_SPM_RELEASE == 'true' }} needs: - - build - - test + - setup + - spm-update env: - NPM_VERSION: ${{needs.build.outputs.npm_version}} + NPM_VERSION: ${{ needs.setup.outputs.npm_version }} + NPM_TAG: ${{ needs.setup.outputs.npm_tag }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 - - name: Setup - run: npm install - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: npm-package - path: dist - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: NativeScript-dSYMs - path: dist/dSYMs - - name: Zip dSYMs - working-directory: dist/dSYMs - run: find . -maxdepth 1 -name '*.dSYM' -print | xargs -I@ zip -r @.zip @ - - name: Partial Changelog - run: npx conventional-changelog -p angular -r2 > body.md - - uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 - with: - artifacts: "dist/nativescript-ios-*.tgz,dist/dSYMs/*.zip" - bodyFile: "body.md" - prerelease: ${{needs.build.outputs.npm_tag != 'latest'}} - allowUpdates: true + - name: Assert ios-spm manifest pins this release + run: | + # Guard against the manifest pointing at a STALE release — e.g. a stamp + # that no-ops and leaves a prior version's nsVersion + artifact + # URLs/checksums in place. Those still resolve and checksum-pass (they're + # internally consistent), so `swift package resolve` below won't catch + # it; this explicit version assertion does. + url="https://raw.githubusercontent.com/NativeScript/ios-spm/${NPM_VERSION}/Package.swift" + manifest="$(curl -fsSL "$url")" + if ! printf '%s\n' "$manifest" | grep -q "let nsVersion = \"${NPM_VERSION}\""; then + echo "::error::ios-spm@${NPM_VERSION} does not pin nsVersion=\"${NPM_VERSION}\" (stale stamp?). Found:" >&2 + printf '%s\n' "$manifest" | grep -n "nsVersion" >&2 || true + exit 1 + fi + echo "OK: ios-spm@${NPM_VERSION} pins nsVersion=\"${NPM_VERSION}\"" + - name: Generate a probe package that depends on ios-spm (real releases) + # Real release (v* tag / manual version): the Release carries every asset, + # so probe the full ios-spm manifest. `swift package resolve` eagerly + # downloads all binaryTargets, exercising the iOS AND visionos artifacts. + if: ${{ needs.setup.outputs.npm_tag != 'next' }} + run: | + node -e ' + const fs = require("fs"); + const v = process.env.NPM_VERSION; + fs.mkdirSync("spmverify/Sources/Probe", { recursive: true }); + fs.writeFileSync("spmverify/Package.swift", + "// swift-tools-version: 5.10\n" + + "import PackageDescription\n" + + "let package = Package(\n" + + " name: \"Probe\",\n" + + " platforms: [.iOS(.v13)],\n" + + " dependencies: [.package(url: \"https://github.com/NativeScript/ios-spm.git\", exact: \"" + v + "\")],\n" + + " targets: [.target(name: \"Probe\", dependencies: [.product(name: \"NativeScript\", package: \"ios-spm\")])]\n" + + ")\n"); + fs.writeFileSync("spmverify/Sources/Probe/Probe.swift", "// probe\n"); + ' + - name: Generate an iOS-only probe package (next channel) + # The "next" Release has no visionos assets (build matrix is iOS-only for + # next), and `swift package resolve` would eagerly try to fetch every + # binaryTarget in the full ios-spm manifest -> 404 on the visionos zips. + # So probe an iOS-only package whose binaryTargets are the iOS artifacts + # from the stamped manifest (real Release URLs + real checksums): resolve + # still downloads and checksum-verifies exactly what a next consumer gets, + # and never touches visionos. + if: ${{ needs.setup.outputs.npm_tag == 'next' }} + run: | + url="https://raw.githubusercontent.com/NativeScript/ios-spm/${NPM_VERSION}/Package.swift" + curl -fsSL "$url" -o manifest.swift + node -e ' + const fs = require("fs"); + const v = process.env.NPM_VERSION; + const body = fs.readFileSync("manifest.swift", "utf8"); + // Pull the checksum out of the binaryTarget whose url ends in the given + // artifact zip. The url is a Swift interpolation ("\(base)/"), so + // match up to the closing quote; anchoring on " keeps the iOS + // NativeScript slot from matching the visionos one (…visionos.xcframework.zip). + const ck = (artifact) => { + const re = new RegExp("url:\\s*\"[^\"]*" + artifact.replace(/\./g, "\\.") + "\"\\s*,\\s*checksum:\\s*\"([0-9a-f]{64})\""); + const m = body.match(re); + if (!m) { console.error("No iOS checksum for " + artifact + " in ios-spm@" + v); process.exit(1); } + return m[1]; + }; + const nsCk = ck("NativeScript.xcframework.zip"); + const tkCk = ck("TKLiveSync.xcframework.zip"); + fs.mkdirSync("spmverify/Sources/Probe", { recursive: true }); + fs.writeFileSync("spmverify/Package.swift", + "// swift-tools-version: 5.10\n" + + "import PackageDescription\n" + + "let base = \"https://github.com/NativeScript/ios/releases/download/v" + v + "\"\n" + + "let package = Package(\n" + + " name: \"Probe\",\n" + + " platforms: [.iOS(.v13)],\n" + + " targets: [\n" + + " .binaryTarget(name: \"NativeScript\", url: \"\\(base)/NativeScript.xcframework.zip\", checksum: \"" + nsCk + "\"),\n" + + " .binaryTarget(name: \"TKLiveSync\", url: \"\\(base)/TKLiveSync.xcframework.zip\", checksum: \"" + tkCk + "\"),\n" + + " .target(name: \"Probe\", dependencies: [\"NativeScript\", \"TKLiveSync\"])\n" + + " ]\n" + + ")\n"); + fs.writeFileSync("spmverify/Sources/Probe/Probe.swift", "// probe\n"); + ' + - name: Resolve (downloads the xcframework zips and verifies their checksums) + working-directory: spmverify + run: | + # Resolution fetches each binaryTarget's zip and verifies its SHA-256 + # against the stamped checksum; a mismatch or a missing release asset + # fails the release here. For next this covers the iOS artifacts only; + # real releases resolve the full manifest and also cover visionos. + swift package resolve + echo "ios-spm@$NPM_VERSION resolved and checksum-verified." diff --git a/build_all_ios.sh b/build_all_ios.sh index e490f4a5..832c38bb 100755 --- a/build_all_ios.sh +++ b/build_all_ios.sh @@ -7,4 +7,5 @@ rm -rf ./dist ./build_nativescript.sh --no-vision ./build_tklivesync.sh --no-vision ./prepare_dSYMs.sh +./build_spm_artifacts.sh ios ./build_npm_ios.sh \ No newline at end of file diff --git a/build_all_vision.sh b/build_all_vision.sh index f5d1bd40..b8ef5b49 100755 --- a/build_all_vision.sh +++ b/build_all_vision.sh @@ -7,4 +7,5 @@ rm -rf ./dist ./build_nativescript.sh --no-catalyst --no-iphone --no-sim ./build_tklivesync.sh --no-catalyst --no-iphone --no-sim ./prepare_dSYMs.sh +./build_spm_artifacts.sh visionos ./build_npm_vision.sh \ No newline at end of file diff --git a/build_npm_ios.sh b/build_npm_ios.sh index 8f36cbb0..ae62019f 100755 --- a/build_npm_ios.sh +++ b/build_npm_ios.sh @@ -11,26 +11,65 @@ cp ./package.json "$OUTPUT_DIR" cp -r "./project-template-ios/" "$OUTPUT_DIR/framework" -cp -r "dist/NativeScript.xcframework" "$OUTPUT_DIR/framework/internal" -cp -r "dist/TKLiveSync.xcframework" "$OUTPUT_DIR/framework/internal" +# The NativeScript / TKLiveSync xcframeworks are NO LONGER bundled in npm. They +# are published as GitHub Release artifacts and consumed via SwiftPM +# (github.com/NativeScript/ios-spm). Stamp the runtime version into the packaged +# template's SwiftPM reference so it resolves the matching release. +NPM_VERSION=$(node -e "console.log(require('./package.json').version)") +node ./scripts/stamp-template-version.mjs \ + "$OUTPUT_DIR/framework/__PROJECT_NAME__.xcodeproj/project.pbxproj" \ + "$NPM_VERSION" +# Local builds have no ios-spm release tag to resolve, so by default (outside +# CI) rewrite the template to a LOCAL SwiftPM package over the xcframeworks +# just built into dist/. Force with NS_SPM_LOCAL=1, disable with NS_SPM_LOCAL=0. +# The resulting .tgz embeds an absolute path into this checkout — local +# `ns platform add ios --framework-path=dist/nativescript-ios-*.tgz` use only. +if [[ "${NS_SPM_LOCAL:-}" == "1" || ( -z "${CI:-}" && "${NS_SPM_LOCAL:-}" != "0" ) ]]; then + LOCAL_SPM_DIR="$(pwd)/dist/local-spm" + rm -rf "$LOCAL_SPM_DIR" + mkdir -p "$LOCAL_SPM_DIR" + cp -R "dist/NativeScript.xcframework" "$LOCAL_SPM_DIR/" + cp -R "dist/TKLiveSync.xcframework" "$LOCAL_SPM_DIR/" + cat > "$LOCAL_SPM_DIR/Package.swift" <<'EOF' +// swift-tools-version: 5.10 +// Local dev override of github.com/NativeScript/ios-spm: same product shape, +// but binaryTargets point at the freshly built xcframeworks in this folder. +import PackageDescription + +let package = Package( + name: "NativeScriptSDK", + platforms: [ + .iOS(.v13), + .macCatalyst(.v13), + ], + products: [ + .library(name: "NativeScript", targets: ["NativeScript", "TKLiveSync"]), + .library(name: "NativeScriptSDK", targets: ["NativeScript", "TKLiveSync"]), + ], + dependencies: [], + targets: [ + .binaryTarget(name: "NativeScript", path: "NativeScript.xcframework"), + .binaryTarget(name: "TKLiveSync", path: "TKLiveSync.xcframework"), + ] +) +EOF + node ./scripts/stamp-template-local-spm.mjs \ + "$OUTPUT_DIR/framework/__PROJECT_NAME__.xcodeproj/project.pbxproj" \ + "$LOCAL_SPM_DIR" +fi + +# Build-time metadata generator is still shipped in npm (Phase 1). See the +# distribution plan for moving this to an on-demand artifact (Phase 2). mkdir -p "$OUTPUT_DIR/framework/internal/metadata-generator-x86_64" cp -r "metadata-generator/dist/x86_64/." "$OUTPUT_DIR/framework/internal/metadata-generator-x86_64" mkdir -p "$OUTPUT_DIR/framework/internal/metadata-generator-arm64" cp -r "metadata-generator/dist/arm64/." "$OUTPUT_DIR/framework/internal/metadata-generator-arm64" -# Add xcframeworks to .zip (NPM modules do not support symlinks, unzipping is done by {N} CLI) -( - set -e - cd $OUTPUT_DIR/framework/internal - zip -qr --symlinks XCFrameworks.zip *.xcframework - rm -rf *.xcframework -) - pushd "$OUTPUT_DIR" npm pack mv *.tgz ../ popd -checkpoint "npm package created." \ No newline at end of file +checkpoint "npm package created." diff --git a/build_npm_vision.sh b/build_npm_vision.sh index 8685cbcf..e8c52af7 100755 --- a/build_npm_vision.sh +++ b/build_npm_vision.sh @@ -11,26 +11,26 @@ cp ./package.json "$OUTPUT_DIR" cp -r "./project-template-vision/" "$OUTPUT_DIR/framework" -cp -r "dist/NativeScript.xcframework" "$OUTPUT_DIR/framework/internal" -cp -r "dist/TKLiveSync.xcframework" "$OUTPUT_DIR/framework/internal" - +# The NativeScript / TKLiveSync xcframeworks are NO LONGER bundled in npm. They +# are published as GitHub Release artifacts and consumed via SwiftPM +# (github.com/NativeScript/ios-spm). Stamp the runtime version into the packaged +# template's SwiftPM reference so it resolves the matching release. +NPM_VERSION=$(node -e "console.log(require('./package.json').version)") +node ./scripts/stamp-template-version.mjs \ + "$OUTPUT_DIR/framework/__PROJECT_NAME__.xcodeproj/project.pbxproj" \ + "$NPM_VERSION" + +# Build-time metadata generator is still shipped in npm (Phase 1). See the +# distribution plan for moving this to an on-demand artifact (Phase 2). mkdir -p "$OUTPUT_DIR/framework/internal/metadata-generator-x86_64" cp -r "metadata-generator/dist/x86_64/." "$OUTPUT_DIR/framework/internal/metadata-generator-x86_64" mkdir -p "$OUTPUT_DIR/framework/internal/metadata-generator-arm64" cp -r "metadata-generator/dist/arm64/." "$OUTPUT_DIR/framework/internal/metadata-generator-arm64" -# Add xcframeworks to .zip (NPM modules do not support symlinks, unzipping is done by {N} CLI) -( - set -e - cd $OUTPUT_DIR/framework/internal - zip -qr --symlinks XCFrameworks.zip *.xcframework - rm -rf *.xcframework -) - pushd "$OUTPUT_DIR" npm pack mv *.tgz ../ popd -checkpoint "npm package created." \ No newline at end of file +checkpoint "npm package created." diff --git a/build_spm_artifacts.sh b/build_spm_artifacts.sh new file mode 100755 index 00000000..6d231c07 --- /dev/null +++ b/build_spm_artifacts.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Package the built xcframeworks as SwiftPM binary-target artifacts: +# * a .zip per xcframework (framework at the zip root, symlinks preserved) +# * a SHA-256 checksum for each (the value SwiftPM's binaryTarget(checksum:) expects) +# +# Output goes to dist/artifacts/: +# .zip and a combined checksums.env (KEY=sha256 lines) +# +# Usage: ./build_spm_artifacts.sh [ios|visionos] (default: ios) +# +# These artifacts are uploaded to the GitHub Release and referenced by +# github.com/NativeScript/ios-spm (see scripts/stamp-spm-release.mjs). +set -e +source "$(dirname "$0")/build_utils.sh" + +TARGET="${1:-ios}" +DIST="$(pwd)/dist" +OUT="$DIST/artifacts" + +case "$TARGET" in + ios) + NS_ZIP="NativeScript.xcframework.zip" + TK_ZIP="TKLiveSync.xcframework.zip" + NS_KEY="NS_CHECKSUM_NATIVESCRIPT_IOS" + TK_KEY="NS_CHECKSUM_TKLIVESYNC_IOS" + ;; + visionos|vision|xr) + TARGET="visionos" + NS_ZIP="NativeScript.visionos.xcframework.zip" + TK_ZIP="TKLiveSync.visionos.xcframework.zip" + NS_KEY="NS_CHECKSUM_NATIVESCRIPT_VISIONOS" + TK_KEY="NS_CHECKSUM_TKLIVESYNC_VISIONOS" + ;; + *) + echo "Unknown target '$TARGET' (expected ios or visionos)" >&2 + exit 1 + ;; +esac + +checkpoint "Packaging SwiftPM artifacts for $TARGET..." +rm -rf "$OUT" +mkdir -p "$OUT" + +# SwiftPM's binaryTarget(checksum:) is the SHA-256 of the zip. `swift package +# compute-checksum` is the canonical producer; shasum -a 256 yields the same +# value and is the portable fallback. +compute_checksum() { + local zip="$1" + if command -v swift >/dev/null 2>&1 && swift package compute-checksum "$zip" >/dev/null 2>&1; then + swift package compute-checksum "$zip" + else + shasum -a 256 "$zip" | awk '{print $1}' + fi +} + +# zip_xcframework +zip_xcframework() { + local src="$1" zipname="$2" + if [ ! -d "$DIST/$src" ]; then + echo "Missing $DIST/$src — run the runtime build first." >&2 + exit 1 + fi + ( cd "$DIST" && zip -qr --symlinks "$OUT/$zipname" "$src" ) +} + +zip_xcframework "NativeScript.xcframework" "$NS_ZIP" +zip_xcframework "TKLiveSync.xcframework" "$TK_ZIP" + +NS_SUM="$(compute_checksum "$OUT/$NS_ZIP")" +TK_SUM="$(compute_checksum "$OUT/$TK_ZIP")" + +# Per-target filename so the iOS and visionOS env files don't collide when the +# release/stamp jobs merge both platforms' artifacts into one directory. +CHECKSUMS="$OUT/checksums-$TARGET.env" +{ + echo "${NS_KEY}=${NS_SUM}" + echo "${TK_KEY}=${TK_SUM}" +} > "$CHECKSUMS" + +checkpoint "SwiftPM artifacts ready in $OUT:" +( cd "$OUT" && ls -lh *.zip ) +echo "Checksums ($CHECKSUMS):" +cat "$CHECKSUMS" diff --git a/project-template-ios/__PROJECT_NAME__.xcodeproj/project.pbxproj b/project-template-ios/__PROJECT_NAME__.xcodeproj/project.pbxproj index 142e4274..52bdac61 100644 --- a/project-template-ios/__PROJECT_NAME__.xcodeproj/project.pbxproj +++ b/project-template-ios/__PROJECT_NAME__.xcodeproj/project.pbxproj @@ -7,8 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 39940D9122C4EF600050DDE1 /* NativeScript.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 39940D8C22C4EAAA0050DDE1 /* NativeScript.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 39940E1C22C5DFFF0050DDE1 /* TKLiveSync.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 39940E1B22C5DFFF0050DDE1 /* TKLiveSync.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + AA10000000000000000000C1 /* NativeScript in Frameworks */ = {isa = PBXBuildFile; productRef = AA10000000000000000000B1 /* NativeScript */; }; 858B842D18CA22B800AB12DE /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 858B833A18CA111C00AB12DE /* InfoPlist.strings */; }; AAA0AADB2A54B96B00EE55A4 /* NativeScriptStart.m in Sources */ = {isa = PBXBuildFile; fileRef = AAA0AADA2A54B96B00EE55A4 /* NativeScriptStart.m */; }; CD45EE7C18DC2D5800FB50C0 /* app in Resources */ = {isa = PBXBuildFile; fileRef = CD45EE7A18DC2D5800FB50C0 /* app */; }; @@ -22,8 +21,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 39940D9122C4EF600050DDE1 /* NativeScript.xcframework in Embed Frameworks */, - 39940E1C22C5DFFF0050DDE1 /* TKLiveSync.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -35,8 +32,6 @@ 391174B721F1D99900BA2583 /* plugins-release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "plugins-release.xcconfig"; sourceTree = SOURCE_ROOT; }; 391174B821F1D99900BA2583 /* plugins-debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "plugins-debug.xcconfig"; sourceTree = SOURCE_ROOT; }; 39940D8122C4E84C0050DDE1 /* __PROJECT_NAME__.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = __PROJECT_NAME__.entitlements; sourceTree = ""; }; - 39940D8C22C4EAAA0050DDE1 /* NativeScript.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = NativeScript.xcframework; path = internal/NativeScript.xcframework; sourceTree = ""; }; - 39940E1B22C5DFFF0050DDE1 /* TKLiveSync.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TKLiveSync.xcframework; path = internal/TKLiveSync.xcframework; sourceTree = ""; }; 42C751E2232B769100186695 /* nativescript-pre-link */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = "nativescript-pre-link"; path = "internal/nativescript-pre-link"; sourceTree = SOURCE_ROOT; }; 42C751E3232B769100186695 /* strip-dynamic-framework-architectures.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; name = "strip-dynamic-framework-architectures.sh"; path = "internal/strip-dynamic-framework-architectures.sh"; sourceTree = SOURCE_ROOT; }; 42C751E4232B769100186695 /* nsld.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; name = nsld.sh; path = internal/nsld.sh; sourceTree = SOURCE_ROOT; }; @@ -63,6 +58,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AA10000000000000000000C1 /* NativeScript in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -115,8 +111,6 @@ 858B833018CA111C00AB12DE /* Frameworks */ = { isa = PBXGroup; children = ( - 39940E1B22C5DFFF0050DDE1 /* TKLiveSync.xcframework */, - 39940D8C22C4EAAA0050DDE1 /* NativeScript.xcframework */, ); name = Frameworks; sourceTree = ""; @@ -176,6 +170,9 @@ dependencies = ( ); name = __PROJECT_NAME__; + packageProductDependencies = ( + AA10000000000000000000B1 /* NativeScript */, + ); productName = JDBridgeApp; productReference = 858B843318CA22B800AB12DE /* __PROJECT_NAME__.app */; productType = "com.apple.product-type.application"; @@ -206,6 +203,9 @@ Base, ); mainGroup = 858B832518CA111C00AB12DE; + packageReferences = ( + AA10000000000000000000A1 /* XCRemoteSwiftPackageReference "ios-spm" */, + ); productRefGroup = 858B832F18CA111C00AB12DE /* Products */; projectDirPath = ""; projectRoot = ""; @@ -498,6 +498,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AA10000000000000000000A1 /* XCRemoteSwiftPackageReference "ios-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/NativeScript/ios-spm.git"; + requirement = { + kind = exactVersion; + version = "__NS_RUNTIME_VERSION__"; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AA10000000000000000000B1 /* NativeScript */ = { + isa = XCSwiftPackageProductDependency; + package = AA10000000000000000000A1 /* XCRemoteSwiftPackageReference "ios-spm" */; + productName = NativeScript; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 858B832618CA111C00AB12DE /* Project object */; } diff --git a/project-template-ios/internal/nativescript-build.xcconfig b/project-template-ios/internal/nativescript-build.xcconfig index 8a3fc311..6a2167c8 100644 --- a/project-template-ios/internal/nativescript-build.xcconfig +++ b/project-template-ios/internal/nativescript-build.xcconfig @@ -1,6 +1,11 @@ // * NativeScript build related flags // * Add [sdk=*] after each one to avoid conflict with CocoaPods flags -OTHER_LDFLAGS[sdk=*] = $(inherited) -ObjC -sectcreate __DATA __TNSMetadata "$(CONFIGURATION_BUILD_DIR)/metadata-$(CURRENT_ARCH).bin" -framework NativeScript -framework TKLiveSync -F"$(SRCROOT)/internal" -licucore -lz -lc++ -framework Foundation -framework UIKit -framework CoreGraphics -framework CoreServices -framework Security +// * NativeScript + TKLiveSync are provided by the SwiftPM "NativeScript" product +// * (github.com/NativeScript/ios-spm), so SwiftPM links/embeds them automatically. +// * The explicit -framework NativeScript / -framework TKLiveSync / -F internal are +// * intentionally omitted. SwiftPM stages the resolved framework slice into +// * CONFIGURATION_BUILD_DIR (below), which is where the metadata generator finds it. +OTHER_LDFLAGS[sdk=*] = $(inherited) -ObjC -sectcreate __DATA __TNSMetadata "$(CONFIGURATION_BUILD_DIR)/metadata-$(CURRENT_ARCH).bin" -licucore -lz -lc++ -framework Foundation -framework UIKit -framework CoreGraphics -framework CoreServices -framework Security // We need to add CONFIGURATION_BUILD_DIR here so that we can explicitly quote-escape any paths in it, because the implicitly added path by Xcode is not always escaped FRAMEWORK_SEARCH_PATHS[sdk=*] = $(inherited) "$(SRCROOT)/internal/" "$(CONFIGURATION_BUILD_DIR)" diff --git a/project-template-vision/__PROJECT_NAME__.xcodeproj/project.pbxproj b/project-template-vision/__PROJECT_NAME__.xcodeproj/project.pbxproj index 01de986a..e549122c 100644 --- a/project-template-vision/__PROJECT_NAME__.xcodeproj/project.pbxproj +++ b/project-template-vision/__PROJECT_NAME__.xcodeproj/project.pbxproj @@ -7,8 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 39940D9122C4EF600050DDE1 /* NativeScript.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 39940D8C22C4EAAA0050DDE1 /* NativeScript.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 39940E1C22C5DFFF0050DDE1 /* TKLiveSync.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 39940E1B22C5DFFF0050DDE1 /* TKLiveSync.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + AA10000000000000000000C1 /* NativeScriptVisionOS in Frameworks */ = {isa = PBXBuildFile; productRef = AA10000000000000000000B1 /* NativeScriptVisionOS */; }; 858B842D18CA22B800AB12DE /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 858B833A18CA111C00AB12DE /* InfoPlist.strings */; }; AAA0AADB2A54B96B00EE55A4 /* NativeScriptStart.m in Sources */ = {isa = PBXBuildFile; fileRef = AAA0AADA2A54B96B00EE55A4 /* NativeScriptStart.m */; }; CD45EE7C18DC2D5800FB50C0 /* app in Resources */ = {isa = PBXBuildFile; fileRef = CD45EE7A18DC2D5800FB50C0 /* app */; }; @@ -21,8 +20,6 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( - 39940D9122C4EF600050DDE1 /* NativeScript.xcframework in Embed Frameworks */, - 39940E1C22C5DFFF0050DDE1 /* TKLiveSync.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -34,8 +31,6 @@ 391174B721F1D99900BA2583 /* plugins-release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "plugins-release.xcconfig"; sourceTree = SOURCE_ROOT; }; 391174B821F1D99900BA2583 /* plugins-debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = "plugins-debug.xcconfig"; sourceTree = SOURCE_ROOT; }; 39940D8122C4E84C0050DDE1 /* __PROJECT_NAME__.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = __PROJECT_NAME__.entitlements; sourceTree = ""; }; - 39940D8C22C4EAAA0050DDE1 /* NativeScript.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = NativeScript.xcframework; path = internal/NativeScript.xcframework; sourceTree = ""; }; - 39940E1B22C5DFFF0050DDE1 /* TKLiveSync.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = TKLiveSync.xcframework; path = internal/TKLiveSync.xcframework; sourceTree = ""; }; 42C751E2232B769100186695 /* nativescript-pre-link */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = "nativescript-pre-link"; path = "internal/nativescript-pre-link"; sourceTree = SOURCE_ROOT; }; 42C751E3232B769100186695 /* strip-dynamic-framework-architectures.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; name = "strip-dynamic-framework-architectures.sh"; path = "internal/strip-dynamic-framework-architectures.sh"; sourceTree = SOURCE_ROOT; }; 42C751E4232B769100186695 /* nsld.sh */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; name = nsld.sh; path = internal/nsld.sh; sourceTree = SOURCE_ROOT; }; @@ -61,6 +56,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + AA10000000000000000000C1 /* NativeScriptVisionOS in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -112,8 +108,6 @@ 858B833018CA111C00AB12DE /* Frameworks */ = { isa = PBXGroup; children = ( - 39940E1B22C5DFFF0050DDE1 /* TKLiveSync.xcframework */, - 39940D8C22C4EAAA0050DDE1 /* NativeScript.xcframework */, ); name = Frameworks; sourceTree = ""; @@ -173,6 +167,9 @@ dependencies = ( ); name = __PROJECT_NAME__; + packageProductDependencies = ( + AA10000000000000000000B1 /* NativeScriptVisionOS */, + ); productName = JDBridgeApp; productReference = 858B843318CA22B800AB12DE /* __PROJECT_NAME__.app */; productType = "com.apple.product-type.application"; @@ -203,6 +200,9 @@ Base, ); mainGroup = 858B832518CA111C00AB12DE; + packageReferences = ( + AA10000000000000000000A1 /* XCRemoteSwiftPackageReference "ios-spm" */, + ); productRefGroup = 858B832F18CA111C00AB12DE /* Products */; projectDirPath = ""; projectRoot = ""; @@ -504,6 +504,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AA10000000000000000000A1 /* XCRemoteSwiftPackageReference "ios-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/NativeScript/ios-spm.git"; + requirement = { + kind = exactVersion; + version = "__NS_RUNTIME_VERSION__"; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AA10000000000000000000B1 /* NativeScriptVisionOS */ = { + isa = XCSwiftPackageProductDependency; + package = AA10000000000000000000A1 /* XCRemoteSwiftPackageReference "ios-spm" */; + productName = NativeScriptVisionOS; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 858B832618CA111C00AB12DE /* Project object */; } diff --git a/project-template-vision/internal/nativescript-build.xcconfig b/project-template-vision/internal/nativescript-build.xcconfig index 8a3fc311..c9033349 100644 --- a/project-template-vision/internal/nativescript-build.xcconfig +++ b/project-template-vision/internal/nativescript-build.xcconfig @@ -1,6 +1,9 @@ // * NativeScript build related flags // * Add [sdk=*] after each one to avoid conflict with CocoaPods flags -OTHER_LDFLAGS[sdk=*] = $(inherited) -ObjC -sectcreate __DATA __TNSMetadata "$(CONFIGURATION_BUILD_DIR)/metadata-$(CURRENT_ARCH).bin" -framework NativeScript -framework TKLiveSync -F"$(SRCROOT)/internal" -licucore -lz -lc++ -framework Foundation -framework UIKit -framework CoreGraphics -framework CoreServices -framework Security +// * NativeScript + TKLiveSync are provided by the SwiftPM "NativeScriptVisionOS" product +// * (github.com/NativeScript/ios-spm); SwiftPM links/embeds them automatically. The +// * explicit -framework NativeScript / -framework TKLiveSync / -F internal are omitted. +OTHER_LDFLAGS[sdk=*] = $(inherited) -ObjC -sectcreate __DATA __TNSMetadata "$(CONFIGURATION_BUILD_DIR)/metadata-$(CURRENT_ARCH).bin" -licucore -lz -lc++ -framework Foundation -framework UIKit -framework CoreGraphics -framework CoreServices -framework Security // We need to add CONFIGURATION_BUILD_DIR here so that we can explicitly quote-escape any paths in it, because the implicitly added path by Xcode is not always escaped FRAMEWORK_SEARCH_PATHS[sdk=*] = $(inherited) "$(SRCROOT)/internal/" "$(CONFIGURATION_BUILD_DIR)" diff --git a/scripts/stamp-spm-release.mjs b/scripts/stamp-spm-release.mjs new file mode 100644 index 00000000..ed0f022c --- /dev/null +++ b/scripts/stamp-spm-release.mjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// Stamp the released version + artifact checksums into ios-spm/Package.swift. +// +// The manifest ships with replaceable tokens: +// let nsVersion = "NS_SPM_VERSION" +// checksum: "NS_CHECKSUM_NATIVESCRIPT_IOS" (and TKLIVESYNC_IOS, *_VISIONOS) +// +// This script (run by the release flow against a checkout of NativeScript/ios-spm) +// replaces them with the version being released and the SHA-256 of each uploaded +// xcframework zip. Checksums come from one or more `KEY=sha256` env files produced +// by build_spm_artifacts.sh (one per platform: iOS job + visionOS job). +// +// Usage: +// node scripts/stamp-spm-release.mjs \ +// --package /path/to/ios-spm/Package.swift \ +// --version 9.1.0 \ +// --checksums ios-checksums.env [--checksums vision-checksums.env] \ +// [--strict] +// +// --strict fails if any NS_SPM_VERSION / NS_CHECKSUM_* token is left unstamped +// (use it for a full iOS + visionOS release so a placeholder can never ship). +import fs from "node:fs"; + +const args = process.argv.slice(2); +const opts = { checksums: [] }; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--package") opts.package = args[++i]; + else if (a === "--version") opts.version = args[++i]; + else if (a === "--checksums") opts.checksums.push(args[++i]); + else if (a === "--strict") opts.strict = true; + else { + console.error(`Unknown argument: ${a}`); + process.exit(1); + } +} + +if (!opts.package || !opts.version) { + console.error( + "Usage: stamp-spm-release.mjs --package --version --checksums [--checksums ] [--strict]" + ); + process.exit(1); +} + +const SHA256_RE = /^[0-9a-f]{64}$/; +const checksums = {}; +for (const file of opts.checksums) { + const text = fs.readFileSync(file, "utf8"); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!key) continue; + // A binaryTarget checksum must be a 64-char lowercase hex SHA-256. Reject + // anything else now (empty/truncated/uppercase) so we can't stamp a manifest + // that resolves to a checksum mismatch later. + if (key.startsWith("NS_CHECKSUM_") && !SHA256_RE.test(value)) { + console.error( + `ERROR: ${key} in ${file} is not a valid SHA-256 checksum: "${value}"` + ); + process.exit(1); + } + checksums[key] = value; + } +} + +let manifest = fs.readFileSync(opts.package, "utf8"); + +// Stamping is IDEMPOTENT: each slot is rewritten whether it still holds the +// NS_SPM_VERSION / NS_CHECKSUM_* token or an already-stamped concrete value. +// This matters because the release flow pushes the stamped manifest back to +// `main`, so by the second release there are no tokens left. A one-shot token +// replace would then silently no-op, the "no changes to commit" tag would land +// on the prior commit, and every new tag would resolve to the FIRST release's +// artifacts (the 9.0.4-next.* incident). Anchoring on the structural shape +// instead of the token lets us re-stamp correctly forever. +const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +// Each checksum env key ↔ the artifact zip its binaryTarget references (the +// pairing baked into the manifest template). Lets us find the right checksum +// slot by URL once the NS_CHECKSUM_* tokens are gone. +const KEY_TO_ARTIFACT = { + NS_CHECKSUM_NATIVESCRIPT_IOS: "NativeScript.xcframework.zip", + NS_CHECKSUM_TKLIVESYNC_IOS: "TKLiveSync.xcframework.zip", + NS_CHECKSUM_NATIVESCRIPT_VISIONOS: "NativeScript.visionos.xcframework.zip", + NS_CHECKSUM_TKLIVESYNC_VISIONOS: "TKLiveSync.visionos.xcframework.zip", +}; + +// Version: rewrite the value inside `let nsVersion = "..."`. +const versionRe = /(let\s+nsVersion\s*=\s*")[^"]*(")/; +if (!versionRe.test(manifest)) { + console.error(`ERROR: could not find 'let nsVersion = "..."' in ${opts.package}`); + process.exit(1); +} +manifest = manifest.replace(versionRe, `$1${opts.version}$2`); + +// Checksums: for each provided checksum, rewrite the value in the binaryTarget +// whose url points at the matching artifact zip. +const applied = []; +for (const [key, value] of Object.entries(checksums)) { + const artifact = KEY_TO_ARTIFACT[key]; + if (!artifact) { + console.error(`ERROR: no artifact mapping for checksum key ${key}; update KEY_TO_ARTIFACT.`); + process.exit(1); + } + const re = new RegExp( + `(url:\\s*"[^"]*${escapeRegExp(artifact)}",\\s*checksum:\\s*")[^"]*(")` + ); + if (!re.test(manifest)) { + console.error(`ERROR: no binaryTarget checksum slot for ${artifact} in ${opts.package}`); + process.exit(1); + } + manifest = manifest.replace(re, `$1${value}$2`); + applied.push(key); +} + +fs.writeFileSync(opts.package, manifest); + +console.log(`Stamped ${opts.package}`); +console.log(` version: ${opts.version}`); +console.log(` checksums applied: ${applied.length ? applied.join(", ") : "(none)"}`); + +// Safety net: no placeholder tokens may remain (they'd resolve to a broken URL +// or a checksum mismatch). With idempotent stamping a leftover token signals a +// real manifest/key drift (e.g. a missing platform's checksums), not a routine +// 2nd-release no-op. +const leftover = [...new Set(manifest.match(/NS_SPM_VERSION|NS_CHECKSUM_[A-Z_]+/g) || [])]; +if (leftover.length) { + const msg = `Unstamped tokens remain: ${leftover.join(", ")}`; + if (opts.strict) { + console.error(`ERROR: ${msg}`); + process.exit(1); + } + console.warn(`WARNING: ${msg}`); +} diff --git a/scripts/stamp-template-local-spm.mjs b/scripts/stamp-template-local-spm.mjs new file mode 100644 index 00000000..1264a051 --- /dev/null +++ b/scripts/stamp-template-local-spm.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Stamp a packaged project template to consume the runtime via a LOCAL SwiftPM +// package instead of the released ios-spm tag (the D6 "dev/offline override" +// from SPM_DISTRIBUTION_PLAN.md). +// +// Local `npm run build-ios` produces xcframeworks in dist/ but no ios-spm +// release tag, so the template's XCRemoteSwiftPackageReference (exactVersion +// pinned by stamp-template-version.mjs) can never resolve. This script rewrites +// that reference into an XCLocalSwiftPackageReference pointing at the local-spm +// package the build just created (dist/local-spm — Package.swift + binary +// targets over the built xcframeworks). +// +// The resulting npm package is machine-specific (it embeds an absolute path +// into this checkout) and is only meant for `ns platform add ios +// --framework-path=dist/nativescript-ios-.tgz` style local testing. +// +// Usage: node scripts/stamp-template-local-spm.mjs +import fs from "node:fs"; +import path from "node:path"; + +const [, , pbxPath, localSpmPath] = process.argv; + +if (!pbxPath || !localSpmPath) { + console.error("Usage: stamp-template-local-spm.mjs "); + process.exit(1); +} + +if (!path.isAbsolute(localSpmPath)) { + console.error(`local-spm path must be absolute, got: ${localSpmPath}`); + process.exit(1); +} + +if (!fs.existsSync(path.join(localSpmPath, "Package.swift"))) { + console.error(`No Package.swift found in ${localSpmPath} — run the runtime build first.`); + process.exit(1); +} + +let contents = fs.readFileSync(pbxPath, "utf8"); + +const REMOTE_REF_RE = + /\/\* Begin XCRemoteSwiftPackageReference section \*\/[\s\S]*?\/\* End XCRemoteSwiftPackageReference section \*\//; + +if (!REMOTE_REF_RE.test(contents)) { + console.error(`No XCRemoteSwiftPackageReference section found in ${pbxPath} (already stamped local?).`); + process.exit(1); +} + +// The template uses a fixed UUID for the package reference; keep it so the +// packageReferences list and product dependency keep resolving. +const refUuidMatch = contents.match(/([0-9A-F]{24}) \/\* XCRemoteSwiftPackageReference "ios-spm" \*\//); +if (!refUuidMatch) { + console.error(`Could not find the "ios-spm" package reference UUID in ${pbxPath}.`); + process.exit(1); +} +const refUuid = refUuidMatch[1]; + +contents = contents.replace( + REMOTE_REF_RE, + `/* Begin XCLocalSwiftPackageReference section */ +\t\t${refUuid} /* XCLocalSwiftPackageReference "local-spm" */ = { +\t\t\tisa = XCLocalSwiftPackageReference; +\t\t\trelativePath = ${JSON.stringify(localSpmPath)}; +\t\t}; +/* End XCLocalSwiftPackageReference section */` +); + +// Update the comment annotations everywhere the reference UUID appears +// (packageReferences list + XCSwiftPackageProductDependency). +contents = contents.split(`${refUuid} /* XCRemoteSwiftPackageReference "ios-spm" */`).join(`${refUuid} /* XCLocalSwiftPackageReference "local-spm" */`); + +fs.writeFileSync(pbxPath, contents); +console.log(`Stamped ${pbxPath} → local SwiftPM package at ${localSpmPath}`); diff --git a/scripts/stamp-template-version.mjs b/scripts/stamp-template-version.mjs new file mode 100644 index 00000000..bff2b32f --- /dev/null +++ b/scripts/stamp-template-version.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Stamp the runtime version into a packaged project template's SwiftPM reference. +// +// The source templates ship with `version = "__NS_RUNTIME_VERSION__";` in their +// XCRemoteSwiftPackageReference. At npm-pack time we replace that placeholder +// with the concrete version being published, so `@nativescript/ios@X` always +// resolves the matching `ios-spm` tag `X` (and therefore the xcframework built +// for X). The {N} CLI copies the stamped template verbatim — no CLI change. +// +// Usage: node scripts/stamp-template-version.mjs +import fs from "node:fs"; + +const [, , pbxPath, version] = process.argv; + +if (!pbxPath || !version) { + console.error("Usage: stamp-template-version.mjs "); + process.exit(1); +} + +const PLACEHOLDER = "__NS_RUNTIME_VERSION__"; +let contents = fs.readFileSync(pbxPath, "utf8"); + +if (!contents.includes(PLACEHOLDER)) { + console.error( + `No '${PLACEHOLDER}' placeholder found in ${pbxPath}. ` + + `Either it was already stamped or the SwiftPM reference is missing.` + ); + process.exit(1); +} + +contents = contents.split(PLACEHOLDER).join(version); +fs.writeFileSync(pbxPath, contents); +console.log(`Stamped ${pbxPath} → ios-spm exactVersion ${version}`); From 4d45a9e9d8274bb46f33b2dd0d7b933deddc70db Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 10 Jul 2026 14:35:33 -0700 Subject: [PATCH 2/3] fix(release): shape the ios-spm manifest per channel and verify the real manifest --- .github/workflows/npm_release.yml | 122 ++++++------------- build_spm_artifacts.sh | 2 +- scripts/generate-spm-manifest.mjs | 196 ++++++++++++++++++++++++++++++ scripts/generate-spm-probe.mjs | 65 ++++++++++ scripts/stamp-spm-release.mjs | 138 --------------------- 5 files changed, 299 insertions(+), 224 deletions(-) create mode 100644 scripts/generate-spm-manifest.mjs create mode 100644 scripts/generate-spm-probe.mjs delete mode 100644 scripts/stamp-spm-release.mjs diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml index 36e8f93f..bd6d0ad8 100644 --- a/.github/workflows/npm_release.yml +++ b/.github/workflows/npm_release.yml @@ -27,7 +27,7 @@ permissions: # slim npm package only carries the Xcode project template + metadata generator. # # Flow: setup → build (ios, visionos) → test → github-release (assets) → -# spm-update (stamp + tag ios-spm) → publish (npm) → verify-spm +# spm-update (generate manifest + tag ios-spm) → publish (npm) → verify-spm # # The release half (github-release, spm-update, publish, verify-spm) is gated on # repo variable ENABLE_SPM_RELEASE. Until it is 'true' (and the ios-spm App @@ -350,17 +350,24 @@ jobs: pattern: spm-artifacts-* path: spm-artifacts merge-multiple: true - - name: Stamp Package.swift + - name: Generate Package.swift run: | + # The generator emits the WHOLE manifest, shaped by the checksums it + # is given: "next" builds produce only checksums-ios.env, so the next + # manifest omits the visionOS product/targets whose Release assets + # don't exist (SwiftPM eagerly downloads every binaryTarget in a + # resolved manifest — an unshipped asset 404s for every consumer). + # Real releases pass --strict so a full release can never silently + # ship an iOS-only manifest. STRICT="" - if [ "$NPM_TAG" = "latest" ]; then STRICT="--strict"; fi + if [ "$NPM_TAG" != "next" ]; then STRICT="--strict"; fi CHECKSUM_ARGS="" for f in spm-artifacts/checksums-*.env; do [ -f "$f" ] && CHECKSUM_ARGS="$CHECKSUM_ARGS --checksums $f" done echo "Using checksum files:$CHECKSUM_ARGS" if [ -z "$CHECKSUM_ARGS" ]; then echo "No checksum files found" >&2; exit 1; fi - node ios/scripts/stamp-spm-release.mjs \ + node ios/scripts/generate-spm-manifest.mjs \ --package ios-spm/Package.swift \ --version "$NPM_VERSION" \ $CHECKSUM_ARGS $STRICT @@ -448,13 +455,12 @@ jobs: # Post-publish smoke test: resolve ios-spm at the released tag and verify the # binary artifact downloads + checksum-validate against the real Release. # - # Channel-aware, mirroring the build/publish matrix: `next` builds iOS only, so - # its Release has no visionos assets. `swift package resolve` eagerly downloads - # EVERY binaryTarget in a resolved package (not just the ones a referenced - # product needs), so resolving the full ios-spm manifest on a `next` version - # would try to fetch the visionos zips and 404. For `next` we therefore probe - # an iOS-only package; only real releases (v* tag / manual version) resolve the - # full manifest and thus also verify the visionos artifacts. + # One probe for every channel, against the REAL released manifest. The + # manifest's target set is channel-shaped by generate-spm-manifest.mjs + # ("next" manifests only declare the iOS targets, because next Releases have + # no visionos assets), so resolving ios-spm at the released tag exercises + # exactly the artifact set a consumer's xcodebuild will download: iOS-only on + # next, iOS + visionos on real releases (v* tag / manual version). verify-spm: name: Verify SPM resolution runs-on: macos-14 @@ -470,95 +476,41 @@ jobs: uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 - name: Assert ios-spm manifest pins this release run: | - # Guard against the manifest pointing at a STALE release — e.g. a stamp - # that no-ops and leaves a prior version's nsVersion + artifact - # URLs/checksums in place. Those still resolve and checksum-pass (they're - # internally consistent), so `swift package resolve` below won't catch - # it; this explicit version assertion does. + # Guard against the tag pointing at a STALE manifest — e.g. a + # commit/tag step that silently failed and left a prior version's + # nsVersion + artifact URLs/checksums in place. Those still resolve + # and checksum-pass (they're internally consistent), so `swift + # package resolve` below won't catch it; this explicit version + # assertion does. url="https://raw.githubusercontent.com/NativeScript/ios-spm/${NPM_VERSION}/Package.swift" manifest="$(curl -fsSL "$url")" if ! printf '%s\n' "$manifest" | grep -q "let nsVersion = \"${NPM_VERSION}\""; then - echo "::error::ios-spm@${NPM_VERSION} does not pin nsVersion=\"${NPM_VERSION}\" (stale stamp?). Found:" >&2 + echo "::error::ios-spm@${NPM_VERSION} does not pin nsVersion=\"${NPM_VERSION}\" (stale manifest?). Found:" >&2 printf '%s\n' "$manifest" | grep -n "nsVersion" >&2 || true exit 1 fi echo "OK: ios-spm@${NPM_VERSION} pins nsVersion=\"${NPM_VERSION}\"" - - name: Generate a probe package that depends on ios-spm (real releases) - # Real release (v* tag / manual version): the Release carries every asset, - # so probe the full ios-spm manifest. `swift package resolve` eagerly - # downloads all binaryTargets, exercising the iOS AND visionos artifacts. - if: ${{ needs.setup.outputs.npm_tag != 'next' }} - run: | - node -e ' - const fs = require("fs"); - const v = process.env.NPM_VERSION; - fs.mkdirSync("spmverify/Sources/Probe", { recursive: true }); - fs.writeFileSync("spmverify/Package.swift", - "// swift-tools-version: 5.10\n" + - "import PackageDescription\n" + - "let package = Package(\n" + - " name: \"Probe\",\n" + - " platforms: [.iOS(.v13)],\n" + - " dependencies: [.package(url: \"https://github.com/NativeScript/ios-spm.git\", exact: \"" + v + "\")],\n" + - " targets: [.target(name: \"Probe\", dependencies: [.product(name: \"NativeScript\", package: \"ios-spm\")])]\n" + - ")\n"); - fs.writeFileSync("spmverify/Sources/Probe/Probe.swift", "// probe\n"); - ' - - name: Generate an iOS-only probe package (next channel) - # The "next" Release has no visionos assets (build matrix is iOS-only for - # next), and `swift package resolve` would eagerly try to fetch every - # binaryTarget in the full ios-spm manifest -> 404 on the visionos zips. - # So probe an iOS-only package whose binaryTargets are the iOS artifacts - # from the stamped manifest (real Release URLs + real checksums): resolve - # still downloads and checksum-verifies exactly what a next consumer gets, - # and never touches visionos. - if: ${{ needs.setup.outputs.npm_tag == 'next' }} - run: | - url="https://raw.githubusercontent.com/NativeScript/ios-spm/${NPM_VERSION}/Package.swift" - curl -fsSL "$url" -o manifest.swift - node -e ' - const fs = require("fs"); - const v = process.env.NPM_VERSION; - const body = fs.readFileSync("manifest.swift", "utf8"); - // Pull the checksum out of the binaryTarget whose url ends in the given - // artifact zip. The url is a Swift interpolation ("\(base)/"), so - // match up to the closing quote; anchoring on " keeps the iOS - // NativeScript slot from matching the visionos one (…visionos.xcframework.zip). - const ck = (artifact) => { - const re = new RegExp("url:\\s*\"[^\"]*" + artifact.replace(/\./g, "\\.") + "\"\\s*,\\s*checksum:\\s*\"([0-9a-f]{64})\""); - const m = body.match(re); - if (!m) { console.error("No iOS checksum for " + artifact + " in ios-spm@" + v); process.exit(1); } - return m[1]; - }; - const nsCk = ck("NativeScript.xcframework.zip"); - const tkCk = ck("TKLiveSync.xcframework.zip"); - fs.mkdirSync("spmverify/Sources/Probe", { recursive: true }); - fs.writeFileSync("spmverify/Package.swift", - "// swift-tools-version: 5.10\n" + - "import PackageDescription\n" + - "let base = \"https://github.com/NativeScript/ios/releases/download/v" + v + "\"\n" + - "let package = Package(\n" + - " name: \"Probe\",\n" + - " platforms: [.iOS(.v13)],\n" + - " targets: [\n" + - " .binaryTarget(name: \"NativeScript\", url: \"\\(base)/NativeScript.xcframework.zip\", checksum: \"" + nsCk + "\"),\n" + - " .binaryTarget(name: \"TKLiveSync\", url: \"\\(base)/TKLiveSync.xcframework.zip\", checksum: \"" + tkCk + "\"),\n" + - " .target(name: \"Probe\", dependencies: [\"NativeScript\", \"TKLiveSync\"])\n" + - " ]\n" + - ")\n"); - fs.writeFileSync("spmverify/Sources/Probe/Probe.swift", "// probe\n"); - ' + - name: Generate a probe package that depends on ios-spm + # The probe pins ios-spm at the exact released version. Resolving it + # takes the same path a consumer's xcodebuild does, and the released + # manifest only declares targets whose assets exist (channel-shaped by + # generate-spm-manifest.mjs) — so this single probe is valid for every + # channel. + run: node scripts/generate-spm-probe.mjs --version "$NPM_VERSION" --dir spmverify - name: Resolve (downloads the xcframework zips and verifies their checksums) working-directory: spmverify run: | # Resolution fetches each binaryTarget's zip and verifies its SHA-256 - # against the stamped checksum; a mismatch or a missing release asset - # fails the release here. For next this covers the iOS artifacts only; - # real releases resolve the full manifest and also cover visionos. + # against the manifest checksum; a mismatch or a missing release asset + # fails the release here. For next the manifest declares the iOS + # artifacts only; real releases also declare (and verify) visionos. swift package resolve echo "ios-spm@$NPM_VERSION resolved and checksum-verified." diff --git a/build_spm_artifacts.sh b/build_spm_artifacts.sh index 6d231c07..a9b35c3d 100755 --- a/build_spm_artifacts.sh +++ b/build_spm_artifacts.sh @@ -9,7 +9,7 @@ # Usage: ./build_spm_artifacts.sh [ios|visionos] (default: ios) # # These artifacts are uploaded to the GitHub Release and referenced by -# github.com/NativeScript/ios-spm (see scripts/stamp-spm-release.mjs). +# github.com/NativeScript/ios-spm (see scripts/generate-spm-manifest.mjs). set -e source "$(dirname "$0")/build_utils.sh" diff --git a/scripts/generate-spm-manifest.mjs b/scripts/generate-spm-manifest.mjs new file mode 100644 index 00000000..106274e1 --- /dev/null +++ b/scripts/generate-spm-manifest.mjs @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// Generate ios-spm/Package.swift for a release. +// +// ensure binaryTarget whose Release asset was never uploaded breaks +// resolution for every consumer of that version. Here, the visionOS +// product/targets are emitted only when the visionOS checksums are provided. +// +// Checksums come from the KEY=sha256 env files produced by +// build_spm_artifacts.sh (one file per platform). The iOS checksums are always +// required. --strict additionally requires the visionOS checksums; pass it for +// every non-"next" release so a real release can never silently ship an +// iOS-only manifest. +// +// Usage: +// node scripts/generate-spm-manifest.mjs \ +// --package /path/to/ios-spm/Package.swift \ +// --version 9.1.0 \ +// --checksums checksums-ios.env [--checksums checksums-visionos.env] \ +// [--strict] +import fs from "node:fs"; + +const args = process.argv.slice(2); +const opts = { checksums: [] }; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--package") opts.package = args[++i]; + else if (a === "--version") opts.version = args[++i]; + else if (a === "--checksums") opts.checksums.push(args[++i]); + else if (a === "--strict") opts.strict = true; + else { + console.error(`Unknown argument: ${a}`); + process.exit(1); + } +} + +if (!opts.package || !opts.version || opts.checksums.length === 0) { + console.error( + "Usage: generate-spm-manifest.mjs --package --version --checksums [--checksums ] [--strict]" + ); + process.exit(1); +} + +// The version is interpolated into Swift source and into a release URL; accept +// semver (with optional prerelease) and nothing else. +const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +if (!VERSION_RE.test(opts.version)) { + console.error(`ERROR: "${opts.version}" is not a valid release version`); + process.exit(1); +} + +const IOS_KEYS = ["NS_CHECKSUM_NATIVESCRIPT_IOS", "NS_CHECKSUM_TKLIVESYNC_IOS"]; +const VISION_KEYS = [ + "NS_CHECKSUM_NATIVESCRIPT_VISIONOS", + "NS_CHECKSUM_TKLIVESYNC_VISIONOS", +]; +const KNOWN_KEYS = new Set([...IOS_KEYS, ...VISION_KEYS]); + +// A binaryTarget checksum must be a 64-char lowercase hex SHA-256. Reject +// anything else now (empty/truncated/uppercase) so we can't emit a manifest +// that resolves to a checksum mismatch later. +const SHA256_RE = /^[0-9a-f]{64}$/; +const checksums = {}; +for (const file of opts.checksums) { + const text = fs.readFileSync(file, "utf8"); + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!key) continue; + if (!KNOWN_KEYS.has(key)) { + console.error(`ERROR: unknown checksum key ${key} in ${file}`); + process.exit(1); + } + if (!SHA256_RE.test(value)) { + console.error( + `ERROR: ${key} in ${file} is not a valid SHA-256 checksum: "${value}"` + ); + process.exit(1); + } + checksums[key] = value; + } +} + +const missingIos = IOS_KEYS.filter((k) => !(k in checksums)); +if (missingIos.length) { + console.error(`ERROR: missing iOS checksums: ${missingIos.join(", ")}`); + process.exit(1); +} + +const presentVision = VISION_KEYS.filter((k) => k in checksums); +if (presentVision.length !== 0 && presentVision.length !== VISION_KEYS.length) { + const missing = VISION_KEYS.filter((k) => !(k in checksums)); + console.error( + `ERROR: partial visionOS checksums — have ${presentVision.join(", ")} but missing ${missing.join(", ")}` + ); + process.exit(1); +} +const includeVision = presentVision.length === VISION_KEYS.length; +if (opts.strict && !includeVision) { + console.error( + "ERROR: --strict requires the visionOS checksums (a non-next release must ship the full manifest)" + ); + process.exit(1); +} + +const visionPlatform = includeVision ? `\n .visionOS(.v1),` : ""; +const visionProduct = includeVision + ? `\n // visionOS family (xros + xrsimulator) + .library(name: "NativeScriptVisionOS", targets: ["NativeScriptVisionOS", "TKLiveSyncVisionOS"]),` + : ""; +const visionTargets = includeVision + ? ` + .binaryTarget( + name: "NativeScriptVisionOS", + url: "\\(releaseBase)/NativeScript.visionos.xcframework.zip", + checksum: "${checksums.NS_CHECKSUM_NATIVESCRIPT_VISIONOS}" + ), + .binaryTarget( + name: "TKLiveSyncVisionOS", + url: "\\(releaseBase)/TKLiveSync.visionos.xcframework.zip", + checksum: "${checksums.NS_CHECKSUM_TKLIVESYNC_VISIONOS}" + ),` + : ""; + +const manifest = `// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// +// GENERATED FILE — DO NOT EDIT BY HAND. +// Emitted per release by scripts/generate-spm-manifest.mjs in +// github.com/NativeScript/ios. The target set mirrors the assets the release +// publishes: the rolling "next" channel builds iOS only, so its manifests omit +// the visionOS product/targets (SwiftPM eagerly downloads every binaryTarget +// in a resolved manifest, and a target without an uploaded asset would break +// resolution for every consumer of that version). +// +// Copyright OpenJS Foundation and other contributors, https://openjsf.org +// +// 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. + +import PackageDescription + +let nsVersion = "${opts.version}" +let releaseBase = "https://github.com/NativeScript/ios/releases/download/v\\(nsVersion)" + +let package = Package( + name: "NativeScriptSDK", + platforms: [ + .iOS(.v13), + .macCatalyst(.v13),${visionPlatform} + ], + products: [ + // iOS family (iphoneos + iphonesimulator + Mac Catalyst) + .library(name: "NativeScript", targets: ["NativeScript", "TKLiveSync"]), + // Backwards-compatible alias for the historical product name. + .library(name: "NativeScriptSDK", targets: ["NativeScript", "TKLiveSync"]),${visionProduct} + ], + dependencies: [], + targets: [ + .binaryTarget( + name: "NativeScript", + url: "\\(releaseBase)/NativeScript.xcframework.zip", + checksum: "${checksums.NS_CHECKSUM_NATIVESCRIPT_IOS}" + ), + .binaryTarget( + name: "TKLiveSync", + url: "\\(releaseBase)/TKLiveSync.xcframework.zip", + checksum: "${checksums.NS_CHECKSUM_TKLIVESYNC_IOS}" + ),${visionTargets} + ] +) +`; + +fs.writeFileSync(opts.package, manifest); + +console.log(`Generated ${opts.package}`); +console.log(` version: ${opts.version}`); +console.log(` targets: iOS${includeVision ? " + visionOS" : " only (no visionOS checksums provided)"}`); diff --git a/scripts/generate-spm-probe.mjs b/scripts/generate-spm-probe.mjs new file mode 100644 index 00000000..2b07e55d --- /dev/null +++ b/scripts/generate-spm-probe.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +// Generate a throwaway SwiftPM package that depends on ios-spm at an exact +// released version — the release workflow's verify-spm job resolves it as a +// post-publish smoke test. +// +// `swift package resolve` on the probe downloads and checksum-verifies every +// binaryTarget in that release's manifest, which is exactly the resolution +// path a consumer's xcodebuild takes on a generated app project. Because the +// released manifest's target set is channel-shaped (see +// generate-spm-manifest.mjs), the SAME probe verifies every channel: "next" +// manifests only declare the iOS artifacts, real releases also declare (and +// therefore also verify) the visionOS ones. +// +// Usage: node scripts/generate-spm-probe.mjs --version [--dir spmverify] +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +const opts = { dir: "spmverify" }; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--version") opts.version = args[++i]; + else if (a === "--dir") opts.dir = args[++i]; + else { + console.error(`Unknown argument: ${a}`); + process.exit(1); + } +} + +if (!opts.version) { + console.error("Usage: generate-spm-probe.mjs --version [--dir ]"); + process.exit(1); +} + +// The version is interpolated into Swift source; accept semver (with optional +// prerelease) and nothing else. +const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +if (!VERSION_RE.test(opts.version)) { + console.error(`ERROR: "${opts.version}" is not a valid release version`); + process.exit(1); +} + +const manifest = `// swift-tools-version: 5.10 +import PackageDescription + +let package = Package( + name: "Probe", + platforms: [.iOS(.v13)], + dependencies: [ + .package(url: "https://github.com/NativeScript/ios-spm.git", exact: "${opts.version}") + ], + targets: [ + .target(name: "Probe", dependencies: [.product(name: "NativeScript", package: "ios-spm")]) + ] +) +`; + +fs.mkdirSync(path.join(opts.dir, "Sources", "Probe"), { recursive: true }); +fs.writeFileSync(path.join(opts.dir, "Package.swift"), manifest); +fs.writeFileSync( + path.join(opts.dir, "Sources", "Probe", "Probe.swift"), + "// probe\n" +); + +console.log(`Generated probe package in ${opts.dir} (ios-spm exact: ${opts.version})`); diff --git a/scripts/stamp-spm-release.mjs b/scripts/stamp-spm-release.mjs deleted file mode 100644 index ed0f022c..00000000 --- a/scripts/stamp-spm-release.mjs +++ /dev/null @@ -1,138 +0,0 @@ -#!/usr/bin/env node -// Stamp the released version + artifact checksums into ios-spm/Package.swift. -// -// The manifest ships with replaceable tokens: -// let nsVersion = "NS_SPM_VERSION" -// checksum: "NS_CHECKSUM_NATIVESCRIPT_IOS" (and TKLIVESYNC_IOS, *_VISIONOS) -// -// This script (run by the release flow against a checkout of NativeScript/ios-spm) -// replaces them with the version being released and the SHA-256 of each uploaded -// xcframework zip. Checksums come from one or more `KEY=sha256` env files produced -// by build_spm_artifacts.sh (one per platform: iOS job + visionOS job). -// -// Usage: -// node scripts/stamp-spm-release.mjs \ -// --package /path/to/ios-spm/Package.swift \ -// --version 9.1.0 \ -// --checksums ios-checksums.env [--checksums vision-checksums.env] \ -// [--strict] -// -// --strict fails if any NS_SPM_VERSION / NS_CHECKSUM_* token is left unstamped -// (use it for a full iOS + visionOS release so a placeholder can never ship). -import fs from "node:fs"; - -const args = process.argv.slice(2); -const opts = { checksums: [] }; -for (let i = 0; i < args.length; i++) { - const a = args[i]; - if (a === "--package") opts.package = args[++i]; - else if (a === "--version") opts.version = args[++i]; - else if (a === "--checksums") opts.checksums.push(args[++i]); - else if (a === "--strict") opts.strict = true; - else { - console.error(`Unknown argument: ${a}`); - process.exit(1); - } -} - -if (!opts.package || !opts.version) { - console.error( - "Usage: stamp-spm-release.mjs --package --version --checksums [--checksums ] [--strict]" - ); - process.exit(1); -} - -const SHA256_RE = /^[0-9a-f]{64}$/; -const checksums = {}; -for (const file of opts.checksums) { - const text = fs.readFileSync(file, "utf8"); - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const eq = trimmed.indexOf("="); - if (eq === -1) continue; - const key = trimmed.slice(0, eq).trim(); - const value = trimmed.slice(eq + 1).trim(); - if (!key) continue; - // A binaryTarget checksum must be a 64-char lowercase hex SHA-256. Reject - // anything else now (empty/truncated/uppercase) so we can't stamp a manifest - // that resolves to a checksum mismatch later. - if (key.startsWith("NS_CHECKSUM_") && !SHA256_RE.test(value)) { - console.error( - `ERROR: ${key} in ${file} is not a valid SHA-256 checksum: "${value}"` - ); - process.exit(1); - } - checksums[key] = value; - } -} - -let manifest = fs.readFileSync(opts.package, "utf8"); - -// Stamping is IDEMPOTENT: each slot is rewritten whether it still holds the -// NS_SPM_VERSION / NS_CHECKSUM_* token or an already-stamped concrete value. -// This matters because the release flow pushes the stamped manifest back to -// `main`, so by the second release there are no tokens left. A one-shot token -// replace would then silently no-op, the "no changes to commit" tag would land -// on the prior commit, and every new tag would resolve to the FIRST release's -// artifacts (the 9.0.4-next.* incident). Anchoring on the structural shape -// instead of the token lets us re-stamp correctly forever. -const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - -// Each checksum env key ↔ the artifact zip its binaryTarget references (the -// pairing baked into the manifest template). Lets us find the right checksum -// slot by URL once the NS_CHECKSUM_* tokens are gone. -const KEY_TO_ARTIFACT = { - NS_CHECKSUM_NATIVESCRIPT_IOS: "NativeScript.xcframework.zip", - NS_CHECKSUM_TKLIVESYNC_IOS: "TKLiveSync.xcframework.zip", - NS_CHECKSUM_NATIVESCRIPT_VISIONOS: "NativeScript.visionos.xcframework.zip", - NS_CHECKSUM_TKLIVESYNC_VISIONOS: "TKLiveSync.visionos.xcframework.zip", -}; - -// Version: rewrite the value inside `let nsVersion = "..."`. -const versionRe = /(let\s+nsVersion\s*=\s*")[^"]*(")/; -if (!versionRe.test(manifest)) { - console.error(`ERROR: could not find 'let nsVersion = "..."' in ${opts.package}`); - process.exit(1); -} -manifest = manifest.replace(versionRe, `$1${opts.version}$2`); - -// Checksums: for each provided checksum, rewrite the value in the binaryTarget -// whose url points at the matching artifact zip. -const applied = []; -for (const [key, value] of Object.entries(checksums)) { - const artifact = KEY_TO_ARTIFACT[key]; - if (!artifact) { - console.error(`ERROR: no artifact mapping for checksum key ${key}; update KEY_TO_ARTIFACT.`); - process.exit(1); - } - const re = new RegExp( - `(url:\\s*"[^"]*${escapeRegExp(artifact)}",\\s*checksum:\\s*")[^"]*(")` - ); - if (!re.test(manifest)) { - console.error(`ERROR: no binaryTarget checksum slot for ${artifact} in ${opts.package}`); - process.exit(1); - } - manifest = manifest.replace(re, `$1${value}$2`); - applied.push(key); -} - -fs.writeFileSync(opts.package, manifest); - -console.log(`Stamped ${opts.package}`); -console.log(` version: ${opts.version}`); -console.log(` checksums applied: ${applied.length ? applied.join(", ") : "(none)"}`); - -// Safety net: no placeholder tokens may remain (they'd resolve to a broken URL -// or a checksum mismatch). With idempotent stamping a leftover token signals a -// real manifest/key drift (e.g. a missing platform's checksums), not a routine -// 2nd-release no-op. -const leftover = [...new Set(manifest.match(/NS_SPM_VERSION|NS_CHECKSUM_[A-Z_]+/g) || [])]; -if (leftover.length) { - const msg = `Unstamped tokens remain: ${leftover.join(", ")}`; - if (opts.strict) { - console.error(`ERROR: ${msg}`); - process.exit(1); - } - console.warn(`WARNING: ${msg}`); -} From 348b4f527e0baa69df5be6e715809be995e016a2 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 10 Jul 2026 15:53:32 -0700 Subject: [PATCH 3/3] ci: opt out of setup-node v6 default dependency caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup-node v6 enables dependency caching by default when it detects a package manager, and this workflow runs on push to main — so the cache is written to the default-branch scope every job can read. A poisoned cache is worst in the jobs that mint the ios-spm App token, hold contents:write, or publish with id-token:write, and the build job feeds npm install output into the shipped artifact. Set package-manager-cache: false on every setup-node step in the release workflow (also restores the pre-v6 no-cache behavior). --- .github/workflows/npm_release.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/npm_release.yml b/.github/workflows/npm_release.yml index bd6d0ad8..8daf97e6 100644 --- a/.github/workflows/npm_release.yml +++ b/.github/workflows/npm_release.yml @@ -54,6 +54,11 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 + # setup-node v6 enables dependency caching by default when it + # detects a package manager — a cache-poisoning vector, worst in the + # jobs that mint App tokens or publish. Disabled on every setup-node + # step in this workflow (also keeps pre-v6 behavior). + package-manager-cache: false - name: Install deps for version scripts run: npm install --no-audit --no-fund - name: Compute version and tag @@ -128,6 +133,7 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 + package-manager-cache: false registry-url: "https://registry.npmjs.org" - name: Install Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -190,6 +196,7 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 + package-manager-cache: false - name: Install Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: @@ -270,6 +277,7 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 + package-manager-cache: false - name: Setup run: npm install --no-audit --no-fund - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -345,6 +353,7 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 + package-manager-cache: false - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: spm-artifacts-* @@ -419,6 +428,7 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 + package-manager-cache: false registry-url: "https://registry.npmjs.org" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -482,6 +492,7 @@ jobs: - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: node-version: 22 + package-manager-cache: false - name: Assert ios-spm manifest pins this release run: | # Guard against the tag pointing at a STALE manifest — e.g. a