@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/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 only: the git ref to compare the working tree against.
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
- // A remote channel returns a promise; a local one does not.
500
- const result = await publish(archive, manifest, name, { ...(configured[name] || {}), baseDir: config.root }, log);
501
- for (const file of result.files || []) {
502
- handoff.push({ file, target, version: manifest.version, channel: name, type: config.channelType(name) });
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 ChannelError(
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 ChannelError(`could not read ${name} back from ${fileUrl}: ${await answered(response)}.` +
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 notThisOne(name, manifest) {
88
- return new ChannelError(`GitLab already has ${name} in ${manifest.target} ${manifest.version}, and it is not this one; ` +
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
- /** Uploads one file unless GitLab has it: true when it was already there, identical. */
93
- async function upload(fileUrl, name, bytes, manifest, headers) {
94
- const held = await readBack(fileUrl, name, headers);
95
- if (held) {
96
- if (held.equals(bytes)) return true;
97
- throw notThisOne(name, manifest);
98
- }
99
- const response = await fetch(fileUrl, { method: "PUT", headers, body: bytes });
100
- if (response.ok) return false;
101
- const refused = `${await answered(response)}`;
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(fileUrl, name, headers).catch(() => null);
104
- if (now && now.equals(bytes)) return true;
105
- if (now) throw notThisOne(name, manifest);
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 ChannelError(`GitLab refused the release ${tag}: ${await answered(created)}`);
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 ChannelError(`could not read the GitLab release ${tag}: ${await answered(existing)}`);
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 ChannelError(`GitLab refused a link on the release ${tag}: ${await answered(added)}`);
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
- let identical = 0;
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
- if (await upload(file.url, file.name, file.bytes, manifest, settings.headers)) identical += 1;
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 = identical === files.length;
188
+ const alreadyPublished = held === files.length;
172
189
  log(alreadyPublished
173
- ? `-- ${packageName} ${manifest.version} is already in GitLab project ${settings.project}, identical; nothing to do`
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 ChannelError(`could not read the NuGet service index at ${index}: ${response.status} ${response.statusText}`);
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 ChannelError(`the NuGet feed at ${index} has no ${PUBLISH_RESOURCE} resource, so it cannot be pushed to`);
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
- * Whether the feed's copy of this version is these bytes: true or false, or null when the
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()).equals(bytes);
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 ChannelError(`could not read ${id} ${version} back from ${url}: ${response.status} ${response.statusText}`.trim() +
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 ChannelError(
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 && held.equals(bytes)) {
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 package again is success, a different one is not.
280
- const same = await sameAsPublished(content, id, manifest.version, bytes, key);
281
- if (same) {
282
- log(`-- NuGet ${id} ${manifest.version} is already published, identical; nothing to do`);
283
- return {
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 ChannelError(`the NuGet feed refused the package: ${response.status} ${response.statusText}. ` +
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
- module.exports = { tgz, TarError };
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 };