Skip to content

Commit 4d45a9e

Browse files
committed
fix(release): shape the ios-spm manifest per channel and verify the real manifest
1 parent e381a4e commit 4d45a9e

5 files changed

Lines changed: 299 additions & 224 deletions

File tree

.github/workflows/npm_release.yml

Lines changed: 37 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ permissions:
2727
# slim npm package only carries the Xcode project template + metadata generator.
2828
#
2929
# Flow: setup → build (ios, visionos) → test → github-release (assets) →
30-
# spm-update (stamp + tag ios-spm) → publish (npm) → verify-spm
30+
# spm-update (generate manifest + tag ios-spm) → publish (npm) → verify-spm
3131
#
3232
# The release half (github-release, spm-update, publish, verify-spm) is gated on
3333
# repo variable ENABLE_SPM_RELEASE. Until it is 'true' (and the ios-spm App
@@ -350,17 +350,24 @@ jobs:
350350
pattern: spm-artifacts-*
351351
path: spm-artifacts
352352
merge-multiple: true
353-
- name: Stamp Package.swift
353+
- name: Generate Package.swift
354354
run: |
355+
# The generator emits the WHOLE manifest, shaped by the checksums it
356+
# is given: "next" builds produce only checksums-ios.env, so the next
357+
# manifest omits the visionOS product/targets whose Release assets
358+
# don't exist (SwiftPM eagerly downloads every binaryTarget in a
359+
# resolved manifest — an unshipped asset 404s for every consumer).
360+
# Real releases pass --strict so a full release can never silently
361+
# ship an iOS-only manifest.
355362
STRICT=""
356-
if [ "$NPM_TAG" = "latest" ]; then STRICT="--strict"; fi
363+
if [ "$NPM_TAG" != "next" ]; then STRICT="--strict"; fi
357364
CHECKSUM_ARGS=""
358365
for f in spm-artifacts/checksums-*.env; do
359366
[ -f "$f" ] && CHECKSUM_ARGS="$CHECKSUM_ARGS --checksums $f"
360367
done
361368
echo "Using checksum files:$CHECKSUM_ARGS"
362369
if [ -z "$CHECKSUM_ARGS" ]; then echo "No checksum files found" >&2; exit 1; fi
363-
node ios/scripts/stamp-spm-release.mjs \
370+
node ios/scripts/generate-spm-manifest.mjs \
364371
--package ios-spm/Package.swift \
365372
--version "$NPM_VERSION" \
366373
$CHECKSUM_ARGS $STRICT
@@ -448,13 +455,12 @@ jobs:
448455
# Post-publish smoke test: resolve ios-spm at the released tag and verify the
449456
# binary artifact downloads + checksum-validate against the real Release.
450457
#
451-
# Channel-aware, mirroring the build/publish matrix: `next` builds iOS only, so
452-
# its Release has no visionos assets. `swift package resolve` eagerly downloads
453-
# EVERY binaryTarget in a resolved package (not just the ones a referenced
454-
# product needs), so resolving the full ios-spm manifest on a `next` version
455-
# would try to fetch the visionos zips and 404. For `next` we therefore probe
456-
# an iOS-only package; only real releases (v* tag / manual version) resolve the
457-
# full manifest and thus also verify the visionos artifacts.
458+
# One probe for every channel, against the REAL released manifest. The
459+
# manifest's target set is channel-shaped by generate-spm-manifest.mjs
460+
# ("next" manifests only declare the iOS targets, because next Releases have
461+
# no visionos assets), so resolving ios-spm at the released tag exercises
462+
# exactly the artifact set a consumer's xcodebuild will download: iOS-only on
463+
# next, iOS + visionos on real releases (v* tag / manual version).
458464
verify-spm:
459465
name: Verify SPM resolution
460466
runs-on: macos-14
@@ -470,95 +476,41 @@ jobs:
470476
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
471477
with:
472478
egress-policy: audit
479+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
480+
with:
481+
persist-credentials: false
473482
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
474483
with:
475484
node-version: 22
476485
- name: Assert ios-spm manifest pins this release
477486
run: |
478-
# Guard against the manifest pointing at a STALE release — e.g. a stamp
479-
# that no-ops and leaves a prior version's nsVersion + artifact
480-
# URLs/checksums in place. Those still resolve and checksum-pass (they're
481-
# internally consistent), so `swift package resolve` below won't catch
482-
# it; this explicit version assertion does.
487+
# Guard against the tag pointing at a STALE manifest — e.g. a
488+
# commit/tag step that silently failed and left a prior version's
489+
# nsVersion + artifact URLs/checksums in place. Those still resolve
490+
# and checksum-pass (they're internally consistent), so `swift
491+
# package resolve` below won't catch it; this explicit version
492+
# assertion does.
483493
url="https://raw.githubusercontent.com/NativeScript/ios-spm/${NPM_VERSION}/Package.swift"
484494
manifest="$(curl -fsSL "$url")"
485495
if ! printf '%s\n' "$manifest" | grep -q "let nsVersion = \"${NPM_VERSION}\""; then
486-
echo "::error::ios-spm@${NPM_VERSION} does not pin nsVersion=\"${NPM_VERSION}\" (stale stamp?). Found:" >&2
496+
echo "::error::ios-spm@${NPM_VERSION} does not pin nsVersion=\"${NPM_VERSION}\" (stale manifest?). Found:" >&2
487497
printf '%s\n' "$manifest" | grep -n "nsVersion" >&2 || true
488498
exit 1
489499
fi
490500
echo "OK: ios-spm@${NPM_VERSION} pins nsVersion=\"${NPM_VERSION}\""
491-
- name: Generate a probe package that depends on ios-spm (real releases)
492-
# Real release (v* tag / manual version): the Release carries every asset,
493-
# so probe the full ios-spm manifest. `swift package resolve` eagerly
494-
# downloads all binaryTargets, exercising the iOS AND visionos artifacts.
495-
if: ${{ needs.setup.outputs.npm_tag != 'next' }}
496-
run: |
497-
node -e '
498-
const fs = require("fs");
499-
const v = process.env.NPM_VERSION;
500-
fs.mkdirSync("spmverify/Sources/Probe", { recursive: true });
501-
fs.writeFileSync("spmverify/Package.swift",
502-
"// swift-tools-version: 5.10\n" +
503-
"import PackageDescription\n" +
504-
"let package = Package(\n" +
505-
" name: \"Probe\",\n" +
506-
" platforms: [.iOS(.v13)],\n" +
507-
" dependencies: [.package(url: \"https://github.com/NativeScript/ios-spm.git\", exact: \"" + v + "\")],\n" +
508-
" targets: [.target(name: \"Probe\", dependencies: [.product(name: \"NativeScript\", package: \"ios-spm\")])]\n" +
509-
")\n");
510-
fs.writeFileSync("spmverify/Sources/Probe/Probe.swift", "// probe\n");
511-
'
512-
- name: Generate an iOS-only probe package (next channel)
513-
# The "next" Release has no visionos assets (build matrix is iOS-only for
514-
# next), and `swift package resolve` would eagerly try to fetch every
515-
# binaryTarget in the full ios-spm manifest -> 404 on the visionos zips.
516-
# So probe an iOS-only package whose binaryTargets are the iOS artifacts
517-
# from the stamped manifest (real Release URLs + real checksums): resolve
518-
# still downloads and checksum-verifies exactly what a next consumer gets,
519-
# and never touches visionos.
520-
if: ${{ needs.setup.outputs.npm_tag == 'next' }}
521-
run: |
522-
url="https://raw.githubusercontent.com/NativeScript/ios-spm/${NPM_VERSION}/Package.swift"
523-
curl -fsSL "$url" -o manifest.swift
524-
node -e '
525-
const fs = require("fs");
526-
const v = process.env.NPM_VERSION;
527-
const body = fs.readFileSync("manifest.swift", "utf8");
528-
// Pull the checksum out of the binaryTarget whose url ends in the given
529-
// artifact zip. The url is a Swift interpolation ("\(base)/<zip>"), so
530-
// match up to the closing quote; anchoring on <zip>" keeps the iOS
531-
// NativeScript slot from matching the visionos one (…visionos.xcframework.zip).
532-
const ck = (artifact) => {
533-
const re = new RegExp("url:\\s*\"[^\"]*" + artifact.replace(/\./g, "\\.") + "\"\\s*,\\s*checksum:\\s*\"([0-9a-f]{64})\"");
534-
const m = body.match(re);
535-
if (!m) { console.error("No iOS checksum for " + artifact + " in ios-spm@" + v); process.exit(1); }
536-
return m[1];
537-
};
538-
const nsCk = ck("NativeScript.xcframework.zip");
539-
const tkCk = ck("TKLiveSync.xcframework.zip");
540-
fs.mkdirSync("spmverify/Sources/Probe", { recursive: true });
541-
fs.writeFileSync("spmverify/Package.swift",
542-
"// swift-tools-version: 5.10\n" +
543-
"import PackageDescription\n" +
544-
"let base = \"https://github.com/NativeScript/ios/releases/download/v" + v + "\"\n" +
545-
"let package = Package(\n" +
546-
" name: \"Probe\",\n" +
547-
" platforms: [.iOS(.v13)],\n" +
548-
" targets: [\n" +
549-
" .binaryTarget(name: \"NativeScript\", url: \"\\(base)/NativeScript.xcframework.zip\", checksum: \"" + nsCk + "\"),\n" +
550-
" .binaryTarget(name: \"TKLiveSync\", url: \"\\(base)/TKLiveSync.xcframework.zip\", checksum: \"" + tkCk + "\"),\n" +
551-
" .target(name: \"Probe\", dependencies: [\"NativeScript\", \"TKLiveSync\"])\n" +
552-
" ]\n" +
553-
")\n");
554-
fs.writeFileSync("spmverify/Sources/Probe/Probe.swift", "// probe\n");
555-
'
501+
- name: Generate a probe package that depends on ios-spm
502+
# The probe pins ios-spm at the exact released version. Resolving it
503+
# takes the same path a consumer's xcodebuild does, and the released
504+
# manifest only declares targets whose assets exist (channel-shaped by
505+
# generate-spm-manifest.mjs) — so this single probe is valid for every
506+
# channel.
507+
run: node scripts/generate-spm-probe.mjs --version "$NPM_VERSION" --dir spmverify
556508
- name: Resolve (downloads the xcframework zips and verifies their checksums)
557509
working-directory: spmverify
558510
run: |
559511
# Resolution fetches each binaryTarget's zip and verifies its SHA-256
560-
# against the stamped checksum; a mismatch or a missing release asset
561-
# fails the release here. For next this covers the iOS artifacts only;
562-
# real releases resolve the full manifest and also cover visionos.
512+
# against the manifest checksum; a mismatch or a missing release asset
513+
# fails the release here. For next the manifest declares the iOS
514+
# artifacts only; real releases also declare (and verify) visionos.
563515
swift package resolve
564516
echo "ios-spm@$NPM_VERSION resolved and checksum-verified."

