@arc-e-tect/api-only-publisher 0.16.0 → 0.16.2

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 CHANGED
@@ -6,7 +6,7 @@
6
6
  :source-highlighter: rouge
7
7
  // The released version of this component: its snippets use it. The release
8
8
  // workflow updates it; do not change it by hand.
9
- :api-only-publisher-version: 0.16.0
9
+ :api-only-publisher-version: 0.16.2
10
10
 
11
11
  [NOTE]
12
12
  .About this documentation
@@ -700,9 +700,13 @@ A run that would publish nothing at all fails instead of succeeding quietly.
700
700
  [#idempotent]
701
701
  === Publishing the same version again
702
702
 
703
- `publish` is idempotent: publishing a version again with the same content changes nothing, on every channel.
703
+ `publish` is idempotent: publishing a version again with the same documents changes nothing, on every channel.
704
704
 
705
- That rests on `pack` being reproducible.
705
+ A published version is its documents: the names and SHA-256 hashes that `manifest.json` lists under `files`, which are exactly what a subscriber records in its lockfile.
706
+ A remote channel compares those, not the bytes of what it holds.
707
+ The manifest also records the commit a version was packed from, and that commit's time, so the same documents packed from a later commit are another archive, and the same version.
708
+
709
+ Packing the same commit again gives the same bytes, because `pack` is reproducible.
706
710
  The same documents always pack to the same archive, byte for byte: the archive is written with a fixed mode, no owner and a fixed timestamp, and `manifest.json`'s `producedAt` is not the time `pack` ran but the time of the commit being packed.
707
711
  Set `SOURCE_DATE_EPOCH` (seconds since 1970, the https://reproducible-builds.org/specs/source-date-epoch/[reproducible-builds convention ↗]) to choose another; outside a git repository, where there is no commit, it is the only way to make two packs identical, and without it `producedAt` is the current time.
708
712
 
@@ -716,11 +720,15 @@ What happens when the destination already has the version:
716
720
  |Written again. With the same content the bytes are the same; with different content the version is replaced, and every subscriber whose lockfile recorded the old bytes fails its build until the change is published as a new version.
717
721
 
718
722
  |`maven` and `nuget` to a URL, `npm`, `github-release`, `gitlab-package`
719
- |The same bytes: nothing is sent, and the log says `already published, identical`. Different bytes: `publish` fails, naming the version -- a published version is never replaced, so publish the change as a new version.
723
+ |The same documents: nothing is sent, and the log says `already published, identical`, or `with the same documents` when the copy came from another commit.
724
+ Other documents: `publish` fails, naming the version -- a published version is never replaced, so publish the change as a new version.
725
+ A copy that cannot be read, so there is nothing to compare, fails as one with other documents would, saying why it `cannot be compared`.
720
726
  `maven` and `gitlab-package` read back every file before uploading any, so a run interrupted half-way is completed by the next one; a GitLab registry that allows duplicate packages, as it does by default, would otherwise replace a version.
721
- `github-release` compares the asset it would upload with the one the release has, and never overwrites one.
722
- `npm` compares the integrity hash the registry records, or the tarball's SHA-1 where it records only that, as GitLab's does; its files are packed with fixed modes, so the package is the same bytes whatever the umask of the machine that publishes it.
723
- A NuGet feed that cannot return the package it has, so there is nothing to compare, fails as a different one would.
727
+ A version completed beside an archive from another commit gets that archive's `manifest.json`, so the two agree on where the version came from; a POM carries no documents, and one already there is left as it is.
728
+ `github-release` reads the manifest in the asset the release has, and never overwrites one.
729
+ `npm` takes the same integrity hash the registry records, or the same tarball SHA-1 where it records only that, as GitLab's does, for the same package; otherwise it downloads the registry's copy and compares its documents.
730
+ Its files are packed with fixed modes, so the package is the same bytes whatever the umask of the machine that publishes it.
731
+ `nuget` reads the manifest in the package the feed returns.
724
732
  |===
725
733
 
726
734
  [#remote-switch]
@@ -832,7 +840,7 @@ A .NET build uses it with `dotnet nuget add source <repository>`, or a `nuget.co
832
840
  *A remote feed* is configured by its service index, the URL a feed's settings show and that ends in `index.json`, such as `https://api.nuget.org/v3/index.json`.
833
841
  The Publisher reads the service index for its `PackagePublish/2.0.0` endpoint, then pushes the package there with one `PUT`, as `multipart/form-data`, with the key from the environment variable `tokenEnv` names in the `X-NuGet-ApiKey` header.
834
842
  The `PUT` goes to that endpoint with a trailing `/`, as `dotnet nuget push` sends it; a GitLab feed recognises the upload only there.
835
- Before the push, the Publisher reads the feed's copy of the version back (its `PackageBaseAddress/3.0.0` resource): the same package succeeds without a push, a different one fails, and a copy that cannot be read, other than one the feed does not have, fails rather than being pushed over (<<idempotent,Publishing the same version again §>>).
843
+ Before the push, the Publisher reads the feed's copy of the version back (its `PackageBaseAddress/3.0.0` resource): a package with the same documents succeeds without a push, one with other documents fails, and a copy that cannot be read, other than one the feed does not have, fails rather than being pushed over (<<idempotent,Publishing the same version again §>>).
836
844
  The check comes first because a feed may accept a different package under a version it already has: GitLab does, by default, and serves the new one from then on.
837
845
  A feed that answers the push with 409 -- the version appeared in the meantime -- is read back and compared the same way.
838
846
  Without the variable set, the push fails before anything is sent, naming it.
@@ -903,7 +911,7 @@ A deploy token is the least privilege for packages alone: it reaches one project
903
911
 
904
912
  *The same version again.*
905
913
  GitLab allows duplicate packages by default: it accepts a different file under a version it already has, and serves that one from then on.
906
- So the channel reads every file back before uploading any: an identical file is left alone, a missing one is uploaded, and a different one fails `publish` (<<idempotent,Publishing the same version again §>>).
914
+ So the channel reads every file back before uploading any: a file with the same documents is left alone, a missing one is uploaded, and one with other documents fails `publish` (<<idempotent,Publishing the same version again §>>).
907
915
  Turning *Allow duplicates* off for generic packages, in the group's *Settings › Packages and registries*, makes GitLab refuse a replaced file too, from every client; the Publisher keeps a version immutable either way.
908
916
 
909
917
  *`release: true`* also creates the tag's GitLab release, tagging `$CI_COMMIT_SHA` or, outside CI, the checkout's `HEAD`, and links both files as packages.
@@ -932,7 +940,7 @@ channels:
932
940
  *The token* goes as a bearer token, `Authorization: Bearer <token>`, unless `tokenHeader` names another header, which then carries it as it is.
933
941
  GitLab's Maven registry takes a job token as `Job-Token`, a personal or project access token as `Private-Token`, and a deploy token as `Deploy-Token`; a GitLab Maven repository URL needs the project's numeric ID, since Gradle reads `%2F` in a path as `/`.
934
942
 
935
- *Every file is read back* before any is uploaded: identical is left alone, missing is uploaded, different fails `publish`, naming the file.
943
+ *Every file is read back* before any is uploaded: the artifact and the manifest with the same documents are left alone, a missing file is uploaded, and other documents fail `publish`, naming the file; the POM carries no documents, and one already there is left alone.
936
944
 
937
945
  *`maven-metadata.xml`* lists an artifact's versions, which is how Gradle resolves a version range and how Renovate and Dependabot see a new version.
938
946
  Nexus, Artifactory and GitHub Packages keep it themselves; GitLab does not for files uploaded this way.
@@ -1309,7 +1317,7 @@ A service subscribes to its own contract to implement it, and to the contracts o
1309
1317
 
1310
1318
  * Versions are per target, so a change to one service's fragments releases only that service's contract.
1311
1319
  A change to a fragment several targets share changes all of them, and `changed` names which, as <<closure,Independent versioning, and the dependency closure §>> describes.
1312
- * Publishing every target on every run is safe: a version that is already published, with the same bytes, is left alone (<<idempotent,Publishing the same version again §>>).
1320
+ * Publishing every target on every run is safe: a version that is already published, with the same documents, is left alone, whichever commit it was packed from (<<idempotent,Publishing the same version again §>>).
1313
1321
  What fails is a changed contract whose version was not changed, which is the mistake `changed` exists to catch first.
1314
1322
  * `landscape` is optional: a whole-landscape view for reading, generated on every build and never published, as <<aggregates,Aggregates §>> describes.
1315
1323
 
@@ -2035,7 +2043,7 @@ include::pipelines/github-actions.yml[]
2035
2043
 
2036
2044
  What the two steps lose, and how the pipeline makes up for it:
2037
2045
 
2038
- * *Idempotence.* A channel's remote mode leaves a version the registry has alone when it is identical, and refuses it when it differs; `npm publish` or `dotnet nuget push` of an existing version fails instead.
2046
+ * *Idempotence.* A channel's remote mode leaves a version the registry has alone when it holds the same documents, and refuses it when it does not; `npm publish` or `dotnet nuget push` of an existing version fails instead.
2039
2047
  Each publishing step checks for the version first, compares the bytes, and publishes only what is missing, so a re-run is safe and a real conflict still fails.
2040
2048
  * *Maven checksums and metadata.* The local Maven layout holds the artifact, its POM and its manifest, and no `.md5`, `.sha1` or `maven-metadata.xml`.
2041
2049
  `mvn deploy:deploy-file` writes those when it uploads, so the repository ends up as a remote `maven` channel would leave it, and Gradle resolves it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
5
5
  "license": "MIT",
6
6
  "author": "Arc-E-Tect",
package/src/channels.js CHANGED
@@ -17,6 +17,7 @@ const { ChannelError } = require("./channel-error");
17
17
  const { publishNuget } = require("./nuget");
18
18
  const { publishGitlabPackage } = require("./gitlab");
19
19
  const { refuseRemote } = require("./remote");
20
+ const { judgeHeld } = require("./held-documents");
20
21
 
21
22
  /** What every package and release says it is, unless `description` says otherwise. */
22
23
  const DEFAULT_DESCRIPTION = "API description documents for {target}.";
@@ -175,29 +176,29 @@ function publishNpm(archive, manifest, options, log) {
175
176
  return { location, distTag, name, files: [location] };
176
177
  }
177
178
 
178
- // Idempotent: a version the registry already has is left alone when it is the
179
- // same package, and refused when it is not -- npm never replaces a version.
179
+ // Idempotent: a version the registry already has is left alone when it holds the
180
+ // same documents, and refused when it does not -- npm never replaces a version.
180
181
  const registry = options.registry ? ["--registry", options.registry] : [];
181
182
  const [local] = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json"],
182
183
  { cwd: workDir, encoding: "utf8", stdio: "pipe" }));
183
184
  const published = npmPublishedDist(name, version, registry);
184
185
  if (published) {
185
186
  // The SHA-512 integrity when the registry records one; the tarball's SHA-1
186
- // otherwise, as GitLab's npm registry records only that.
187
+ // otherwise, as GitLab's npm registry records only that. The same hash is the
188
+ // same package; any other answer is settled by the documents in the registry's copy.
187
189
  const [held, mine] = published.integrity ? [published.integrity, local.integrity]
188
190
  : published.shasum ? [published.shasum, local.shasum] : [null, null];
189
- if (!held) {
190
- throw new ChannelError(
191
- `npm already has ${name}@${version}, and it cannot be compared: the registry records neither ` +
192
- "an integrity nor a shasum for it. A published version is never replaced; publish the change as a new version.");
193
- }
194
- if (held === mine) {
191
+ if (held && held === mine) {
195
192
  log(`-- npm ${name}@${version} is already published, identical; nothing to do`);
196
193
  return { location: name + "@" + version, distTag, name, alreadyPublished: true };
197
194
  }
198
- throw new ChannelError(
199
- `npm already has ${name}@${version}, and it is not this package (${held}, not ${mine}). ` +
195
+ const refused = (why) => new ChannelError(`npm already has ${name}@${version}, and it ${why}. ` +
200
196
  "A published version is never replaced; publish the change as a new version.");
197
+ const judged = judgeHeld(npmPublishedPackage(name, version, registry), "tgz", manifest);
198
+ if (judged.verdict === "unreadable") throw refused(`cannot be compared: ${judged.reason}`);
199
+ if (judged.verdict === "different") throw refused(held ? `is not this package (${held}, not ${mine})` : "is not this package");
200
+ log(`-- npm ${name}@${version} is already published, with the same documents; nothing to do`);
201
+ return { location: name + "@" + version, distTag, name, alreadyPublished: true };
201
202
  }
202
203
 
203
204
  const args = ["publish", "--tag", distTag, ...registry];
@@ -228,6 +229,24 @@ function npmPublishedDist(name, version, registry) {
228
229
  }
229
230
  }
230
231
 
232
+ /** The package npm holds as `name@version`, downloaded with the registry's own credentials. */
233
+ function npmPublishedPackage(name, version, registry) {
234
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "api-only-npm-held-"));
235
+ try {
236
+ let packed;
237
+ try {
238
+ packed = JSON.parse(execFileSync("npm", ["pack", `${name}@${version}`, "--pack-destination", dir, "--json", ...registry],
239
+ { encoding: "utf8", stdio: "pipe" }));
240
+ } catch (error) {
241
+ const output = `${error.stdout || ""}${error.stderr || ""}`.trim();
242
+ throw new ChannelError(`could not read ${name}@${version} back from npm to compare: ${output || error.message}`);
243
+ }
244
+ return fs.readFileSync(path.join(dir, packed[0].filename));
245
+ } finally {
246
+ fs.rmSync(dir, { recursive: true, force: true });
247
+ }
248
+ }
249
+
231
250
  /**
232
251
  * `github-release` -- attach the archive to a GitHub release.
233
252
  *
@@ -265,26 +284,29 @@ function publishGithubRelease(archive, manifest, options, log) {
265
284
  }
266
285
 
267
286
  // Idempotent, as the registries are: an asset the release already has is left alone
268
- // when it is the same bytes, and refused when it is not. Never --clobber: that would
269
- // replace a published contract under the same version.
287
+ // when it holds the same documents, and refused when it does not. Never --clobber: that
288
+ // would replace a published contract under the same version.
270
289
  const asset = path.basename(archive);
271
290
  const assets = exists
272
291
  ? gh(["release", "view", tag, "--repo", repository, "--json", "assets", "--jq", ".assets[].name"]).split("\n").map((a) => a.trim())
273
292
  : [];
274
293
  if (assets.includes(asset)) {
275
294
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "api-only-gh-"));
295
+ let held;
276
296
  try {
277
297
  gh(["release", "download", tag, "--repo", repository, "--pattern", asset, "--dir", dir]);
278
- if (fs.readFileSync(path.join(dir, asset)).equals(fs.readFileSync(archive))) {
279
- log(`-- ${repository} release ${tag} already has ${asset}, identical; nothing to do`);
280
- return { location: `${repository}@${tag}`, tag, alreadyPublished: true };
281
- }
298
+ held = fs.readFileSync(path.join(dir, asset));
282
299
  } finally {
283
300
  fs.rmSync(dir, { recursive: true, force: true });
284
301
  }
285
- throw new ChannelError(
286
- `${repository} release ${tag} already has ${asset}, and it is not this archive. ` +
302
+ const refused = (why) => new ChannelError(`${repository} release ${tag} already has ${asset}, and it ${why}. ` +
287
303
  "A published version is never replaced; publish the change as a new version.");
304
+ const judged = judgeHeld(held, "tgz", manifest);
305
+ if (judged.verdict === "unreadable") throw refused(`cannot be compared: ${judged.reason}`);
306
+ if (judged.verdict === "different") throw refused("is not this archive");
307
+ const identical = held.equals(fs.readFileSync(archive));
308
+ log(`-- ${repository} release ${tag} already has ${asset}, ${identical ? "identical" : "with the same documents"}; nothing to do`);
309
+ return { location: `${repository}@${tag}`, tag, alreadyPublished: true };
288
310
  }
289
311
  gh(["release", "upload", tag, archive, "--repo", repository]);
290
312
  log(`-- Published ${manifest.target} ${manifest.version} to ${repository} release ${tag}`);
@@ -408,41 +430,57 @@ function publishMavenRemote(archive, manifest, options, log) {
408
430
  const base = `${artifactDir}/${version}/${artifactId}-${version}`;
409
431
 
410
432
  const work = (async () => {
411
- // Idempotent, file by file: each file the repository already has is left alone when
412
- // it is the same bytes, and refuses the whole version when it is not. Every file is
413
- // read back before any is uploaded, so a run interrupted after the artifact is
414
- // completed by the next one, and a version that differs anywhere is not touched.
433
+ // Idempotent, by documents: each file the repository already has that carries them --
434
+ // the artifact and the manifest -- is left alone when it holds the same documents, and
435
+ // refuses the whole version when it does not; the POM carries none, and is left alone.
436
+ // Every file is read back before any is uploaded, so a run interrupted after the
437
+ // artifact is completed by the next one, and a version that differs anywhere is not
438
+ // touched.
415
439
  const files = [
416
- { url: `${base}.${extension}`, bytes: fs.readFileSync(archive), type: "application/octet-stream" },
440
+ { url: `${base}.${extension}`, bytes: fs.readFileSync(archive), type: "application/octet-stream", kind: "tgz" },
417
441
  { url: `${base}.pom`, type: "application/xml",
418
442
  bytes: Buffer.from(pom(options.groupId, artifactId, version, extension, pomDescription(options, manifest)), "utf8") },
419
- { url: `${base}-manifest.json`, type: "application/json",
443
+ { url: `${base}-manifest.json`, type: "application/json", kind: "json",
420
444
  bytes: Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8") },
421
445
  ];
446
+ const refused = (file, why) => new ChannelError(
447
+ `${options.repository} already has ${options.groupId}:${artifactId}:${version}, ` +
448
+ `and its ${file.url.split("/").pop()} ${why}. ` +
449
+ "A published version is never replaced; publish the change as a new version.");
422
450
  const missing = [];
451
+ let identical = true;
452
+ let heldArchiveManifest = null;
423
453
  for (const file of files) {
424
454
  const existing = await getFile(file.url, auth);
425
455
  if (!existing) {
426
456
  missing.push(file);
427
- } else if (!existing.equals(file.bytes)) {
428
- throw new ChannelError(
429
- `${options.repository} already has ${options.groupId}:${artifactId}:${version}, ` +
430
- `and its ${file.url.split("/").pop()} is not this one. ` +
431
- "A published version is never replaced; publish the change as a new version.");
457
+ continue;
432
458
  }
459
+ identical = identical && existing.equals(file.bytes);
460
+ if (!file.kind) continue;
461
+ const judged = judgeHeld(existing, file.kind, manifest);
462
+ if (judged.verdict === "unreadable") throw refused(file, `cannot be compared: ${judged.reason}`);
463
+ if (judged.verdict === "different") throw refused(file, "is not this one");
464
+ if (file.kind === "tgz") heldArchiveManifest = judged.held.bytes;
465
+ }
466
+ for (const file of missing) {
467
+ // A version completed beside an artifact from another commit gets that artifact's
468
+ // manifest, so the two agree on where the version came from.
469
+ const bytes = file.kind === "json" && heldArchiveManifest ? heldArchiveManifest : file.bytes;
470
+ await putFile(file.url, bytes, auth, file.type);
433
471
  }
434
- for (const file of missing) await putFile(file.url, file.bytes, auth, file.type);
435
472
  if (options.metadata !== false) {
436
473
  await updateMetadata(`${artifactDir}/maven-metadata.xml`, options.groupId, artifactId, manifest, auth, log);
437
474
  }
438
- return missing.length === 0;
475
+ return { alreadyPublished: missing.length === 0, identical };
439
476
  })();
440
477
 
441
478
  // The CLI is synchronous throughout; surfacing the promise here would make
442
479
  // every caller async for one channel's benefit.
443
- return work.then((alreadyPublished) => {
480
+ return work.then(({ alreadyPublished, identical }) => {
444
481
  log(alreadyPublished
445
- ? `-- ${options.groupId}:${artifactId}:${version} is already in ${options.repository}, identical; nothing to do`
482
+ ? `-- ${options.groupId}:${artifactId}:${version} is already in ${options.repository}, ` +
483
+ `${identical ? "identical" : "with the same documents"}; nothing to do`
446
484
  : `-- Deployed ${options.groupId}:${artifactId}:${version} to ${options.repository}`);
447
485
  return {
448
486
  location: `${base}.${extension}`, coordinates: `${options.groupId}:${artifactId}:${version}@${extension}`,
package/src/gitlab.js CHANGED
@@ -17,6 +17,7 @@ const { execFileSync } = require("child_process");
17
17
 
18
18
  const { isPrerelease } = require("./version-policy");
19
19
  const { ChannelError } = 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" };
@@ -84,26 +85,33 @@ async function readBack(fileUrl, name, headers) {
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 ChannelError(`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 ChannelError(`GitLab refused ${file.name}: ${answer}.` +
107
115
  (response.status === 401 || response.status === 403 ? TOKEN_HINT : ""));
108
116
  }
109
117
 
@@ -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) {
@@ -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
@@ -25,6 +25,7 @@ const { execFileSync } = require("child_process");
25
25
  const { parse, isPrerelease } = require("./version-policy");
26
26
  const { zip } = require("./zip");
27
27
  const { ChannelError } = 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";
@@ -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 ChannelError(`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
  /**
@@ -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,18 +287,11 @@ 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 ChannelError(`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
297
  throw new ChannelError(`the NuGet feed refused the package: ${response.status} ${response.statusText}. ` +
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 };
package/src/zip.js CHANGED
@@ -100,4 +100,45 @@ function zip(entries) {
100
100
  return Buffer.concat([...locals, directory, end]);
101
101
  }
102
102
 
103
- module.exports = { zip, crc32, ZipError };
103
+ /**
104
+ * The entries of a ZIP archive, from its central directory, each inflated and checked
105
+ * against its CRC-32: what `zip` writes, and what a NuGet feed returns of it.
106
+ *
107
+ * @param {Buffer} bytes
108
+ * @returns {Array<{name: string, data: Buffer}>}
109
+ */
110
+ function unzip(bytes) {
111
+ const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
112
+ if (end < 0 || end + 22 > bytes.length) throw new ZipError("not a ZIP archive: it has no end of central directory record");
113
+ const entries = [];
114
+ let offset = bytes.readUInt32LE(end + 16);
115
+ for (let i = 0; i < bytes.readUInt16LE(end + 10); i++) {
116
+ if (offset + 46 > bytes.length || bytes.readUInt32LE(offset) !== 0x02014b50) {
117
+ throw new ZipError(`not a ZIP archive: no central directory entry at ${offset}`);
118
+ }
119
+ const method = bytes.readUInt16LE(offset + 10);
120
+ const crc = bytes.readUInt32LE(offset + 16);
121
+ const compressed = bytes.readUInt32LE(offset + 20);
122
+ const nameLength = bytes.readUInt16LE(offset + 28);
123
+ const local = bytes.readUInt32LE(offset + 42);
124
+ const name = bytes.toString("utf8", offset + 46, offset + 46 + nameLength);
125
+ if (local + 30 > bytes.length || bytes.readUInt32LE(local) !== 0x04034b50) {
126
+ throw new ZipError(`not a ZIP archive: no local header for ${name}`);
127
+ }
128
+ const start = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28);
129
+ const raw = bytes.subarray(start, start + compressed);
130
+ let data;
131
+ try {
132
+ data = method === 8 ? zlib.inflateRawSync(raw) : method === 0 ? Buffer.from(raw) : null;
133
+ } catch (error) {
134
+ throw new ZipError(`${name} cannot be inflated: ${error.message}`);
135
+ }
136
+ if (data === null) throw new ZipError(`${name} is compressed with method ${method}, which is not supported`);
137
+ if (crc32(data) !== crc) throw new ZipError(`${name} does not match its CRC-32`);
138
+ entries.push({ name, data });
139
+ offset += 46 + nameLength + bytes.readUInt16LE(offset + 30) + bytes.readUInt16LE(offset + 32);
140
+ }
141
+ return entries;
142
+ }
143
+
144
+ module.exports = { zip, unzip, crc32, ZipError };