@synopackageland/cli 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +3 -5
- package/dist/build-spk/broker-glue.d.ts +16 -0
- package/dist/build-spk/broker-glue.js +40 -0
- package/dist/build-spk/delete-app-data.d.ts +11 -0
- package/dist/build-spk/delete-app-data.js +56 -0
- package/dist/build-spk/generator.js +123 -70
- package/dist/build-spk/native-lifecycle.d.ts +51 -0
- package/dist/build-spk/native-lifecycle.js +205 -0
- package/dist/build-spk/native-payload.d.ts +15 -0
- package/dist/build-spk/native-payload.js +65 -39
- package/dist/cli.js +47 -8
- package/dist/deploy/deploy.d.ts +3 -0
- package/dist/deploy/deploy.js +13 -0
- package/dist/discovery.d.ts +2 -0
- package/dist/discovery.js +2 -0
- package/dist/host/digests.js +9 -1
- package/dist/host/dsm-client.d.ts +7 -0
- package/dist/host/dsm-client.js +25 -0
- package/dist/host/types.d.ts +6 -0
- package/dist/init.js +1 -0
- package/dist/native/load.d.ts +8 -0
- package/dist/native/load.js +11 -0
- package/dist/native/types.d.ts +28 -0
- package/dist/native/types.js +3 -0
- package/dist/native/validate.d.ts +6 -0
- package/dist/native/validate.js +144 -0
- package/dist/publish/curated-release.js +277 -91
- package/dist/publish/preflight.d.ts +24 -0
- package/dist/publish/preflight.js +116 -0
- package/dist/publish/publish.d.ts +4 -0
- package/dist/publish/publish.js +13 -5
- package/dist/release-bin.d.ts +2 -0
- package/dist/release-bin.js +19 -0
- package/dist/templates/native/native.yaml +16 -0
- package/dist/templates/native/synology-app.yaml +7 -0
- package/dist/types.d.ts +1 -1
- package/dist/validate.js +37 -4
- package/package.json +9 -8
- package/skills/reference/diagnose.md +15 -1
- package/templates/native/native.yaml +16 -0
- package/templates/native/synology-app.yaml +7 -0
- package/dist/deploy/package-source.d.ts +0 -7
- package/dist/deploy/package-source.js +0 -17
- package/dist/deploy/spk-info.d.ts +0 -12
- package/dist/deploy/spk-info.js +0 -41
- package/dist/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
- package/dist/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
- package/templates/hello-files/com.synology.hello.files.dev.spk +0 -0
- package/templates/hello-web-page/com.synology.hello.web.page.dev.spk +0 -0
|
@@ -10,6 +10,8 @@ import { digestAppPackageClaims, digestCuratedReleaseClaims, normalizeCapabiliti
|
|
|
10
10
|
import { CuratedTrustRootError, loadCuratedTrustRoot, resolveCuratedSigningTrust, } from "../attestation/keyring.js";
|
|
11
11
|
import { loadContract } from "../contract/load.js";
|
|
12
12
|
import { loadCompose } from "../compose/analyze.js";
|
|
13
|
+
import { loadNative } from "../native/load.js";
|
|
14
|
+
import { infoArchValue, isNativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
13
15
|
import { EMBEDDED_ATTESTATION_RELATIVE_PATH, buildSpk, extractSpk, } from "../build-spk/generator.js";
|
|
14
16
|
import { findProjectRoot } from "../discovery.js";
|
|
15
17
|
import { validateProject } from "../validate.js";
|
|
@@ -112,95 +114,172 @@ export async function releaseCurated(options) {
|
|
|
112
114
|
const workspace = mkdtempSync(join(tmpdir(), "synopkgland-curated-release-"));
|
|
113
115
|
try {
|
|
114
116
|
const releaseProject = copyReleaseProject(options.project, workspace, packageIdentity);
|
|
115
|
-
const outputPath = join(workspace, `${packageIdentity}.spk`);
|
|
116
117
|
const buildFn = options.buildSpk ?? buildSpk;
|
|
117
|
-
const built = await buildFn({
|
|
118
|
-
project: releaseProject,
|
|
119
|
-
outputPath,
|
|
120
|
-
packageIdentity,
|
|
121
|
-
displayname: prepared.displayname,
|
|
122
|
-
spkVersion: prepared.version,
|
|
123
|
-
embeddedAttestationToken: embedded.token,
|
|
124
|
-
});
|
|
125
|
-
if (!built.ok || !built.outputPath) {
|
|
126
|
-
throw new CuratedReleaseError("CURATED_BUILD_FAILED");
|
|
127
|
-
}
|
|
128
|
-
await assertEmbeddedAttestationInSpk(built.outputPath, embedded.token, embeddedInput, options.signer);
|
|
129
|
-
const artifact = digestArtifact(built.outputPath);
|
|
130
118
|
if (!options.signer.signRelease) {
|
|
131
119
|
throw new CuratedReleaseError("CURATED_RELEASE_SIGNER_MISSING");
|
|
132
120
|
}
|
|
133
|
-
const detachedInput = {
|
|
134
|
-
packageIdentity,
|
|
135
|
-
packageVersion: prepared.version,
|
|
136
|
-
packageBuild: options.build,
|
|
137
|
-
artifactDigest: artifact.sha256,
|
|
138
|
-
embeddedAttestationDigest: embedded.attestationDigest,
|
|
139
|
-
};
|
|
140
|
-
let detached;
|
|
141
|
-
try {
|
|
142
|
-
detached = await options.signer.signRelease(detachedInput);
|
|
143
|
-
}
|
|
144
|
-
catch (error) {
|
|
145
|
-
if (error instanceof CuratedReleaseError) {
|
|
146
|
-
throw error;
|
|
147
|
-
}
|
|
148
|
-
throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
|
|
149
|
-
}
|
|
150
|
-
assertDetachedAttestation(detached, detachedInput);
|
|
151
|
-
await verifySignedToken(options.signer, detached.token, "release");
|
|
152
|
-
const artifactKey = buildImmutableSpkKey({
|
|
153
|
-
package: packageIdentity,
|
|
154
|
-
version: prepared.version,
|
|
155
|
-
sha256: artifact.sha256,
|
|
156
|
-
});
|
|
157
|
-
const attestationKey = buildImmutableAttestationKey({
|
|
158
|
-
package: packageIdentity,
|
|
159
|
-
version: prepared.version,
|
|
160
|
-
artifactDigest: artifact.sha256,
|
|
161
|
-
});
|
|
162
|
-
const tokenSha256 = digestBytes(Buffer.from(detached.token, "utf8"));
|
|
163
121
|
if (!options.signer.verificationJwk) {
|
|
164
122
|
throw new CuratedReleaseError("CURATED_ATTESTATION_SIGNATURE_INVALID");
|
|
165
123
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
124
|
+
if (!prepared.isNative) {
|
|
125
|
+
// Compose single-artifact release
|
|
126
|
+
const outputPath = join(workspace, `${packageIdentity}.spk`);
|
|
127
|
+
const built = await buildFn({
|
|
128
|
+
project: releaseProject,
|
|
129
|
+
outputPath,
|
|
130
|
+
packageIdentity,
|
|
131
|
+
displayname: prepared.displayname,
|
|
132
|
+
spkVersion: prepared.version,
|
|
133
|
+
embeddedAttestationToken: embedded.token,
|
|
134
|
+
});
|
|
135
|
+
if (!built.ok || !built.outputPath) {
|
|
136
|
+
throw new CuratedReleaseError("CURATED_BUILD_FAILED");
|
|
137
|
+
}
|
|
138
|
+
await assertEmbeddedAttestationInSpk(built.outputPath, embedded.token, embeddedInput, options.signer);
|
|
139
|
+
const artifact = digestArtifact(built.outputPath);
|
|
140
|
+
const detachedInput = {
|
|
141
|
+
packageIdentity,
|
|
142
|
+
packageVersion: prepared.version,
|
|
143
|
+
packageBuild: options.build,
|
|
144
|
+
artifactDigest: artifact.sha256,
|
|
145
|
+
embeddedAttestationDigest: embedded.attestationDigest,
|
|
146
|
+
};
|
|
147
|
+
let detached;
|
|
148
|
+
try {
|
|
149
|
+
detached = await options.signer.signRelease(detachedInput);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (error instanceof CuratedReleaseError) {
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
|
|
156
|
+
}
|
|
157
|
+
assertDetachedAttestation(detached, detachedInput);
|
|
158
|
+
await verifySignedToken(options.signer, detached.token, "release");
|
|
159
|
+
const artifactKey = buildImmutableSpkKey({
|
|
160
|
+
package: packageIdentity,
|
|
161
|
+
version: prepared.version,
|
|
189
162
|
sha256: artifact.sha256,
|
|
190
|
-
}
|
|
191
|
-
|
|
163
|
+
});
|
|
164
|
+
const attestationKey = buildImmutableAttestationKey({
|
|
165
|
+
package: packageIdentity,
|
|
166
|
+
version: prepared.version,
|
|
167
|
+
artifactDigest: artifact.sha256,
|
|
168
|
+
});
|
|
169
|
+
const tokenSha256 = digestBytes(Buffer.from(detached.token, "utf8"));
|
|
170
|
+
const attestation = {
|
|
171
|
+
packageId: packageIdentity,
|
|
172
|
+
version: prepared.version,
|
|
173
|
+
packageBuild: options.build,
|
|
174
|
+
artifactDigest: artifact.sha256,
|
|
175
|
+
embeddedAttestationDigest: embedded.attestationDigest,
|
|
192
176
|
key: attestationKey,
|
|
177
|
+
token: detached.token,
|
|
193
178
|
digest: detached.attestationDigest,
|
|
179
|
+
tokenSha256,
|
|
180
|
+
verificationJwk: options.signer.verificationJwk,
|
|
181
|
+
};
|
|
182
|
+
const index = {
|
|
183
|
+
packageId: packageIdentity,
|
|
184
|
+
version: prepared.version,
|
|
185
|
+
displayname: prepared.displayname,
|
|
186
|
+
description: prepared.description,
|
|
187
|
+
...(prepared.maintainer ? { maintainer: prepared.maintainer } : {}),
|
|
188
|
+
osMinVer: prepared.osMinVer,
|
|
189
|
+
artifact: {
|
|
190
|
+
key: artifactKey,
|
|
191
|
+
size: artifact.size,
|
|
192
|
+
md5: artifact.md5,
|
|
193
|
+
sha256: artifact.sha256,
|
|
194
|
+
},
|
|
195
|
+
attestation: {
|
|
196
|
+
key: attestationKey,
|
|
197
|
+
digest: detached.attestationDigest,
|
|
198
|
+
packageBuild: options.build,
|
|
199
|
+
artifactDigest: artifact.sha256,
|
|
200
|
+
embeddedAttestationDigest: embedded.attestationDigest,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
// This is the only transition to external publication. Both tokens have
|
|
204
|
+
// been claim-checked, signature-checked when the signer supplies a key,
|
|
205
|
+
// and the embedded token was read back from the final SPK first.
|
|
206
|
+
await publishCuratedRelease({
|
|
207
|
+
artifact: {
|
|
208
|
+
packageId: packageIdentity,
|
|
209
|
+
version: prepared.version,
|
|
210
|
+
path: built.outputPath,
|
|
211
|
+
key: artifactKey,
|
|
212
|
+
size: artifact.size,
|
|
213
|
+
md5: artifact.md5,
|
|
214
|
+
sha256: artifact.sha256,
|
|
215
|
+
},
|
|
216
|
+
attestation,
|
|
217
|
+
index,
|
|
218
|
+
}, options.publisher);
|
|
219
|
+
return {
|
|
220
|
+
packageIdentity,
|
|
194
221
|
packageBuild: options.build,
|
|
222
|
+
version: prepared.version,
|
|
223
|
+
artifactKey,
|
|
195
224
|
artifactDigest: artifact.sha256,
|
|
225
|
+
attestationKey,
|
|
196
226
|
embeddedAttestationDigest: embedded.attestationDigest,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
227
|
+
attestationDigest: detached.attestationDigest,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
// Native multi-arch release (1 Build -> N artifacts)
|
|
231
|
+
const targetFamilies = Object.keys(prepared.native?.targets ?? {}).filter(isNativeArchFamily);
|
|
232
|
+
if (targetFamilies.length === 0) {
|
|
233
|
+
throw new CuratedReleaseError("CURATED_NATIVE_TARGETS_EMPTY");
|
|
234
|
+
}
|
|
235
|
+
const builtArches = [];
|
|
236
|
+
for (const family of targetFamilies) {
|
|
237
|
+
const outputPath = join(workspace, `${packageIdentity}-${family}.spk`);
|
|
238
|
+
const built = await buildFn({
|
|
239
|
+
project: releaseProject,
|
|
240
|
+
outputPath,
|
|
241
|
+
packageIdentity,
|
|
242
|
+
displayname: prepared.displayname,
|
|
243
|
+
spkVersion: prepared.version,
|
|
244
|
+
embeddedAttestationToken: embedded.token,
|
|
245
|
+
archFamily: family,
|
|
246
|
+
});
|
|
247
|
+
if (!built.ok || !built.outputPath) {
|
|
248
|
+
throw new CuratedReleaseError("CURATED_BUILD_FAILED");
|
|
249
|
+
}
|
|
250
|
+
await assertEmbeddedAttestationInSpk(built.outputPath, embedded.token, embeddedInput, options.signer);
|
|
251
|
+
const artifact = digestArtifact(built.outputPath);
|
|
252
|
+
const detachedInput = {
|
|
253
|
+
packageIdentity,
|
|
254
|
+
packageVersion: prepared.version,
|
|
255
|
+
packageBuild: options.build,
|
|
256
|
+
artifactDigest: artifact.sha256,
|
|
257
|
+
embeddedAttestationDigest: embedded.attestationDigest,
|
|
258
|
+
};
|
|
259
|
+
let detached;
|
|
260
|
+
try {
|
|
261
|
+
detached = await options.signer.signRelease(detachedInput);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (error instanceof CuratedReleaseError) {
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
throw new CuratedReleaseError("CURATED_RELEASE_ATTESTATION_BINDING_FAILED");
|
|
268
|
+
}
|
|
269
|
+
assertDetachedAttestation(detached, detachedInput);
|
|
270
|
+
await verifySignedToken(options.signer, detached.token, "release");
|
|
271
|
+
const artifactKey = buildImmutableSpkKey({
|
|
272
|
+
package: packageIdentity,
|
|
273
|
+
version: prepared.version,
|
|
274
|
+
sha256: artifact.sha256,
|
|
275
|
+
});
|
|
276
|
+
const attestationKey = buildImmutableAttestationKey({
|
|
277
|
+
package: packageIdentity,
|
|
278
|
+
version: prepared.version,
|
|
279
|
+
artifactDigest: artifact.sha256,
|
|
280
|
+
});
|
|
281
|
+
const tokenSha256 = digestBytes(Buffer.from(detached.token, "utf8"));
|
|
282
|
+
const releaseArtifact = {
|
|
204
283
|
packageId: packageIdentity,
|
|
205
284
|
version: prepared.version,
|
|
206
285
|
path: built.outputPath,
|
|
@@ -208,19 +287,89 @@ export async function releaseCurated(options) {
|
|
|
208
287
|
size: artifact.size,
|
|
209
288
|
md5: artifact.md5,
|
|
210
289
|
sha256: artifact.sha256,
|
|
290
|
+
};
|
|
291
|
+
const releaseAttestation = {
|
|
292
|
+
packageId: packageIdentity,
|
|
293
|
+
version: prepared.version,
|
|
294
|
+
packageBuild: options.build,
|
|
295
|
+
artifactDigest: artifact.sha256,
|
|
296
|
+
embeddedAttestationDigest: embedded.attestationDigest,
|
|
297
|
+
key: attestationKey,
|
|
298
|
+
token: detached.token,
|
|
299
|
+
digest: detached.attestationDigest,
|
|
300
|
+
tokenSha256,
|
|
301
|
+
verificationJwk: options.signer.verificationJwk,
|
|
302
|
+
};
|
|
303
|
+
const archPlatforms = infoArchValue(family).split(" ");
|
|
304
|
+
const archArtifact = {
|
|
305
|
+
key: artifactKey,
|
|
306
|
+
size: artifact.size,
|
|
307
|
+
md5: artifact.md5,
|
|
308
|
+
sha256: artifact.sha256,
|
|
309
|
+
arch: archPlatforms,
|
|
310
|
+
attestation: {
|
|
311
|
+
key: attestationKey,
|
|
312
|
+
digest: detached.attestationDigest,
|
|
313
|
+
packageBuild: options.build,
|
|
314
|
+
artifactDigest: artifact.sha256,
|
|
315
|
+
embeddedAttestationDigest: embedded.attestationDigest,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
builtArches.push({
|
|
319
|
+
family,
|
|
320
|
+
outputPath: built.outputPath,
|
|
321
|
+
artifact,
|
|
322
|
+
artifactKey,
|
|
323
|
+
attestationKey,
|
|
324
|
+
detached,
|
|
325
|
+
tokenSha256,
|
|
326
|
+
releaseArtifact,
|
|
327
|
+
releaseAttestation,
|
|
328
|
+
archArtifact,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
const primary = builtArches[0];
|
|
332
|
+
const allArchs = targetFamilies.flatMap((family) => infoArchValue(family).split(" "));
|
|
333
|
+
const indexPayload = {
|
|
334
|
+
packageId: packageIdentity,
|
|
335
|
+
version: prepared.version,
|
|
336
|
+
displayname: prepared.displayname,
|
|
337
|
+
description: prepared.description,
|
|
338
|
+
...(prepared.maintainer ? { maintainer: prepared.maintainer } : {}),
|
|
339
|
+
osMinVer: prepared.osMinVer,
|
|
340
|
+
arch: allArchs,
|
|
341
|
+
artifact: {
|
|
342
|
+
key: primary.artifactKey,
|
|
343
|
+
size: primary.artifact.size,
|
|
344
|
+
md5: primary.artifact.md5,
|
|
345
|
+
sha256: primary.artifact.sha256,
|
|
346
|
+
},
|
|
347
|
+
attestation: {
|
|
348
|
+
key: primary.attestationKey,
|
|
349
|
+
digest: primary.detached.attestationDigest,
|
|
350
|
+
packageBuild: options.build,
|
|
351
|
+
artifactDigest: primary.artifact.sha256,
|
|
352
|
+
embeddedAttestationDigest: embedded.attestationDigest,
|
|
211
353
|
},
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
354
|
+
artifacts: builtArches.map((item) => item.archArtifact),
|
|
355
|
+
};
|
|
356
|
+
// 5. Publication order: all artifacts -> all detached attestations -> index
|
|
357
|
+
for (const item of builtArches) {
|
|
358
|
+
await options.publisher.uploadArtifact(item.releaseArtifact);
|
|
359
|
+
}
|
|
360
|
+
for (const item of builtArches) {
|
|
361
|
+
await options.publisher.uploadAttestation(item.releaseAttestation);
|
|
362
|
+
}
|
|
363
|
+
await options.publisher.updateIndex(indexPayload);
|
|
215
364
|
return {
|
|
216
365
|
packageIdentity,
|
|
217
366
|
packageBuild: options.build,
|
|
218
367
|
version: prepared.version,
|
|
219
|
-
artifactKey,
|
|
220
|
-
artifactDigest: artifact.sha256,
|
|
221
|
-
attestationKey,
|
|
368
|
+
artifactKey: primary.artifactKey,
|
|
369
|
+
artifactDigest: primary.artifact.sha256,
|
|
370
|
+
attestationKey: primary.attestationKey,
|
|
222
371
|
embeddedAttestationDigest: embedded.attestationDigest,
|
|
223
|
-
attestationDigest: detached.attestationDigest,
|
|
372
|
+
attestationDigest: primary.detached.attestationDigest,
|
|
224
373
|
};
|
|
225
374
|
}
|
|
226
375
|
finally {
|
|
@@ -352,6 +501,25 @@ function prepareRelease(project, build) {
|
|
|
352
501
|
if (!validation.ok) {
|
|
353
502
|
throw new CuratedReleaseError("CURATED_VALIDATION_FAILED", validation.errors.map((error) => error.code).join(","));
|
|
354
503
|
}
|
|
504
|
+
const nativePath = project.nativePath ?? join(project.root, "native.yaml");
|
|
505
|
+
const isNative = existsSync(nativePath);
|
|
506
|
+
if (isNative) {
|
|
507
|
+
const parsedNative = loadNative(nativePath);
|
|
508
|
+
if (!parsedNative) {
|
|
509
|
+
throw new CuratedReleaseError("CURATED_NATIVE_MISSING");
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
isNative: true,
|
|
513
|
+
contract,
|
|
514
|
+
native: parsedNative.native,
|
|
515
|
+
packageIdentity,
|
|
516
|
+
version,
|
|
517
|
+
displayname: String(contract.displayname),
|
|
518
|
+
description: String(contract.description),
|
|
519
|
+
maintainer: typeof contract.maintainer === "string" ? contract.maintainer : undefined,
|
|
520
|
+
osMinVer: String(contract.os_min_ver),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
355
523
|
const parsedCompose = loadCompose(project.composePath);
|
|
356
524
|
if (!parsedCompose) {
|
|
357
525
|
throw new CuratedReleaseError("CURATED_COMPOSE_MISSING");
|
|
@@ -361,6 +529,7 @@ function prepareRelease(project, build) {
|
|
|
361
529
|
throw new CuratedReleaseError("CURATED_COMPOSE_GATE_FAILED", composeGate.errors.join(","));
|
|
362
530
|
}
|
|
363
531
|
return {
|
|
532
|
+
isNative: false,
|
|
364
533
|
contract,
|
|
365
534
|
compose: parsedCompose.compose,
|
|
366
535
|
packageIdentity,
|
|
@@ -376,15 +545,22 @@ function copyReleaseProject(source, workspace, packageIdentity) {
|
|
|
376
545
|
mkdirSync(root, { recursive: true });
|
|
377
546
|
const contractPath = join(root, "synology-app.yaml");
|
|
378
547
|
const composePath = join(root, "compose.yaml");
|
|
548
|
+
const nativePath = join(root, "native.yaml");
|
|
379
549
|
writeFileSync(contractPath, readFileSync(source.contractPath));
|
|
380
|
-
|
|
550
|
+
if (existsSync(source.composePath)) {
|
|
551
|
+
writeFileSync(composePath, readFileSync(source.composePath));
|
|
552
|
+
}
|
|
553
|
+
const srcNative = source.nativePath ?? join(source.root, "native.yaml");
|
|
554
|
+
if (existsSync(srcNative)) {
|
|
555
|
+
writeFileSync(nativePath, readFileSync(srcNative));
|
|
556
|
+
}
|
|
381
557
|
for (const name of ["icon.png", "README.md", "html"]) {
|
|
382
558
|
const sourcePath = join(source.root, name);
|
|
383
559
|
if (existsSync(sourcePath)) {
|
|
384
560
|
cpSync(sourcePath, join(root, name), { recursive: true });
|
|
385
561
|
}
|
|
386
562
|
}
|
|
387
|
-
return { root, contractPath, composePath };
|
|
563
|
+
return { root, contractPath, composePath, nativePath };
|
|
388
564
|
}
|
|
389
565
|
function digestArtifact(path) {
|
|
390
566
|
const bytes = readFileSync(path);
|
|
@@ -403,11 +579,21 @@ function digestFile(path) {
|
|
|
403
579
|
*/
|
|
404
580
|
export function digestReleaseBundle(project) {
|
|
405
581
|
const files = [];
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
582
|
+
if (!existsSync(project.contractPath) || !statSync(project.contractPath).isFile()) {
|
|
583
|
+
throw new CuratedReleaseError("CURATED_BUNDLE_FILE_MISSING", "synology-app.yaml");
|
|
584
|
+
}
|
|
585
|
+
files.push("synology-app.yaml");
|
|
586
|
+
const hasCompose = existsSync(project.composePath) && statSync(project.composePath).isFile();
|
|
587
|
+
const nativePath = project.nativePath ?? join(project.root, "native.yaml");
|
|
588
|
+
const hasNative = existsSync(nativePath) && statSync(nativePath).isFile();
|
|
589
|
+
if (!hasCompose && !hasNative) {
|
|
590
|
+
throw new CuratedReleaseError("CURATED_BUNDLE_FILE_MISSING", "compose.yaml");
|
|
591
|
+
}
|
|
592
|
+
if (hasCompose) {
|
|
593
|
+
files.push("compose.yaml");
|
|
594
|
+
}
|
|
595
|
+
if (hasNative) {
|
|
596
|
+
files.push("native.yaml");
|
|
411
597
|
}
|
|
412
598
|
const htmlRoot = join(project.root, "html");
|
|
413
599
|
if (existsSync(htmlRoot)) {
|
|
@@ -453,7 +639,7 @@ function collectCapabilities(contract, compose) {
|
|
|
453
639
|
capabilities.push(`install.${name}:${parameter.type}`);
|
|
454
640
|
}
|
|
455
641
|
}
|
|
456
|
-
if (Object.values(compose.services ?? {}).some((service) => service.privileged === true)) {
|
|
642
|
+
if (compose && Object.values(compose.services ?? {}).some((service) => service.privileged === true)) {
|
|
457
643
|
capabilities.push("compose.privileged");
|
|
458
644
|
}
|
|
459
645
|
return normalizeCapabilities(capabilities);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ProjectRoot } from "../discovery.js";
|
|
2
|
+
export interface PreflightCheck {
|
|
3
|
+
/** Stable id so scripts and docs can refer to a specific gate. */
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
detail?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PreflightResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
checks: PreflightCheck[];
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Every gate for a public release that can be decided on the developer's own
|
|
15
|
+
* machine. Submitting runs these first so a definition that CI would reject
|
|
16
|
+
* never reaches review; `--check` runs the same set and stops.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately excludes anything needing release authority: Build allocation,
|
|
19
|
+
* attestation signing and catalog publication belong to trusted CI running
|
|
20
|
+
* from the merged revision, and no local run can stand in for a maintainer's
|
|
21
|
+
* approval.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runPublicReleasePreflight(project: ProjectRoot): Promise<PreflightResult>;
|
|
24
|
+
export declare function formatPreflight(result: PreflightResult): string;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isDevelopmentPackageIdentity } from "@synopackageland/package-source/index-manifest";
|
|
5
|
+
import { buildSpk } from "../build-spk/generator.js";
|
|
6
|
+
import { loadCompose } from "../compose/analyze.js";
|
|
7
|
+
import { loadContract } from "../contract/load.js";
|
|
8
|
+
import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
9
|
+
import { loadNative } from "../native/load.js";
|
|
10
|
+
import { validateProject } from "../validate.js";
|
|
11
|
+
import { validateCuratedCompose } from "./curated-release.js";
|
|
12
|
+
/**
|
|
13
|
+
* Every gate for a public release that can be decided on the developer's own
|
|
14
|
+
* machine. Submitting runs these first so a definition that CI would reject
|
|
15
|
+
* never reaches review; `--check` runs the same set and stops.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately excludes anything needing release authority: Build allocation,
|
|
18
|
+
* attestation signing and catalog publication belong to trusted CI running
|
|
19
|
+
* from the merged revision, and no local run can stand in for a maintainer's
|
|
20
|
+
* approval.
|
|
21
|
+
*/
|
|
22
|
+
export async function runPublicReleasePreflight(project) {
|
|
23
|
+
const checks = [];
|
|
24
|
+
const add = (check) => {
|
|
25
|
+
checks.push(check);
|
|
26
|
+
return check.ok;
|
|
27
|
+
};
|
|
28
|
+
const validation = validateProject(project, { profile: "listing" });
|
|
29
|
+
add({
|
|
30
|
+
id: "listing-validate",
|
|
31
|
+
title: "App Contract 與 runtime 通過 Curated listing profile",
|
|
32
|
+
ok: validation.ok,
|
|
33
|
+
detail: validation.ok ? undefined : validation.errors.map((error) => error.code).join(", "),
|
|
34
|
+
});
|
|
35
|
+
const contract = loadContract(project.contractPath).contract;
|
|
36
|
+
const packageIdentity = typeof contract.package === "string" ? contract.package : "";
|
|
37
|
+
const formalIdentity = packageIdentity.length > 0 && !isDevelopmentPackageIdentity(packageIdentity);
|
|
38
|
+
add({
|
|
39
|
+
id: "formal-identity",
|
|
40
|
+
title: "Package Identity 是正式身分(不是 .dev)",
|
|
41
|
+
ok: formalIdentity,
|
|
42
|
+
detail: formalIdentity
|
|
43
|
+
? undefined
|
|
44
|
+
: packageIdentity.length === 0
|
|
45
|
+
? "App Contract 缺少 package 欄位。"
|
|
46
|
+
: `Curated 不接受 development identity:${packageIdentity}`,
|
|
47
|
+
});
|
|
48
|
+
const nativePath = project.nativePath;
|
|
49
|
+
const isNative = nativePath !== undefined && existsSync(nativePath);
|
|
50
|
+
if (isNative) {
|
|
51
|
+
const parsedNative = loadNative(nativePath);
|
|
52
|
+
const families = Object.keys(parsedNative?.native.targets ?? {}).filter(isNativeArchFamily);
|
|
53
|
+
const pinned = families.filter((family) => {
|
|
54
|
+
const target = parsedNative?.native.targets?.[family];
|
|
55
|
+
return typeof target?.url === "string" && typeof target?.sha256 === "string";
|
|
56
|
+
});
|
|
57
|
+
add({
|
|
58
|
+
id: "curated-runtime",
|
|
59
|
+
title: "每個 native target 都以 url + sha256 釘住",
|
|
60
|
+
ok: families.length > 0 && pinned.length === families.length,
|
|
61
|
+
detail: families.length === 0
|
|
62
|
+
? "native.yaml 沒有宣告任何 target。"
|
|
63
|
+
: pinned.length === families.length
|
|
64
|
+
? undefined
|
|
65
|
+
: `缺少 url 或 sha256:${families.filter((f) => !pinned.includes(f)).join(", ")}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const parsedCompose = loadCompose(project.composePath);
|
|
70
|
+
const gate = parsedCompose
|
|
71
|
+
? validateCuratedCompose(parsedCompose.compose)
|
|
72
|
+
: { ok: false, errors: ["CURATED_COMPOSE_MISSING"] };
|
|
73
|
+
add({
|
|
74
|
+
id: "curated-runtime",
|
|
75
|
+
title: "Compose 只用釘住 digest 的公開 image,且沒有 build:",
|
|
76
|
+
ok: gate.ok,
|
|
77
|
+
detail: gate.ok ? undefined : gate.errors.join(", "),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// The strongest local gate: actually produce the artifact. For native this
|
|
81
|
+
// fetches each pinned asset and refuses it unless the bytes hash to the
|
|
82
|
+
// declared sha256, so a bad pin fails here instead of in CI.
|
|
83
|
+
if (checks.every((check) => check.ok)) {
|
|
84
|
+
const staging = mkdtempSync(join(tmpdir(), "synopkgland-preflight-"));
|
|
85
|
+
try {
|
|
86
|
+
const families = isNative
|
|
87
|
+
? Object.keys(loadNative(nativePath)?.native.targets ?? {}).filter(isNativeArchFamily)
|
|
88
|
+
: [undefined];
|
|
89
|
+
for (const family of families) {
|
|
90
|
+
const built = await buildSpk({
|
|
91
|
+
project,
|
|
92
|
+
outputPath: join(staging, `${packageIdentity}-${family ?? "noarch"}.spk`),
|
|
93
|
+
...(family ? { archFamily: family } : {}),
|
|
94
|
+
});
|
|
95
|
+
add({
|
|
96
|
+
id: family ? `spk-builds:${family}` : "spk-builds",
|
|
97
|
+
title: family ? `SPK 可建置(${family},含 sha256 比對)` : "SPK 可建置",
|
|
98
|
+
ok: built.ok,
|
|
99
|
+
detail: built.ok ? undefined : built.errors.join(", "),
|
|
100
|
+
});
|
|
101
|
+
if (!built.ok) {
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
rmSync(staging, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { ok: checks.every((check) => check.ok), checks };
|
|
111
|
+
}
|
|
112
|
+
export function formatPreflight(result) {
|
|
113
|
+
return result.checks
|
|
114
|
+
.map((check) => `${check.ok ? "✔" : "✖"} ${check.title}${check.detail ? `\n ${check.detail}` : ""}`)
|
|
115
|
+
.join("\n");
|
|
116
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ProjectRoot } from "../discovery.js";
|
|
2
|
+
import { type PreflightResult } from "./preflight.js";
|
|
2
3
|
import type { GitHubCli, GitOps } from "./github-pr.js";
|
|
3
4
|
export interface PublishProjectOptions {
|
|
5
|
+
/** Injected in tests; production runs the real local gate set. */
|
|
6
|
+
preflight?: (project: ProjectRoot) => Promise<PreflightResult>;
|
|
4
7
|
project: ProjectRoot;
|
|
5
8
|
communityAppsRoot?: string;
|
|
6
9
|
baseBranch?: string;
|
|
@@ -10,6 +13,7 @@ export interface PublishProjectOptions {
|
|
|
10
13
|
now?: () => Date;
|
|
11
14
|
}
|
|
12
15
|
export interface PublishProjectResult {
|
|
16
|
+
preflight?: PreflightResult;
|
|
13
17
|
ok: boolean;
|
|
14
18
|
code?: string;
|
|
15
19
|
message?: string;
|
package/dist/publish/publish.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
2
|
import { loadContract } from "../contract/load.js";
|
|
3
|
-
import {
|
|
3
|
+
import { runPublicReleasePreflight } from "./preflight.js";
|
|
4
4
|
import { assertDirectoryMatchesPackage, COMMUNITY_APPS_APPS_DIR, COMMUNITY_APPS_DIR, communityAppDirectory, resolveCommunityAppsRoot, } from "./community-path.js";
|
|
5
5
|
import { listSyncedRelativePaths, syncPackagingDefinition } from "./sync-definition.js";
|
|
6
6
|
function slugifyPackageIdentity(packageIdentity) {
|
|
@@ -28,12 +28,20 @@ function renderPullRequestBody(packageIdentity, appRelativePath, files) {
|
|
|
28
28
|
].join("\n");
|
|
29
29
|
}
|
|
30
30
|
export async function publishProject(options) {
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
// Every locally decidable gate runs before anything is pushed, so a
|
|
32
|
+
// definition CI would reject never costs a maintainer a review.
|
|
33
|
+
const preflight = options.preflight
|
|
34
|
+
? await options.preflight(options.project)
|
|
35
|
+
: await runPublicReleasePreflight(options.project);
|
|
36
|
+
if (!preflight.ok) {
|
|
33
37
|
return {
|
|
34
38
|
ok: false,
|
|
35
|
-
code: "
|
|
36
|
-
message:
|
|
39
|
+
code: "PREFLIGHT_FAILED",
|
|
40
|
+
message: preflight.checks
|
|
41
|
+
.filter((check) => !check.ok)
|
|
42
|
+
.map((check) => `${check.id}${check.detail ? `: ${check.detail}` : ""}`)
|
|
43
|
+
.join("; "),
|
|
44
|
+
preflight,
|
|
37
45
|
};
|
|
38
46
|
}
|
|
39
47
|
const communityRoot = options.communityAppsRoot ?? resolveCommunityAppsRoot(options.project.root);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Trusted-CI entry point. Kept out of the `synopkgland` developer binary on
|
|
4
|
+
* purpose: allocating a Package Build, signing attestations and publishing to
|
|
5
|
+
* the Curated Package Source require release authority that a developer's
|
|
6
|
+
* machine must never hold. Only CI running from the reviewed merged revision
|
|
7
|
+
* has the keyring, the ledger write credential and the publish credential.
|
|
8
|
+
*/
|
|
9
|
+
import { loadDotEnv } from "./load-env.js";
|
|
10
|
+
import { runCuratedReleaseCli } from "./publish/curated-release.js";
|
|
11
|
+
loadDotEnv();
|
|
12
|
+
runCuratedReleaseCli(["node", "synopkgland-release", "release", ...process.argv.slice(2)])
|
|
13
|
+
.then((status) => {
|
|
14
|
+
process.exitCode = status;
|
|
15
|
+
})
|
|
16
|
+
.catch((error) => {
|
|
17
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
});
|