build_spm_artifacts.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
# Usage: ./build_spm_artifacts.sh [ios|visionos] (default: ios)
1010
#
1111
# These artifacts are uploaded to the GitHub Release and referenced by
12-
# github.com/NativeScript/ios-spm (see scripts/stamp-spm-release.mjs).
12+
# github.com/NativeScript/ios-spm (see scripts/generate-spm-manifest.mjs).
1313
set -e
1414
source "$(dirname "$0")/build_utils.sh"
1515

scripts/generate-spm-manifest.mjs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#!/usr/bin/env node
2+
// Generate ios-spm/Package.swift for a release.
3+
//
4+
// ensure binaryTarget whose Release asset was never uploaded breaks
5+
// resolution for every consumer of that version. Here, the visionOS
6+
// product/targets are emitted only when the visionOS checksums are provided.
7+
//
8+
// Checksums come from the KEY=sha256 env files produced by
9+
// build_spm_artifacts.sh (one file per platform). The iOS checksums are always
10+
// required. --strict additionally requires the visionOS checksums; pass it for
11+
// every non-"next" release so a real release can never silently ship an
12+
// iOS-only manifest.
13+
//
14+
// Usage:
15+
// node scripts/generate-spm-manifest.mjs \
16+
// --package /path/to/ios-spm/Package.swift \
17+
// --version 9.1.0 \
18+
// --checksums checksums-ios.env [--checksums checksums-visionos.env] \
19+
// [--strict]
20+
import fs from "node:fs";
21+
22+
const args = process.argv.slice(2);
23+
const opts = { checksums: [] };
24+
for (let i = 0; i < args.length; i++) {
25+
const a = args[i];
26+
if (a === "--package") opts.package = args[++i];
27+
else if (a === "--version") opts.version = args[++i];
28+
else if (a === "--checksums") opts.checksums.push(args[++i]);
29+
else if (a === "--strict") opts.strict = true;
30+
else {
31+
console.error(`Unknown argument: ${a}`);
32+
process.exit(1);
33+
}
34+
}
35+
36+
if (!opts.package || !opts.version || opts.checksums.length === 0) {
37+
console.error(
38+
"Usage: generate-spm-manifest.mjs --package <Package.swift> --version <v> --checksums <file> [--checksums <file>] [--strict]"
39+
);
40+
process.exit(1);
41+
}
42+
43+
// The version is interpolated into Swift source and into a release URL; accept
44+
// semver (with optional prerelease) and nothing else.
45+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
46+
if (!VERSION_RE.test(opts.version)) {
47+
console.error(`ERROR: "${opts.version}" is not a valid release version`);
48+
process.exit(1);
49+
}
50+
51+
const IOS_KEYS = ["NS_CHECKSUM_NATIVESCRIPT_IOS", "NS_CHECKSUM_TKLIVESYNC_IOS"];
52+
const VISION_KEYS = [
53+
"NS_CHECKSUM_NATIVESCRIPT_VISIONOS",
54+
"NS_CHECKSUM_TKLIVESYNC_VISIONOS",
55+
];
56+
const KNOWN_KEYS = new Set([...IOS_KEYS, ...VISION_KEYS]);
57+
58+
// A binaryTarget checksum must be a 64-char lowercase hex SHA-256. Reject
59+
// anything else now (empty/truncated/uppercase) so we can't emit a manifest
60+
// that resolves to a checksum mismatch later.
61+
const SHA256_RE = /^[0-9a-f]{64}$/;
62+
const checksums = {};
63+
for (const file of opts.checksums) {
64+
const text = fs.readFileSync(file, "utf8");
65+
for (const line of text.split("\n")) {
66+
const trimmed = line.trim();
67+
if (!trimmed || trimmed.startsWith("#")) continue;
68+
const eq = trimmed.indexOf("=");
69+
if (eq === -1) continue;
70+
const key = trimmed.slice(0, eq).trim();
71+
const value = trimmed.slice(eq + 1).trim();
72+
if (!key) continue;
73+
if (!KNOWN_KEYS.has(key)) {
74+
console.error(`ERROR: unknown checksum key ${key} in ${file}`);
75+
process.exit(1);
76+
}
77+
if (!SHA256_RE.test(value)) {
78+
console.error(
79+
`ERROR: ${key} in ${file} is not a valid SHA-256 checksum: "${value}"`
80+
);
81+
process.exit(1);
82+
}
83+
checksums[key] = value;
84+
}
85+
}
86+
87+
const missingIos = IOS_KEYS.filter((k) => !(k in checksums));
88+
if (missingIos.length) {
89+
console.error(`ERROR: missing iOS checksums: ${missingIos.join(", ")}`);
90+
process.exit(1);
91+
}
92+
93+
const presentVision = VISION_KEYS.filter((k) => k in checksums);
94+
if (presentVision.length !== 0 && presentVision.length !== VISION_KEYS.length) {
95+
const missing = VISION_KEYS.filter((k) => !(k in checksums));
96+
console.error(
97+
`ERROR: partial visionOS checksums — have ${presentVision.join(", ")} but missing ${missing.join(", ")}`
98+
);
99+
process.exit(1);
100+
}
101+
const includeVision = presentVision.length === VISION_KEYS.length;
102+
if (opts.strict && !includeVision) {
103+
console.error(
104+
"ERROR: --strict requires the visionOS checksums (a non-next release must ship the full manifest)"
105+
);
106+
process.exit(1);
107+
}
108+
109+
const visionPlatform = includeVision ? `\n .visionOS(.v1),` : "";
110+
const visionProduct = includeVision
111+
? `\n // visionOS family (xros + xrsimulator)
112+
.library(name: "NativeScriptVisionOS", targets: ["NativeScriptVisionOS", "TKLiveSyncVisionOS"]),`
113+
: "";
114+
const visionTargets = includeVision
115+
? `
116+
.binaryTarget(
117+
name: "NativeScriptVisionOS",
118+
url: "\\(releaseBase)/NativeScript.visionos.xcframework.zip",
119+
checksum: "${checksums.NS_CHECKSUM_NATIVESCRIPT_VISIONOS}"
120+
),
121+
.binaryTarget(
122+
name: "TKLiveSyncVisionOS",
123+
url: "\\(releaseBase)/TKLiveSync.visionos.xcframework.zip",
124+
checksum: "${checksums.NS_CHECKSUM_TKLIVESYNC_VISIONOS}"
125+
),`
126+
: "";
127+
128+
const manifest = `// swift-tools-version: 5.10
129+
// The swift-tools-version declares the minimum version of Swift required to build this package.
130+
//
131+
// GENERATED FILE — DO NOT EDIT BY HAND.
132+
// Emitted per release by scripts/generate-spm-manifest.mjs in
133+
// github.com/NativeScript/ios. The target set mirrors the assets the release
134+
// publishes: the rolling "next" channel builds iOS only, so its manifests omit
135+
// the visionOS product/targets (SwiftPM eagerly downloads every binaryTarget
136+
// in a resolved manifest, and a target without an uploaded asset would break
137+
// resolution for every consumer of that version).
138+
//
139+
// Copyright OpenJS Foundation and other contributors, https://openjsf.org
140+
//
141+
// Permission is hereby granted, free of charge, to any person obtaining a copy
142+
// of this software and associated documentation files (the "Software"), to deal
143+
// in the Software without restriction, including without limitation the rights
144+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
145+
// copies of the Software, and to permit persons to whom the Software is
146+
// furnished to do so, subject to the following conditions:
147+
//
148+
// The above copyright notice and this permission notice shall be included in
149+
// all copies or substantial portions of the Software.
150+
//
151+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
152+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
153+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
154+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
155+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
156+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
157+
// THE SOFTWARE.
158+
159+
import PackageDescription
160+
161+
let nsVersion = "${opts.version}"
162+
let releaseBase = "https://github.com/NativeScript/ios/releases/download/v\\(nsVersion)"
163+
164+
let package = Package(
165+
name: "NativeScriptSDK",
166+
platforms: [
167+
.iOS(.v13),
168+
.macCatalyst(.v13),${visionPlatform}
169+
],
170+
products: [
171+
// iOS family (iphoneos + iphonesimulator + Mac Catalyst)
172+
.library(name: "NativeScript", targets: ["NativeScript", "TKLiveSync"]),
173+
// Backwards-compatible alias for the historical product name.
174+
.library(name: "NativeScriptSDK", targets: ["NativeScript", "TKLiveSync"]),${visionProduct}
175+
],
176+
dependencies: [],
177+
targets: [
178+
.binaryTarget(
179+
name: "NativeScript",
180+
url: "\\(releaseBase)/NativeScript.xcframework.zip",
181+
checksum: "${checksums.NS_CHECKSUM_NATIVESCRIPT_IOS}"
182+
),
183+
.binaryTarget(
184+
name: "TKLiveSync",
185+
url: "\\(releaseBase)/TKLiveSync.xcframework.zip",
186+
checksum: "${checksums.NS_CHECKSUM_TKLIVESYNC_IOS}"
187+
),${visionTargets}
188+
]
189+
)
190+
`;
191+
192+
fs.writeFileSync(opts.package, manifest);
193+
194+
console.log(`Generated ${opts.package}`);
195+
console.log(` version: ${opts.version}`);
196+
console.log(` targets: iOS${includeVision ? " + visionOS" : " only (no visionOS checksums provided)"}`);

0 commit comments

Comments
 (0)