@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 +20 -12
- package/package.json +1 -1
- package/src/channels.js +72 -34
- package/src/gitlab.js +40 -22
- package/src/held-documents.js +91 -0
- package/src/nuget.js +33 -29
- package/src/tar.js +37 -1
- package/src/zip.js +42 -1
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.
|
|
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
|
|
703
|
+
`publish` is idempotent: publishing a version again with the same documents changes nothing, on every channel.
|
|
704
704
|
|
|
705
|
-
|
|
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
|
|
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
|
-
|
|
722
|
-
`
|
|
723
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
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
|
|
179
|
-
// same
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
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
|
-
|
|
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},
|
|
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
|
|
88
|
-
return new ChannelError(`GitLab already has ${name} in ${manifest.target} ${manifest.version}, and it
|
|
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
|
-
/**
|
|
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 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
|
-
|
|
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) {
|
|
@@ -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
|
-
|
|
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 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
|
|
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
|
|
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 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
|
-
|
|
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
|
-
|
|
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 };
|