@arc-e-tect/api-only-publisher 0.16.1 → 0.17.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/README.adoc +111 -23
- package/package.json +1 -1
- package/src/bundle-version.js +6 -2
- package/src/channel-error.js +29 -3
- package/src/channels.js +102 -47
- package/src/cli.js +70 -8
- package/src/config.js +22 -1
- package/src/gitlab.js +46 -28
- package/src/handoff.js +5 -3
- package/src/held-documents.js +91 -0
- package/src/nuget.js +39 -35
- package/src/tar.js +37 -1
- package/src/version-guard.js +158 -0
- package/src/zip.js +42 -1
package/src/cli.js
CHANGED
|
@@ -17,6 +17,8 @@ const { pack, PackError } = require("./pack");
|
|
|
17
17
|
const { changedSince, ChangedError } = require("./changed");
|
|
18
18
|
const { split, SplitError } = require("./split");
|
|
19
19
|
const { publish, ChannelError } = require("./channels");
|
|
20
|
+
const { outcomeOf } = require("./channel-error");
|
|
21
|
+
const { guard, GuardError } = require("./version-guard");
|
|
20
22
|
const { refuseRemote } = require("./remote");
|
|
21
23
|
const { CHANNEL_TYPES } = require("./channel-types");
|
|
22
24
|
const { VersionError: PolicyError, describe } = require("./version-policy");
|
|
@@ -40,6 +42,7 @@ Usage:
|
|
|
40
42
|
api-only-publisher targets
|
|
41
43
|
api-only-publisher closure [--target <name>]...
|
|
42
44
|
api-only-publisher changed --since <ref>
|
|
45
|
+
api-only-publisher check [--target <name>]... [--pre-release <ids>] [--since <ref>]
|
|
43
46
|
api-only-publisher pack [--pre-release <ids>] [--target <name>]... [--out <dir>]
|
|
44
47
|
api-only-publisher publish [--pre-release <ids>] [--target <name>]... [--channel <name>]... [--out <dir>]
|
|
45
48
|
api-only-publisher split [--out <dir>] [--by kind|target]
|
|
@@ -63,7 +66,9 @@ Options:
|
|
|
63
66
|
init only: the value of that question; a flag always wins.
|
|
64
67
|
--portfolio-paths <target-prefix|none>, --portfolio-location <dir>
|
|
65
68
|
config only: the value of that question; a flag always wins.
|
|
66
|
-
--since <ref> changed
|
|
69
|
+
--since <ref> changed: the git ref to compare the working tree against.
|
|
70
|
+
check/publish: the ref to compare a target without a release
|
|
71
|
+
tag against, instead of the merge-base with the main branch.
|
|
67
72
|
--out <dir> pack/publish/split: where to write, instead of build.packages
|
|
68
73
|
or build.split in apionly.yaml.
|
|
69
74
|
--channel <name> publish only: repeat for several. Default: every configured channel.
|
|
@@ -76,6 +81,8 @@ Options:
|
|
|
76
81
|
What gets built, and where each document goes, is declared in apionly.yaml.
|
|
77
82
|
A published target's version is read from its version file:
|
|
78
83
|
<target>.bundle.properties beside its bundle root, or the file its versionFile names.
|
|
84
|
+
A target changed since its release, or since the main branch, needs a new version:
|
|
85
|
+
check says which do not have one, and publish refuses them.
|
|
79
86
|
`;
|
|
80
87
|
|
|
81
88
|
function parseArgs(argv) {
|
|
@@ -289,6 +296,20 @@ async function runConfig(options, positional, io, log) {
|
|
|
289
296
|
return 0;
|
|
290
297
|
}
|
|
291
298
|
|
|
299
|
+
/** Fails with every target the guard refused -- and, with `undecided`, every one it could not decide -- each with its reason. */
|
|
300
|
+
function refuseViolations(results, { undecided = false } = {}) {
|
|
301
|
+
const listed = (verdict) => results.filter((r) => r.verdict === verdict).map((r) => ` ${r.target} ${r.version}: ${r.reason}`);
|
|
302
|
+
const violations = listed("violation");
|
|
303
|
+
const unknown = undecided ? listed("undecided") : [];
|
|
304
|
+
const parts = [];
|
|
305
|
+
if (violations.length > 0) {
|
|
306
|
+
parts.push(`${violations.length} target(s) changed without a new version:\n${violations.join("\n")}\n` +
|
|
307
|
+
"A published version is never replaced: a changed specification needs a changed version.");
|
|
308
|
+
}
|
|
309
|
+
if (unknown.length > 0) parts.push(`${unknown.length} target(s) could not be checked:\n${unknown.join("\n")}`);
|
|
310
|
+
if (parts.length > 0) throw new GuardError(parts.join("\n\n"));
|
|
311
|
+
}
|
|
312
|
+
|
|
292
313
|
async function main(argv, io = {}) {
|
|
293
314
|
const { options, positional } = parseArgs(argv);
|
|
294
315
|
const command = positional[0];
|
|
@@ -425,6 +446,17 @@ async function main(argv, io = {}) {
|
|
|
425
446
|
log(`\n${changed.length} of ${results.length} target(s) changed since ${options.since}.`);
|
|
426
447
|
return 0;
|
|
427
448
|
}
|
|
449
|
+
case "check": {
|
|
450
|
+
for (const target of targets || []) {
|
|
451
|
+
if (!config.isPublished(target)) throw new ConfigError(`${target} is not published, so it has no version to check`);
|
|
452
|
+
}
|
|
453
|
+
const results = guard(config, shippedTargets(config, targets), { preRelease: options.preRelease, since: options.since });
|
|
454
|
+
for (const r of results) log(`${r.target.padEnd(18)} ${r.version.padEnd(14)} ${r.verdict === "ok" ? "ok" : r.verdict.toUpperCase()} ${r.reason}`);
|
|
455
|
+
// check is the gate: a target it cannot decide fails it too.
|
|
456
|
+
refuseViolations(results, { undecided: true });
|
|
457
|
+
log(`\n${results.length} target(s) checked.`);
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
428
460
|
case "pack": {
|
|
429
461
|
const outDir = options.out ? path.resolve(options.dir, options.out) : config.packagesDir();
|
|
430
462
|
const shipped = shippedTargets(config, targets);
|
|
@@ -483,9 +515,21 @@ async function main(argv, io = {}) {
|
|
|
483
515
|
// Every channel this run uses, before anything is read, packed or sent.
|
|
484
516
|
refuseRemote([...new Set(shipped.flatMap((target) => routes.get(target)))], configured);
|
|
485
517
|
const versions = versionsOf(config, shipped, options.preRelease);
|
|
518
|
+
// A changed target needs a new version: decided before anything is packed or sent.
|
|
519
|
+
try {
|
|
520
|
+
const checked = guard(config, shipped, { preRelease: options.preRelease, since: options.since });
|
|
521
|
+
refuseViolations(checked);
|
|
522
|
+
for (const r of checked.filter((c) => c.verdict === "undecided")) {
|
|
523
|
+
log(`warning: ${r.target} ${r.version}: version not checked: ${r.reason}`);
|
|
524
|
+
}
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (!(error instanceof GuardError && error.notARepository)) throw error;
|
|
527
|
+
log(`warning: versions not checked: ${error.message}`);
|
|
528
|
+
}
|
|
486
529
|
const closures = forTargets(config, undefined, shipped);
|
|
487
530
|
let published = 0;
|
|
488
531
|
const handoff = [];
|
|
532
|
+
const outcomes = [];
|
|
489
533
|
for (const target of Object.keys(config.targets)) {
|
|
490
534
|
if (!shipped.includes(target)) continue;
|
|
491
535
|
const closure = closures.get(target);
|
|
@@ -496,18 +540,36 @@ async function main(argv, io = {}) {
|
|
|
496
540
|
outDir, log,
|
|
497
541
|
});
|
|
498
542
|
for (const name of routes.get(target)) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
543
|
+
const entry = { target, version: manifest.version, channel: name, type: config.channelType(name) };
|
|
544
|
+
let result;
|
|
545
|
+
try {
|
|
546
|
+
// A remote channel returns a promise; a local one does not.
|
|
547
|
+
result = await publish(archive, manifest, name, { ...(configured[name] || {}), baseDir: config.root }, log);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
// A channel is an external party: what it cannot take is reported, never fatal.
|
|
550
|
+
const outcome = outcomeOf(error);
|
|
551
|
+
if (!outcome) throw error;
|
|
552
|
+
outcomes.push({ ...entry, outcome, reason: error.message });
|
|
553
|
+
log(`warning: ${target} ${manifest.version} was not published to ${name}: ${error.message}`);
|
|
554
|
+
if (process.env.GITHUB_ACTIONS === "true") {
|
|
555
|
+
console.log(`::warning title=${target} ${manifest.version} not published to ${name}::${error.message.replace(/\r?\n/g, " ")}`);
|
|
556
|
+
}
|
|
557
|
+
continue;
|
|
503
558
|
}
|
|
559
|
+
outcomes.push({ ...entry, outcome: result.alreadyPublished ? "already-published" : "published" });
|
|
560
|
+
for (const file of result.files || []) handoff.push({ file, ...entry });
|
|
504
561
|
published += 1;
|
|
505
562
|
}
|
|
506
563
|
}
|
|
507
|
-
// What a later step publishes byte for byte; README.adoc#handoff.
|
|
508
|
-
const { sha256: listing } = writeHandoff(config.root, outDir, handoff);
|
|
564
|
+
// What a later step publishes byte for byte, and what each channel made of it; README.adoc#handoff.
|
|
565
|
+
const { sha256: listing } = writeHandoff(config.root, outDir, handoff, outcomes);
|
|
509
566
|
log(`\nPublished ${published} artifact(s).`);
|
|
510
567
|
log(`Listed ${handoff.length} file(s) in ${path.relative(config.root, listing)}.`);
|
|
568
|
+
const problems = outcomes.length - published;
|
|
569
|
+
if (problems > 0) {
|
|
570
|
+
log(`Channel problems: ${problems} of ${outcomes.length} publication(s) did not complete.` +
|
|
571
|
+
(published === 0 ? " Nothing was published." : ""));
|
|
572
|
+
}
|
|
511
573
|
return 0;
|
|
512
574
|
}
|
|
513
575
|
case "split": {
|
|
@@ -533,7 +595,7 @@ function report(error) {
|
|
|
533
595
|
error instanceof ClosureError || error instanceof PackError ||
|
|
534
596
|
error instanceof ChangedError || error instanceof SplitError ||
|
|
535
597
|
error instanceof ChannelError || error instanceof PolicyError ||
|
|
536
|
-
error instanceof BundleVersionError) {
|
|
598
|
+
error instanceof BundleVersionError || error instanceof GuardError) {
|
|
537
599
|
console.error(`Error: ${error.message}`);
|
|
538
600
|
process.exitCode = 1;
|
|
539
601
|
} else {
|
package/src/config.js
CHANGED
|
@@ -21,7 +21,7 @@ const SUPPORTED_SCHEMA_VERSION = 1;
|
|
|
21
21
|
// often a typo or a bad indent than a value nobody has used yet -- see checkKeys.
|
|
22
22
|
const ROOT_KEYS = [
|
|
23
23
|
"schemaVersion", "sources", "defaults", "build", "reports", "lint",
|
|
24
|
-
"distribution", "channels", "targets", "toolchain", "portfolio",
|
|
24
|
+
"distribution", "channels", "targets", "toolchain", "portfolio", "versioning",
|
|
25
25
|
];
|
|
26
26
|
const SOURCES_KEYS = ["root", "openapi", "asyncapi"];
|
|
27
27
|
const DEFAULTS_KEYS = ["openapi", "asyncapi", "placeholders"];
|
|
@@ -36,6 +36,7 @@ const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile", "channels"
|
|
|
36
36
|
const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
|
|
37
37
|
const PORTFOLIO_KEYS = ["openapi", "security", "location"];
|
|
38
38
|
const PORTFOLIO_OPENAPI_KEYS = ["paths", "operationIds", "tags"];
|
|
39
|
+
const VERSIONING_KEYS = ["tagFormat", "mainBranch"];
|
|
39
40
|
|
|
40
41
|
// What every portfolio setting is when apionly.yaml says nothing: safe for the
|
|
41
42
|
// ordinary case (a gateway routing /<target>/** to each service), and named rather
|
|
@@ -204,6 +205,16 @@ function load(configPath) {
|
|
|
204
205
|
);
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
const versioning = parsed.versioning || {};
|
|
209
|
+
checkKeys(versioning, VERSIONING_KEYS, "versioning");
|
|
210
|
+
if (versioning.tagFormat !== undefined) {
|
|
211
|
+
const format = requireString(versioning.tagFormat, "versioning.tagFormat");
|
|
212
|
+
if (!format.includes("{target}") || !format.includes("{version}")) {
|
|
213
|
+
throw new ConfigError(`versioning.tagFormat must contain {target} and {version}, not ${JSON.stringify(format)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (versioning.mainBranch !== undefined) requireString(versioning.mainBranch, "versioning.mainBranch");
|
|
217
|
+
|
|
207
218
|
const channels = parsed.channels || {};
|
|
208
219
|
const types = channelTypes(channels);
|
|
209
220
|
|
|
@@ -314,6 +325,7 @@ function load(configPath) {
|
|
|
314
325
|
distribution: parsed.distribution || null,
|
|
315
326
|
channels,
|
|
316
327
|
portfolio,
|
|
328
|
+
versioning,
|
|
317
329
|
targets,
|
|
318
330
|
|
|
319
331
|
// --- derived accessors, so callers never re-derive a path themselves ---
|
|
@@ -329,6 +341,15 @@ function load(configPath) {
|
|
|
329
341
|
stagingDir(kind) {
|
|
330
342
|
return path.join(this.stagingRoot(kind), requireString(this.sources[kind], `sources.${kind}`));
|
|
331
343
|
},
|
|
344
|
+
// How a target's releases are tagged in git -- by the pipeline that publishes them,
|
|
345
|
+
// never by the Publisher, which only reads the tags -- and the branch a change is
|
|
346
|
+
// compared with when a target has no release tag yet.
|
|
347
|
+
tagFormat() {
|
|
348
|
+
return this.versioning.tagFormat || "{target}-v{version}";
|
|
349
|
+
},
|
|
350
|
+
mainBranch() {
|
|
351
|
+
return this.versioning.mainBranch || "main";
|
|
352
|
+
},
|
|
332
353
|
// Where pack and publish write archives, and split its parts, unless --out says
|
|
333
354
|
// otherwise; relative to this configuration, as every other path here is.
|
|
334
355
|
packagesDir() {
|
package/src/gitlab.js
CHANGED
|
@@ -16,7 +16,8 @@ const path = require("path");
|
|
|
16
16
|
const { execFileSync } = require("child_process");
|
|
17
17
|
|
|
18
18
|
const { isPrerelease } = require("./version-policy");
|
|
19
|
-
const { ChannelError } = require("./channel-error");
|
|
19
|
+
const { ChannelError, ChannelConflict, ChannelUnavailable } = require("./channel-error");
|
|
20
|
+
const { judgeHeld } = require("./held-documents");
|
|
20
21
|
|
|
21
22
|
const GITLAB_COM = "https://gitlab.com/api/v4";
|
|
22
23
|
const TOKEN_HEADERS = { private: "PRIVATE-TOKEN", deploy: "DEPLOY-TOKEN", job: "JOB-TOKEN" };
|
|
@@ -34,7 +35,7 @@ function channelSettings(options, env = process.env) {
|
|
|
34
35
|
tokenType = "job";
|
|
35
36
|
}
|
|
36
37
|
if (!token) {
|
|
37
|
-
throw new
|
|
38
|
+
throw new ChannelUnavailable(
|
|
38
39
|
`no token in $${tokenEnv}, which channels.gitlab-package.tokenEnv names, and no $CI_JOB_TOKEN. ` +
|
|
39
40
|
"Set one in the release job's environment, or run in a GitLab CI job.");
|
|
40
41
|
}
|
|
@@ -78,32 +79,39 @@ async function readBack(fileUrl, name, headers) {
|
|
|
78
79
|
const response = await fetch(fileUrl, { headers });
|
|
79
80
|
if (response.status === 404) return null;
|
|
80
81
|
if (!response.ok) {
|
|
81
|
-
throw new
|
|
82
|
+
throw new ChannelUnavailable(`could not read ${name} back from ${fileUrl}: ${await answered(response)}.` +
|
|
82
83
|
(response.status === 401 || response.status === 403 ? TOKEN_HINT : ""));
|
|
83
84
|
}
|
|
84
85
|
return Buffer.from(await response.arrayBuffer());
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
function
|
|
88
|
-
return new
|
|
88
|
+
function refused(name, manifest, why) {
|
|
89
|
+
return new ChannelConflict(`GitLab already has ${name} in ${manifest.target} ${manifest.version}, and it ${why}; ` +
|
|
89
90
|
"a published version is never replaced, so publish the change as a new version");
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
/** The manifest in GitLab's copy of a file, which must describe the same documents as `manifest`. */
|
|
94
|
+
function sameAs(held, file, manifest) {
|
|
95
|
+
const judged = judgeHeld(held, file.kind, manifest);
|
|
96
|
+
if (judged.verdict === "unreadable") throw refused(file.name, manifest, `cannot be compared: ${judged.reason}`);
|
|
97
|
+
if (judged.verdict === "different") throw refused(file.name, manifest, "is not this one");
|
|
98
|
+
return judged.held;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Uploads one file unless GitLab has it: GitLab's copy when it was already there with the
|
|
103
|
+
* same documents, or null when it was uploaded now.
|
|
104
|
+
*/
|
|
105
|
+
async function upload(file, manifest, headers) {
|
|
106
|
+
const held = await readBack(file.url, file.name, headers);
|
|
107
|
+
if (held) return { held, found: sameAs(held, file, manifest) };
|
|
108
|
+
const response = await fetch(file.url, { method: "PUT", headers, body: file.bytes });
|
|
109
|
+
if (response.ok) return null;
|
|
110
|
+
const answer = `${await answered(response)}`;
|
|
102
111
|
// Refused, perhaps as a duplicate another run published meanwhile: compare that one.
|
|
103
|
-
const now = await readBack(
|
|
104
|
-
if (now
|
|
105
|
-
|
|
106
|
-
throw new ChannelError(`GitLab refused ${name}: ${refused}.` +
|
|
112
|
+
const now = await readBack(file.url, file.name, headers).catch(() => null);
|
|
113
|
+
if (now) return { held: now, found: sameAs(now, file, manifest) };
|
|
114
|
+
throw new ChannelUnavailable(`GitLab refused ${file.name}: ${answer}.` +
|
|
107
115
|
(response.status === 401 || response.status === 403 ? TOKEN_HINT : ""));
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -129,18 +137,18 @@ async function release(settings, manifest, options, links, ref, log) {
|
|
|
129
137
|
description: format(options.notes || options.description || DEFAULT_DESCRIPTION, manifest),
|
|
130
138
|
assets: { links },
|
|
131
139
|
});
|
|
132
|
-
if (!created.ok) throw new
|
|
140
|
+
if (!created.ok) throw new ChannelUnavailable(`GitLab refused the release ${tag}: ${await answered(created)}`);
|
|
133
141
|
log(`-- Created the GitLab release ${tag}`);
|
|
134
142
|
return;
|
|
135
143
|
}
|
|
136
144
|
if (!existing.ok) {
|
|
137
|
-
throw new
|
|
145
|
+
throw new ChannelUnavailable(`could not read the GitLab release ${tag}: ${await answered(existing)}`);
|
|
138
146
|
}
|
|
139
147
|
// A release's link names are unique: one already named, or already pointing here, is left as it is.
|
|
140
148
|
const held = ((await existing.json()).assets || {}).links || [];
|
|
141
149
|
for (const link of links.filter((l) => !held.some((h) => h.url === l.url || h.name === l.name))) {
|
|
142
150
|
const added = await api(`${releases}/${encodeURIComponent(tag)}/assets/links`, settings.headers, "POST", link);
|
|
143
|
-
if (!added.ok) throw new
|
|
151
|
+
if (!added.ok) throw new ChannelUnavailable(`GitLab refused a link on the release ${tag}: ${await answered(added)}`);
|
|
144
152
|
log(`-- Linked ${link.name} from the GitLab release ${tag}`);
|
|
145
153
|
}
|
|
146
154
|
}
|
|
@@ -159,18 +167,28 @@ function publishGitlabPackage(archive, manifest, options, log) {
|
|
|
159
167
|
const base = `${settings.url}/projects/${encodeURIComponent(settings.project)}/packages/generic/` +
|
|
160
168
|
`${encodeURIComponent(packageName)}/${encodeURIComponent(manifest.version)}`;
|
|
161
169
|
const files = [
|
|
162
|
-
{ name: path.basename(archive), bytes: require("fs").readFileSync(archive) },
|
|
163
|
-
{ name: "manifest.json", bytes: Buffer.from(JSON.stringify(manifest, null, 2) + "\n") },
|
|
170
|
+
{ name: path.basename(archive), bytes: require("fs").readFileSync(archive), kind: "tgz" },
|
|
171
|
+
{ name: "manifest.json", bytes: Buffer.from(JSON.stringify(manifest, null, 2) + "\n"), kind: "json" },
|
|
164
172
|
].map((file) => ({ ...file, url: `${base}/${encodeURIComponent(file.name)}` }));
|
|
165
173
|
|
|
166
174
|
return (async () => {
|
|
167
|
-
|
|
175
|
+
// Idempotent, by documents: a file GitLab has is left alone when it holds the same
|
|
176
|
+
// documents, and refused when it does not.
|
|
177
|
+
let held = 0;
|
|
178
|
+
let identical = true;
|
|
168
179
|
for (const file of files) {
|
|
169
|
-
|
|
180
|
+
const copy = await upload(file, manifest, settings.headers);
|
|
181
|
+
if (!copy) continue;
|
|
182
|
+
held += 1;
|
|
183
|
+
identical = identical && copy.held.equals(file.bytes);
|
|
184
|
+
// A version completed beside an archive from another commit gets that archive's
|
|
185
|
+
// manifest, so the two agree on where the version came from.
|
|
186
|
+
if (file.kind === "tgz") files[1].bytes = copy.found.bytes;
|
|
170
187
|
}
|
|
171
|
-
const alreadyPublished =
|
|
188
|
+
const alreadyPublished = held === files.length;
|
|
172
189
|
log(alreadyPublished
|
|
173
|
-
? `-- ${packageName} ${manifest.version} is already in GitLab project ${settings.project},
|
|
190
|
+
? `-- ${packageName} ${manifest.version} is already in GitLab project ${settings.project}, ` +
|
|
191
|
+
`${identical ? "identical" : "with the same documents"}; nothing to do`
|
|
174
192
|
: `-- Published ${packageName} ${manifest.version} to GitLab project ${settings.project}`);
|
|
175
193
|
|
|
176
194
|
if (options.release === true && !wantsRelease) {
|
package/src/handoff.js
CHANGED
|
@@ -21,9 +21,11 @@ const HANDOFF_SCHEMA_VERSION = 1;
|
|
|
21
21
|
/**
|
|
22
22
|
* Writes packages.json and packages.sha256 into `outDir`, replacing any from an
|
|
23
23
|
* earlier run, listing `entries` -- `{ file, target, version, channel }`, `file`
|
|
24
|
-
* absolute -- sorted by path.
|
|
24
|
+
* absolute -- sorted by path. packages.json also lists `outcomes`, what each channel
|
|
25
|
+
* made of each target: `published`, `already-published`, or a channel problem --
|
|
26
|
+
* `conflict` or `unavailable`, with its reason -- that did not fail the run.
|
|
25
27
|
*/
|
|
26
|
-
function writeHandoff(root, outDir, entries) {
|
|
28
|
+
function writeHandoff(root, outDir, entries, outcomes = []) {
|
|
27
29
|
const files = entries
|
|
28
30
|
.map(({ file, target, version, channel, type = channel }) => ({
|
|
29
31
|
path: path.relative(root, file).split(path.sep).join("/"),
|
|
@@ -35,7 +37,7 @@ function writeHandoff(root, outDir, entries) {
|
|
|
35
37
|
fs.mkdirSync(outDir, { recursive: true });
|
|
36
38
|
const json = path.join(outDir, HANDOFF_JSON);
|
|
37
39
|
const sha256 = path.join(outDir, HANDOFF_SHA256);
|
|
38
|
-
fs.writeFileSync(json, JSON.stringify({ schemaVersion: HANDOFF_SCHEMA_VERSION, files }, null, 2) + "\n");
|
|
40
|
+
fs.writeFileSync(json, JSON.stringify({ schemaVersion: HANDOFF_SCHEMA_VERSION, files, outcomes }, null, 2) + "\n");
|
|
39
41
|
fs.writeFileSync(sha256, files.map((f) => `${f.sha256} ${f.path}\n`).join(""));
|
|
40
42
|
return { json, sha256 };
|
|
41
43
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// What a destination already holds under a version, told apart by its documents.
|
|
4
|
+
//
|
|
5
|
+
// A published version is its documents: the manifest's `files`, each document's name and
|
|
6
|
+
// SHA-256, which is exactly what a subscriber records in its lockfile. Comparing bytes
|
|
7
|
+
// instead says too much: the manifest also records the commit it was packed from and that
|
|
8
|
+
// commit's time, so the same documents packed from a later commit are another archive,
|
|
9
|
+
// and a channel comparing bytes would refuse a version that has not changed.
|
|
10
|
+
|
|
11
|
+
const { untgz } = require("./tar");
|
|
12
|
+
const { unzip } = require("./zip");
|
|
13
|
+
|
|
14
|
+
class HeldError extends Error {}
|
|
15
|
+
|
|
16
|
+
// Where each kind of package keeps the manifest: the packed archive at its root, an npm
|
|
17
|
+
// package below package/, a NuGet package in its content folder.
|
|
18
|
+
const MANIFEST_ENTRIES = ["manifest.json", "package/manifest.json", "contracts/manifest.json"];
|
|
19
|
+
|
|
20
|
+
function kindOf(bytes) {
|
|
21
|
+
if (bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) return "tgz";
|
|
22
|
+
if (bytes.length >= 4 && bytes.readUInt32LE(0) === 0x04034b50) return "zip";
|
|
23
|
+
if (/^\s*\{/.test(bytes.toString("utf8", 0, 64))) return "json";
|
|
24
|
+
throw new HeldError("it is neither a manifest nor a package");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The manifest a destination holds, from the manifest itself or from the package that
|
|
29
|
+
* carries it: its parsed contents and its bytes, as written.
|
|
30
|
+
*
|
|
31
|
+
* @param {Buffer} bytes a manifest.json, a packed archive, an npm package or a .nupkg
|
|
32
|
+
* @param {"tgz"|"zip"|"json"} [kind] what `bytes` should be; recognised when omitted
|
|
33
|
+
* @returns {{manifest: object, bytes: Buffer}}
|
|
34
|
+
*/
|
|
35
|
+
function heldManifest(bytes, kind = kindOf(bytes)) {
|
|
36
|
+
let raw = bytes;
|
|
37
|
+
if (kind !== "json") {
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = kind === "zip" ? unzip(bytes) : untgz(bytes);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
// A truncated package can also end a read out of bounds; it is unreadable either way.
|
|
43
|
+
throw new HeldError(error.message);
|
|
44
|
+
}
|
|
45
|
+
const entry = entries.find((e) => MANIFEST_ENTRIES.includes(e.name));
|
|
46
|
+
if (!entry) throw new HeldError("it holds no manifest.json");
|
|
47
|
+
raw = entry.data;
|
|
48
|
+
}
|
|
49
|
+
let manifest;
|
|
50
|
+
try {
|
|
51
|
+
manifest = JSON.parse(raw.toString("utf8"));
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new HeldError(`its manifest.json is not JSON: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
if (!manifest || !Array.isArray(manifest.files)) throw new HeldError("its manifest.json lists no files");
|
|
56
|
+
return { manifest, bytes: raw };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The manifest in `bytes`; see heldManifest. */
|
|
60
|
+
function manifestIn(bytes, kind) {
|
|
61
|
+
return heldManifest(bytes, kind).manifest;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether `held` describes the same documents as `mine`: the same target and version, and
|
|
66
|
+
* the same document names with the same hashes, in any order. Provenance is not compared.
|
|
67
|
+
*/
|
|
68
|
+
function sameDocuments(held, mine) {
|
|
69
|
+
if (held.target !== mine.target || String(held.version) !== String(mine.version)) return false;
|
|
70
|
+
const listing = (manifest) => manifest.files.map((f) => `${f.path}\0${f.sha256}`).sort().join("\n");
|
|
71
|
+
return listing(held) === listing(mine);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* What a destination's copy of a version is, against the manifest being published:
|
|
76
|
+
* `same` with the manifest it carries, `different`, or `unreadable` with the reason --
|
|
77
|
+
* something that cannot be compared is never taken for the same version.
|
|
78
|
+
*
|
|
79
|
+
* @returns {{verdict: "same", held: {manifest: object, bytes: Buffer}} | {verdict: "different"} | {verdict: "unreadable", reason: string}}
|
|
80
|
+
*/
|
|
81
|
+
function judgeHeld(bytes, kind, manifest) {
|
|
82
|
+
let held;
|
|
83
|
+
try {
|
|
84
|
+
held = heldManifest(bytes, kind);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
return { verdict: "unreadable", reason: error.message };
|
|
87
|
+
}
|
|
88
|
+
return sameDocuments(held.manifest, manifest) ? { verdict: "same", held } : { verdict: "different" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { heldManifest, manifestIn, sameDocuments, judgeHeld, HeldError };
|
package/src/nuget.js
CHANGED
|
@@ -24,7 +24,8 @@ const { execFileSync } = require("child_process");
|
|
|
24
24
|
|
|
25
25
|
const { parse, isPrerelease } = require("./version-policy");
|
|
26
26
|
const { zip } = require("./zip");
|
|
27
|
-
const { ChannelError } = require("./channel-error");
|
|
27
|
+
const { ChannelError, ChannelConflict, ChannelUnavailable } = require("./channel-error");
|
|
28
|
+
const { judgeHeld } = require("./held-documents");
|
|
28
29
|
|
|
29
30
|
/** Where the documents and manifest.json sit inside the package. */
|
|
30
31
|
const CONTENT_FOLDER = "contracts";
|
|
@@ -173,13 +174,13 @@ function publishLocal(archive, manifest, options, id, log) {
|
|
|
173
174
|
async function feedResources(index) {
|
|
174
175
|
const response = await fetch(index);
|
|
175
176
|
if (!response.ok) {
|
|
176
|
-
throw new
|
|
177
|
+
throw new ChannelUnavailable(`could not read the NuGet service index at ${index}: ${response.status} ${response.statusText}`);
|
|
177
178
|
}
|
|
178
179
|
const resources = (await response.json()).resources || [];
|
|
179
180
|
const find = (type) => (resources.find((r) => r["@type"] === type) || {})["@id"] || null;
|
|
180
181
|
const publish = find(PUBLISH_RESOURCE);
|
|
181
182
|
if (!publish) {
|
|
182
|
-
throw new
|
|
183
|
+
throw new ChannelUnavailable(`the NuGet feed at ${index} has no ${PUBLISH_RESOURCE} resource, so it cannot be pushed to`);
|
|
183
184
|
}
|
|
184
185
|
return { publish, content: find(CONTENT_RESOURCE) };
|
|
185
186
|
}
|
|
@@ -191,15 +192,27 @@ function contentUrl(content, id, version) {
|
|
|
191
192
|
return `${content.replace(/\/?$/, "/")}${lowerId}/${lowerVersion}/${lowerId}.${lowerVersion}.nupkg`;
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
196
|
-
* feed offers no way to read it back.
|
|
197
|
-
*/
|
|
198
|
-
async function sameAsPublished(content, id, version, bytes, key) {
|
|
195
|
+
/** The feed's copy of this version, or null when the feed offers no way to read it back. */
|
|
196
|
+
async function readableCopy(content, id, version, key) {
|
|
199
197
|
if (!content) return null;
|
|
200
198
|
const response = await fetch(contentUrl(content, id, version), { headers: { "X-NuGet-ApiKey": key } });
|
|
201
199
|
if (!response.ok) return null;
|
|
202
|
-
return Buffer.from(await response.arrayBuffer())
|
|
200
|
+
return Buffer.from(await response.arrayBuffer());
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Whether the feed's copy `held` is this version, by its documents: "identical" for the
|
|
205
|
+
* same bytes, "same" for the same documents in another package, as from another commit;
|
|
206
|
+
* anything else is refused, as a published version is never replaced.
|
|
207
|
+
*/
|
|
208
|
+
function judge(held, bytes, id, manifest) {
|
|
209
|
+
if (held.equals(bytes)) return "identical";
|
|
210
|
+
const refused = (why) => new ChannelConflict(`the NuGet feed already has ${id} ${manifest.version}, and it ${why}; ` +
|
|
211
|
+
"a published version is never replaced, so publish the change as a new version");
|
|
212
|
+
const judged = judgeHeld(held, "zip", manifest);
|
|
213
|
+
if (judged.verdict === "unreadable") throw refused(`cannot be compared: ${judged.reason}`);
|
|
214
|
+
if (judged.verdict === "different") throw refused("is not this package");
|
|
215
|
+
return "same";
|
|
203
216
|
}
|
|
204
217
|
|
|
205
218
|
/**
|
|
@@ -212,7 +225,7 @@ async function publishedCopy(content, id, version, key) {
|
|
|
212
225
|
const response = await fetch(url, { headers: { "X-NuGet-ApiKey": key } });
|
|
213
226
|
if (response.status === 404) return null;
|
|
214
227
|
if (!response.ok) {
|
|
215
|
-
throw new
|
|
228
|
+
throw new ChannelUnavailable(`could not read ${id} ${version} back from ${url}: ${response.status} ${response.statusText}`.trim() +
|
|
216
229
|
(response.status === 401 || response.status === 403
|
|
217
230
|
? ". Check the key in the environment variable named by channels.nuget.tokenEnv." : ""));
|
|
218
231
|
}
|
|
@@ -230,7 +243,7 @@ function publishRemote(archive, manifest, options, id, log) {
|
|
|
230
243
|
const tokenEnv = options.tokenEnv || "NUGET_API_KEY";
|
|
231
244
|
const key = process.env[tokenEnv];
|
|
232
245
|
if (!key) {
|
|
233
|
-
throw new
|
|
246
|
+
throw new ChannelUnavailable(
|
|
234
247
|
`no API key in $${tokenEnv}, which channels.nuget.tokenEnv names. ` +
|
|
235
248
|
"Remote publication needs one; set it in the release job's environment.");
|
|
236
249
|
}
|
|
@@ -245,19 +258,17 @@ function publishRemote(archive, manifest, options, id, log) {
|
|
|
245
258
|
// Idempotent before the push, not only after a 409: a feed that allows duplicate
|
|
246
259
|
// versions -- GitLab's default -- accepts a different package under a version it
|
|
247
260
|
// has, and serves that one from then on.
|
|
261
|
+
const already = (verdict) => {
|
|
262
|
+
log(`-- NuGet ${id} ${manifest.version} is already published, ` +
|
|
263
|
+
`${verdict === "identical" ? "identical" : "with the same documents"}; nothing to do`);
|
|
264
|
+
return {
|
|
265
|
+
location: endpoint, id, version: manifest.version, prerelease: isPrerelease(manifest.version),
|
|
266
|
+
alreadyPublished: true,
|
|
267
|
+
};
|
|
268
|
+
};
|
|
248
269
|
if (content) {
|
|
249
270
|
const held = await publishedCopy(content, id, manifest.version, key);
|
|
250
|
-
if (held
|
|
251
|
-
log(`-- NuGet ${id} ${manifest.version} is already published, identical; nothing to do`);
|
|
252
|
-
return {
|
|
253
|
-
location: endpoint, id, version: manifest.version, prerelease: isPrerelease(manifest.version),
|
|
254
|
-
alreadyPublished: true,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
if (held) {
|
|
258
|
-
throw new ChannelError(`the NuGet feed already has ${id} ${manifest.version}, and it is not this package; ` +
|
|
259
|
-
"a published version is never replaced, so publish the change as a new version");
|
|
260
|
-
}
|
|
271
|
+
if (held) return already(judge(held, bytes, id, manifest));
|
|
261
272
|
}
|
|
262
273
|
const boundary = `api-only-${crypto.createHash("sha256").update(bytes).digest("hex").slice(0, 32)}`;
|
|
263
274
|
const body = Buffer.concat([
|
|
@@ -276,21 +287,14 @@ function publishRemote(archive, manifest, options, id, log) {
|
|
|
276
287
|
body,
|
|
277
288
|
});
|
|
278
289
|
if (response.status === 409) {
|
|
279
|
-
// Idempotent: the same
|
|
280
|
-
const
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
location: endpoint, id, version: manifest.version, prerelease: isPrerelease(manifest.version),
|
|
285
|
-
alreadyPublished: true,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
throw new ChannelError(`the NuGet feed already has ${id} ${manifest.version}` +
|
|
289
|
-
(same === false ? ", and it is not this package" : ", and it cannot be read back to compare") +
|
|
290
|
-
"; a published version is never replaced, so publish the change as a new version");
|
|
290
|
+
// Idempotent: the same documents again are success, others are not.
|
|
291
|
+
const held = await readableCopy(content, id, manifest.version, key);
|
|
292
|
+
if (held) return already(judge(held, bytes, id, manifest));
|
|
293
|
+
throw new ChannelConflict(`the NuGet feed already has ${id} ${manifest.version}, and it cannot be read back to compare; ` +
|
|
294
|
+
"a published version is never replaced, so publish the change as a new version");
|
|
291
295
|
}
|
|
292
296
|
if (!response.ok) {
|
|
293
|
-
throw new
|
|
297
|
+
throw new ChannelUnavailable(`the NuGet feed refused the package: ${response.status} ${response.statusText}. ` +
|
|
294
298
|
(response.status === 401 || response.status === 403
|
|
295
299
|
? "Check the key in the environment variable named by channels.nuget.tokenEnv." : ""));
|
|
296
300
|
}
|
package/src/tar.js
CHANGED
|
@@ -63,4 +63,40 @@ function tgz(entries, mtime) {
|
|
|
63
63
|
return gzipped;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
function text(block, start, length) {
|
|
67
|
+
const end = block.indexOf(0, start);
|
|
68
|
+
return block.toString("utf8", start, end < 0 || end > start + length ? start + length : end);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The regular files in a gzipped tar archive, in order: what `tgz` writes, and what npm
|
|
73
|
+
* packs, with a ustar prefix joined to the name. Directories, links and pax headers are
|
|
74
|
+
* skipped.
|
|
75
|
+
*
|
|
76
|
+
* @param {Buffer} bytes
|
|
77
|
+
* @returns {Array<{name: string, data: Buffer}>}
|
|
78
|
+
*/
|
|
79
|
+
function untgz(bytes) {
|
|
80
|
+
let tar;
|
|
81
|
+
try {
|
|
82
|
+
tar = zlib.gunzipSync(bytes);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw new TarError(`not a gzipped tar archive: ${error.message}`);
|
|
85
|
+
}
|
|
86
|
+
const entries = [];
|
|
87
|
+
for (let offset = 0; offset + BLOCK <= tar.length;) {
|
|
88
|
+
const block = tar.subarray(offset, offset + BLOCK);
|
|
89
|
+
if (block.every((byte) => byte === 0)) break;
|
|
90
|
+
const prefix = block.toString("ascii", 257, 262) === "ustar" ? text(block, 345, 155) : "";
|
|
91
|
+
const name = prefix ? `${prefix}/${text(block, 0, 100)}` : text(block, 0, 100);
|
|
92
|
+
const size = parseInt(text(block, 124, 12).trim() || "0", 8);
|
|
93
|
+
const start = offset + BLOCK;
|
|
94
|
+
if (start + size > tar.length) throw new TarError(`the archive ends inside ${name}`);
|
|
95
|
+
const type = String.fromCharCode(block[156]);
|
|
96
|
+
if (type === "0" || type === "\0") entries.push({ name, data: Buffer.from(tar.subarray(start, start + size)) });
|
|
97
|
+
offset = start + Math.ceil(size / BLOCK) * BLOCK;
|
|
98
|
+
}
|
|
99
|
+
return entries;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { tgz, untgz, TarError };
|