@arc-e-tect/api-only-publisher 0.14.1 → 0.14.3

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.14.1
9
+ :api-only-publisher-version: 0.14.3
10
10
 
11
11
  [NOTE]
12
12
  .About this documentation
@@ -58,6 +58,7 @@ It need not be a deployable, which is why this is not called `services`.
58
58
 
59
59
  This document is the reference for the Publisher alone.
60
60
  To set it up for your situation, start at link:../docs/README.adoc[the documentation →].
61
+ For the configuration that fits a typical situation -- one API or several, one kind of consumer or many -- see <<recipes,Recipes §>>.
61
62
 
62
63
  [#requirements]
63
64
  == Requirements
@@ -676,6 +677,7 @@ What happens when the destination already has the version:
676
677
  |`maven` and `nuget` to a URL, `npm`, `github-release`
677
678
  |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.
678
679
  `github-release` compares the asset it would upload with the one the release has, and never overwrites one.
680
+ `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.
679
681
  A NuGet feed that cannot return the package it has, so there is nothing to compare, fails as a different one would.
680
682
  |===
681
683
 
@@ -755,10 +757,14 @@ A .NET build uses it with `dotnet nuget add source <repository>`, or a `nuget.co
755
757
 
756
758
  *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`.
757
759
  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.
758
- A feed that already has the version answers 409; the Publisher then reads the feed's copy back (its `PackageBaseAddress/3.0.0` resource) and succeeds when it is the same package, and fails when it is not, or cannot be read (<<idempotent,Publishing the same version again §>>).
760
+ The `PUT` goes to that endpoint with a trailing `/`, as `dotnet nuget push` sends it; a GitLab feed recognises the upload only there.
761
+ 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 §>>).
762
+ 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.
763
+ A feed that answers the push with 409 -- the version appeared in the meantime -- is read back and compared the same way.
759
764
  Without the variable set, the push fails before anything is sent, naming it.
760
- That is how nuget.org, GitHub Packages and self-hosted feeds such as BaGet take a push.
761
- A feed that authenticates some other way -- Azure Artifacts, which expects a credential provider or a token in an `Authorization` header -- is not supported yet; only the local-HTTP-stub tests exercise the push, not a real feed.
765
+ That is how nuget.org, GitHub Packages, GitLab and self-hosted feeds such as BaGet take a push.
766
+ A feed that authenticates some other way -- Azure Artifacts, which expects a credential provider or a token in an `Authorization` header -- is not supported yet.
767
+ The local-HTTP-stub tests exercise the push; a real feed, GitLab's, was checked by hand.
762
768
 
763
769
  *The package holds* the `.nuspec`, the two Open Packaging Conventions parts (`[Content_Types].xml` and `_rels/.rels`), and exactly what `pack` produced -- the documents and `manifest.json` -- extracted, never rebuilt, under `contracts/`:
764
770
 
@@ -981,6 +987,354 @@ When that version changes, the review is redone rather than rewritten, and the c
981
987
  Everything in the Publisher's own link:package.json[`package.json` →] is an ordinary dependency, left to a dependency updater: `yaml`, and semantic-release and its plugins, with which the Publisher is released.
982
988
  link:../README.adoc#dependency-updates[The repository's dependency updates →] list every project, and which of the two changes it.
983
989
 
990
+ [#recipes]
991
+ == Recipes
992
+
993
+ Configuring the Publisher is the easy part.
994
+ The unfamiliar part is the idea behind it: an API description is a product.
995
+ It has a version, it is released, and the teams and companies that build against it obtain it the way they obtain any other dependency, in the package format their own build already understands.
996
+
997
+ Two questions decide how a library is configured:
998
+
999
+ * *How many targets?*
1000
+ A target is one contract with a version of its own and consumers of its own: what gets shipped.
1001
+ * *How many channels?*
1002
+ A channel is one way of shipping it -- `file`, `maven`, `npm`, `nuget` or `github-release` -- chosen to suit the technology of whoever consumes it: how it reaches them.
1003
+
1004
+ Each recipe below answers both, for a situation in which the answer is typical.
1005
+ They are starting points, not boundaries: the situations describe where each shape usually turns up, and nothing stops a shape being used elsewhere.
1006
+
1007
+ [cols="2,1,1,2,4",options="header"]
1008
+ |===
1009
+ |Recipe |Targets |Channels |Every target on every channel? |Typical situation
1010
+
1011
+ |<<recipe-vanilla,Vanilla §>>
1012
+ |One
1013
+ |One
1014
+ |Yes
1015
+ |One API, consumed by builds that all use the same technology.
1016
+
1017
+ |<<recipe-microservices,Microservice architecture §>>
1018
+ |Several
1019
+ |One
1020
+ |Yes
1021
+ |Several services, each with its own contract, built by teams that share a technology.
1022
+
1023
+ |<<recipe-business-partners,Business partners §>>
1024
+ |One
1025
+ |Several
1026
+ |Yes
1027
+ |One API offered to third parties, each building its client with a different technology.
1028
+
1029
+ |<<recipe-platform,Platform §>>
1030
+ |Several
1031
+ |Several
1032
+ |Yes
1033
+ |A set of backend services that first and third parties build applications on, each with the technology of its choice.
1034
+
1035
+ |<<recipe-ecosystem,Eco-system §>>
1036
+ |Several
1037
+ |Several
1038
+ |No
1039
+ |Parties that each use some of the backend services, with technologies of their own.
1040
+ |===
1041
+
1042
+ [#recipes-two-facts]
1043
+ === Two facts every recipe rests on
1044
+
1045
+ *A channel is named after its type.*
1046
+ `channels.maven` is the Maven channel, `channels.npm` the npm channel, and so on, so one `apionly.yaml` has at most one channel of each type, with one destination each.
1047
+ Two consumers who both read Maven get the same Maven repository; <<recipe-business-partners,Business partners §>> shows what to do when they each need their own.
1048
+
1049
+ *`apionly.yaml` says which channels exist, not which target goes where.*
1050
+ Without `--channel`, `publish` ships every published target to every configured channel, packing each target once and sending the same bytes everywhere.
1051
+ `--target` and `--channel` narrow a run, and both may be repeated:
1052
+
1053
+ [source,console]
1054
+ ----
1055
+ npm run apionly -- publish --target payments --channel nuget
1056
+ ----
1057
+
1058
+ So when different targets go to different channels, the routing lives in the commands that run `publish`, as <<recipe-ecosystem,Eco-system §>> shows, and not in `apionly.yaml`.
1059
+
1060
+ The excerpts below show only `channels` and `targets`; the rest of `apionly.yaml` is as <<configuration,Configuration §>> describes, and as `init` writes it.
1061
+ Every published target also needs its version file, as <<versions,Versions §>> describes, and a `build` before its `publish`.
1062
+
1063
+ [#recipe-vanilla]
1064
+ === Vanilla: one target, one channel
1065
+
1066
+ *When.*
1067
+ One API, described in one library, and consumed by builds that all use the same technology.
1068
+ A single service whose clients are all Gradle or Maven builds; a library that lives in the same repository as its only implementation.
1069
+
1070
+ .apionly.yaml (excerpt)
1071
+ [source,yaml]
1072
+ ----
1073
+ channels:
1074
+ maven:
1075
+ groupId: com.example.contracts
1076
+ repository: https://maven.example.com/releases # a directory works too
1077
+ # tokenEnv: MAVEN_TOKEN # the variable holding the token
1078
+
1079
+ targets:
1080
+ orders:
1081
+ openapi:
1082
+ bundle: bundles/orders_openapi_structure.yaml
1083
+ ----
1084
+
1085
+ [source,console]
1086
+ ----
1087
+ npm run apionly -- build
1088
+ npm run apionly -- publish
1089
+ ----
1090
+
1091
+ This publishes `com.example.contracts:orders:<version>@tgz`, which a build fetches with the link:../api-only-subscriber/README.adoc[API-Only Subscriber →].
1092
+
1093
+ *Variations.*
1094
+
1095
+ * A library that lives in the same repository as its implementation needs no repository at all: the `file` channel, with `directory: build/publish`, is what `init` writes, and the Subscriber reads it from there.
1096
+ * A consumer that is not a JVM build takes a different channel in place of `maven`: `npm` for a Node.js or TypeScript build, `nuget` for .NET, `github-release` for anything else.
1097
+
1098
+ [#recipe-microservices]
1099
+ === Microservice architecture: several targets, one channel
1100
+
1101
+ *When.*
1102
+ Several services, each owning its own contract, and one library describing them all, so that a schema the services share is one fragment rather than several copies.
1103
+ The teams use the same technology, so one channel reaches all of them.
1104
+
1105
+ .apionly.yaml (excerpt)
1106
+ [source,yaml]
1107
+ ----
1108
+ channels:
1109
+ maven:
1110
+ groupId: com.example.contracts
1111
+ repository: https://maven.example.com/releases
1112
+
1113
+ targets:
1114
+ orders:
1115
+ openapi:
1116
+ bundle: bundles/orders_openapi_structure.yaml
1117
+ asyncapi:
1118
+ bundle: bundles/orders_asyncapi_structure.yaml
1119
+ payments:
1120
+ openapi:
1121
+ bundle: bundles/payments_openapi_structure.yaml
1122
+ shipping:
1123
+ openapi:
1124
+ bundle: bundles/shipping_openapi_structure.yaml
1125
+ landscape:
1126
+ openapi:
1127
+ aggregate: [orders, payments, shipping] # a view of all of them; never published
1128
+ ----
1129
+
1130
+ [source,console]
1131
+ ----
1132
+ npm run apionly -- build
1133
+ npm run apionly -- changed --since origin/main # the targets this branch changes
1134
+ npm run apionly -- publish
1135
+ ----
1136
+
1137
+ Each target is published as an artifact of its own, at the version in its own version file: `com.example.contracts:orders`, `com.example.contracts:payments` and `com.example.contracts:shipping`.
1138
+ A service subscribes to its own contract to implement it, and to the contracts of the services it calls, as a client.
1139
+
1140
+ *Worth knowing.*
1141
+
1142
+ * Versions are per target, so a change to one service's fragments releases only that service's contract.
1143
+ 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.
1144
+ * 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 §>>).
1145
+ What fails is a changed contract whose version was not changed, which is the mistake `changed` exists to catch first.
1146
+ * `landscape` is optional: a whole-landscape view for reading, generated on every build and never published, as <<aggregates,Aggregates §>> describes.
1147
+
1148
+ [#recipe-business-partners]
1149
+ === Business partners: one target, several channels
1150
+
1151
+ *When.*
1152
+ One API, offered to third parties who each build their own client: one in Java, one in TypeScript, one in C#, one in whatever it chose.
1153
+ Each of them should get the contract in the package format its own build understands, and all of them should get the same contract.
1154
+
1155
+ .apionly.yaml (excerpt)
1156
+ [source,yaml]
1157
+ ----
1158
+ channels:
1159
+ maven: # the partners that build on the JVM
1160
+ groupId: com.example.partner
1161
+ repository: https://maven.example.com/partner
1162
+ npm: # the partners that build with Node.js or TypeScript
1163
+ scope: "@example-partner"
1164
+ access: restricted
1165
+ nuget: # the partners that build with .NET
1166
+ idPrefix: Example.Partner
1167
+ repository: https://nuget.example.com/v3/index.json
1168
+ github-release: # everyone else: a download, no package manager needed
1169
+ repository: example/partner-api
1170
+
1171
+ targets:
1172
+ partner-api:
1173
+ openapi:
1174
+ bundle: bundles/partner-api_openapi_structure.yaml
1175
+ asyncapi:
1176
+ bundle: bundles/partner-api_asyncapi_structure.yaml
1177
+ ----
1178
+
1179
+ [source,console]
1180
+ ----
1181
+ npm run apionly -- build
1182
+ npm run apionly -- publish
1183
+ ----
1184
+
1185
+ One run publishes `com.example.partner:partner-api`, `@example-partner/partner-api`, `Example.Partner.PartnerApi` and the release `partner-api-v<version>`, all four from the same packed archive, so a Java partner and a .NET partner always read byte-identical documents.
1186
+
1187
+ *Worth knowing.*
1188
+
1189
+ * `--channel` publishes to one of them on its own, for instance when one partner's feed was unreachable during the release: `npm run apionly -- publish --channel nuget`.
1190
+ The other channels already have the version, and would leave it alone anyway.
1191
+ * A pre-release, `publish --pre-release rc.1`, is how a partner gets an unfinished contract to start building against, without any partner's production build picking it up by accident (<<pre-releases,Pre-releases §>>).
1192
+ * Each channel takes its credential from the environment, never from `apionly.yaml`: `MAVEN_TOKEN` and `NUGET_API_KEY` by default, npm's own configuration for `npm`, and the GitHub CLI's for `github-release` (<<channel-text,What a package says about itself §>>).
1193
+ * Partners that each need a repository of *the same type* -- two Maven repositories, one per partner -- cannot be two `maven` channels.
1194
+ Publish to a local destination instead, `repository` or `directory` set to a directory, and let the pipeline copy the files it lists in `packages.sha256` to each partner's repository, as <<handoff,The hand-off §>> describes.
1195
+
1196
+ [#recipe-platform]
1197
+ === Platform: several targets, several channels, every target on every channel
1198
+
1199
+ *When.*
1200
+ A set of backend services that others build applications on: the organisation's own teams, and third parties.
1201
+ Which services an application uses, and with which technology it is built, is up to whoever builds it, so every contract is offered in every format.
1202
+
1203
+ .apionly.yaml (excerpt)
1204
+ [source,yaml]
1205
+ ----
1206
+ channels:
1207
+ maven:
1208
+ groupId: com.example.platform
1209
+ repository: https://maven.example.com/platform
1210
+ npm:
1211
+ scope: "@example-platform"
1212
+ access: public
1213
+ nuget:
1214
+ idPrefix: Example.Platform
1215
+ repository: https://api.nuget.org/v3/index.json
1216
+
1217
+ targets:
1218
+ catalog:
1219
+ openapi:
1220
+ bundle: bundles/catalog_openapi_structure.yaml
1221
+ customers:
1222
+ openapi:
1223
+ bundle: bundles/customers_openapi_structure.yaml
1224
+ orders:
1225
+ openapi:
1226
+ bundle: bundles/orders_openapi_structure.yaml
1227
+ asyncapi:
1228
+ bundle: bundles/orders_asyncapi_structure.yaml
1229
+ payments:
1230
+ openapi:
1231
+ bundle: bundles/payments_openapi_structure.yaml
1232
+ platform:
1233
+ openapi:
1234
+ aggregate: [catalog, customers, orders, payments]
1235
+ ----
1236
+
1237
+ [source,console]
1238
+ ----
1239
+ npm run apionly -- build
1240
+ npm run apionly -- publish
1241
+ ----
1242
+
1243
+ The configuration is the <<recipe-microservices,microservice architecture §>>'s targets with the <<recipe-business-partners,business partners §>>' channels, and the plain `publish` ships every target to every channel.
1244
+
1245
+ *Worth knowing.*
1246
+
1247
+ * Adding a target to `targets` publishes it on every channel, and adding a channel publishes every target on it.
1248
+ For a platform that is the point; when it is not, the library is an <<recipe-ecosystem,eco-system §>>.
1249
+ * The `platform` aggregate is the one document that shows the whole platform, for a developer portal or a review; it is never published, so nobody can build against it by accident (<<aggregates-never-published,An aggregate is never published §>>).
1250
+ * A target that is not for others at all -- a service's internal administration API, say -- stays in the library with `publish: false`: it is built and linted with the rest, and shipped nowhere.
1251
+
1252
+ [#recipe-ecosystem]
1253
+ === Eco-system: several targets, several channels, not every target on every channel
1254
+
1255
+ *When.*
1256
+ Several parties, first and third, each use some of the backend services, and each builds with a technology of its own.
1257
+ The internal Java teams use all of them; the web front end, in TypeScript, uses the catalogue and the orders; a .NET payment provider uses the payments contract and nothing else.
1258
+ Offering every contract in every format would publish contracts to places nobody reads them, and offer a third party contracts it has no business with.
1259
+
1260
+ This is also the answer to a question that comes up as soon as a library holds more than one target: _how do I publish one of the targets to a different channel?_
1261
+
1262
+ Start from a routing table: which target goes to which channel.
1263
+
1264
+ [cols="1,1,1,1",options="header"]
1265
+ |===
1266
+ |Target |`maven` |`npm` |`nuget`
1267
+ |`catalog` |Yes |Yes |
1268
+ |`customers`|Yes | |
1269
+ |`orders` |Yes |Yes |
1270
+ |`payments` |Yes | |Yes
1271
+ |===
1272
+
1273
+ `apionly.yaml` declares the channels and the targets, as for a <<recipe-platform,platform §>>:
1274
+
1275
+ .apionly.yaml (excerpt)
1276
+ [source,yaml]
1277
+ ----
1278
+ channels:
1279
+ maven:
1280
+ groupId: com.example.services
1281
+ repository: https://maven.example.com/services
1282
+ npm:
1283
+ scope: "@example-web"
1284
+ access: restricted
1285
+ nuget:
1286
+ idPrefix: Example.Services
1287
+ repository: https://nuget.example.com/v3/index.json
1288
+
1289
+ targets:
1290
+ catalog:
1291
+ openapi:
1292
+ bundle: bundles/catalog_openapi_structure.yaml
1293
+ customers:
1294
+ openapi:
1295
+ bundle: bundles/customers_openapi_structure.yaml
1296
+ orders:
1297
+ openapi:
1298
+ bundle: bundles/orders_openapi_structure.yaml
1299
+ payments:
1300
+ openapi:
1301
+ bundle: bundles/payments_openapi_structure.yaml
1302
+ ----
1303
+
1304
+ The routing table becomes one `publish` per channel, each naming its targets.
1305
+ Keeping those commands as scripts in the library's `package.json` keeps the routing in one place, reviewed with the library, rather than spread across pipeline files:
1306
+
1307
+ .package.json (excerpt)
1308
+ [source,json]
1309
+ ----
1310
+ {
1311
+ "scripts": {
1312
+ "apionly": "api-only-publisher",
1313
+ "publish:maven": "api-only-publisher publish --channel maven",
1314
+ "publish:npm": "api-only-publisher publish --channel npm --target catalog --target orders",
1315
+ "publish:nuget": "api-only-publisher publish --channel nuget --target payments",
1316
+ "publish:all": "npm run publish:maven && npm run publish:npm && npm run publish:nuget"
1317
+ }
1318
+ }
1319
+ ----
1320
+
1321
+ [source,console]
1322
+ ----
1323
+ npm run apionly -- build
1324
+ npm run publish:all
1325
+ ----
1326
+
1327
+ `publish:maven` names no target, because every target goes there; a channel that takes only some names each of them.
1328
+
1329
+ *Worth knowing.*
1330
+
1331
+ * A new target goes only where a script sends it.
1332
+ `publish:maven` picks it up because it names no target; the npm and NuGet consumers do not see it until it is added to their script, which is the deliberate step an eco-system wants.
1333
+ * Each script passes its arguments on: `npm run publish:npm -- --pre-release rc.1` publishes a pre-release of the npm targets alone.
1334
+ * Each run packs again, and the archives are the same bytes every time (<<idempotent,Publishing the same version again §>>), so a target on two channels is identical on both.
1335
+ * Each run replaces the hand-off files in the packages directory (<<handoff,The hand-off §>>).
1336
+ A pipeline that publishes the files itself gives each run its own `--out`, such as `--out build/packages/npm`, and keeps every channel's list.
1337
+
984
1338
  [#behaviour]
985
1339
  == Behaviour worth knowing about
986
1340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.14.1",
3
+ "version": "0.14.3",
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
@@ -156,6 +156,9 @@ function publishNpm(archive, manifest, options, log) {
156
156
  ? { type: "git", url: manifest.source.repository }
157
157
  : undefined,
158
158
  }, null, 2) + "\n");
159
+ // npm packs each file with its mode, and extracting and writing them applied this
160
+ // process's umask: fixed modes keep the package the same bytes on every machine.
161
+ for (const file of fs.readdirSync(workDir)) fs.chmodSync(path.join(workDir, file), 0o644);
159
162
 
160
163
  const outDir = path.resolve(options.baseDir || ".", options.directory || "build/packages/npm");
161
164
  fs.mkdirSync(outDir, { recursive: true });
@@ -173,16 +176,25 @@ function publishNpm(archive, manifest, options, log) {
173
176
  // Idempotent: a version the registry already has is left alone when it is the
174
177
  // same package, and refused when it is not -- npm never replaces a version.
175
178
  const registry = options.registry ? ["--registry", options.registry] : [];
176
- const local = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json"],
177
- { cwd: workDir, encoding: "utf8", stdio: "pipe" }))[0].integrity;
178
- const published = npmPublishedIntegrity(name, version, registry);
179
- if (published === local) {
180
- log(`-- npm ${name}@${version} is already published, identical; nothing to do`);
181
- return { location: name + "@" + version, distTag, name, alreadyPublished: true };
182
- }
179
+ const [local] = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json"],
180
+ { cwd: workDir, encoding: "utf8", stdio: "pipe" }));
181
+ const published = npmPublishedDist(name, version, registry);
183
182
  if (published) {
183
+ // The SHA-512 integrity when the registry records one; the tarball's SHA-1
184
+ // otherwise, as GitLab's npm registry records only that.
185
+ const [held, mine] = published.integrity ? [published.integrity, local.integrity]
186
+ : published.shasum ? [published.shasum, local.shasum] : [null, null];
187
+ if (!held) {
188
+ throw new ChannelError(
189
+ `npm already has ${name}@${version}, and it cannot be compared: the registry records neither ` +
190
+ "an integrity nor a shasum for it. A published version is never replaced; publish the change as a new version.");
191
+ }
192
+ if (held === mine) {
193
+ log(`-- npm ${name}@${version} is already published, identical; nothing to do`);
194
+ return { location: name + "@" + version, distTag, name, alreadyPublished: true };
195
+ }
184
196
  throw new ChannelError(
185
- `npm already has ${name}@${version}, and it is not this package (${published}, not ${local}). ` +
197
+ `npm already has ${name}@${version}, and it is not this package (${held}, not ${mine}). ` +
186
198
  "A published version is never replaced; publish the change as a new version.");
187
199
  }
188
200
 
@@ -201,12 +213,12 @@ function publishNpm(archive, manifest, options, log) {
201
213
  }
202
214
  }
203
215
 
204
- /** The integrity npm records for `name@version`, or null when it has no such version. */
205
- function npmPublishedIntegrity(name, version, registry) {
216
+ /** The `dist` npm records for `name@version` -- integrity, shasum, tarball -- or null when it has no such version. */
217
+ function npmPublishedDist(name, version, registry) {
206
218
  try {
207
- const out = execFileSync("npm", ["view", `${name}@${version}`, "dist.integrity", ...registry],
219
+ const out = execFileSync("npm", ["view", `${name}@${version}`, "dist", "--json", ...registry],
208
220
  { encoding: "utf8", stdio: "pipe" }).trim();
209
- return out || null;
221
+ return out ? JSON.parse(out) : null;
210
222
  } catch (error) {
211
223
  const output = `${error.stdout || ""}${error.stderr || ""}`;
212
224
  if (/E404/.test(output)) return null;
@@ -338,24 +350,31 @@ function publishMavenRemote(archive, manifest, options, log) {
338
350
  `${options.groupId.split(".").join("/")}/${artifactId}/${version}/${artifactId}-${version}`;
339
351
 
340
352
  const work = (async () => {
341
- // Idempotent: an artifact the repository already has is left alone when it is
342
- // the same bytes, and refused when it is not.
343
- const bytes = fs.readFileSync(archive);
344
- const existing = await getFile(`${base}.${extension}`, token);
345
- if (existing) {
346
- if (existing.equals(bytes)) return true;
347
- throw new ChannelError(
348
- `${options.repository} already has ${options.groupId}:${artifactId}:${version}, and it is not this archive. ` +
349
- "A published version is never replaced; publish the change as a new version.");
353
+ // Idempotent, file by file: each file the repository already has is left alone when
354
+ // it is the same bytes, and refuses the whole version when it is not. Every file is
355
+ // read back before any is uploaded, so a run interrupted after the artifact is
356
+ // completed by the next one, and a version that differs anywhere is not touched.
357
+ const files = [
358
+ { url: `${base}.${extension}`, bytes: fs.readFileSync(archive), type: "application/octet-stream" },
359
+ { url: `${base}.pom`, type: "application/xml",
360
+ bytes: Buffer.from(pom(options.groupId, artifactId, version, extension, pomDescription(options, manifest)), "utf8") },
361
+ { url: `${base}-manifest.json`, type: "application/json",
362
+ bytes: Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8") },
363
+ ];
364
+ const missing = [];
365
+ for (const file of files) {
366
+ const existing = await getFile(file.url, token);
367
+ if (!existing) {
368
+ missing.push(file);
369
+ } else if (!existing.equals(file.bytes)) {
370
+ throw new ChannelError(
371
+ `${options.repository} already has ${options.groupId}:${artifactId}:${version}, ` +
372
+ `and its ${file.url.split("/").pop()} is not this one. ` +
373
+ "A published version is never replaced; publish the change as a new version.");
374
+ }
350
375
  }
351
- await putFile(`${base}.${extension}`, bytes, token, "application/octet-stream");
352
- await putFile(`${base}.pom`,
353
- Buffer.from(pom(options.groupId, artifactId, version, extension, pomDescription(options, manifest)), "utf8"),
354
- token, "application/xml");
355
- await putFile(`${base}-manifest.json`,
356
- Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8"),
357
- token, "application/json");
358
- return false;
376
+ for (const file of missing) await putFile(file.url, file.bytes, token, file.type);
377
+ return missing.length === 0;
359
378
  })();
360
379
 
361
380
  // The CLI is synchronous throughout; surfacing the promise here would make
package/src/nuget.js CHANGED
@@ -184,20 +184,41 @@ async function feedResources(index) {
184
184
  return { publish, content: find(CONTENT_RESOURCE) };
185
185
  }
186
186
 
187
+ /** Where the feed's `PackageBaseAddress/3.0.0` resource serves this version's package. */
188
+ function contentUrl(content, id, version) {
189
+ const lowerId = id.toLowerCase();
190
+ const lowerVersion = String(version).toLowerCase();
191
+ return `${content.replace(/\/?$/, "/")}${lowerId}/${lowerVersion}/${lowerId}.${lowerVersion}.nupkg`;
192
+ }
193
+
187
194
  /**
188
195
  * Whether the feed's copy of this version is these bytes: true or false, or null when the
189
196
  * feed offers no way to read it back.
190
197
  */
191
198
  async function sameAsPublished(content, id, version, bytes, key) {
192
199
  if (!content) return null;
193
- const lowerId = id.toLowerCase();
194
- const lowerVersion = String(version).toLowerCase();
195
- const url = `${content.replace(/\/?$/, "/")}${lowerId}/${lowerVersion}/${lowerId}.${lowerVersion}.nupkg`;
196
- const response = await fetch(url, { headers: { "X-NuGet-ApiKey": key } });
200
+ const response = await fetch(contentUrl(content, id, version), { headers: { "X-NuGet-ApiKey": key } });
197
201
  if (!response.ok) return null;
198
202
  return Buffer.from(await response.arrayBuffer()).equals(bytes);
199
203
  }
200
204
 
205
+ /**
206
+ * The feed's copy of this version, read before the push: its bytes, or null when the feed
207
+ * does not have it. Anything else is reported rather than pushed over, since a push could
208
+ * then replace a version the feed does have.
209
+ */
210
+ async function publishedCopy(content, id, version, key) {
211
+ const url = contentUrl(content, id, version);
212
+ const response = await fetch(url, { headers: { "X-NuGet-ApiKey": key } });
213
+ if (response.status === 404) return null;
214
+ if (!response.ok) {
215
+ throw new ChannelError(`could not read ${id} ${version} back from ${url}: ${response.status} ${response.statusText}`.trim() +
216
+ (response.status === 401 || response.status === 403
217
+ ? ". Check the key in the environment variable named by channels.nuget.tokenEnv." : ""));
218
+ }
219
+ return Buffer.from(await response.arrayBuffer());
220
+ }
221
+
201
222
  /**
202
223
  * Push the package to a remote feed: one PUT to the endpoint the service index names,
203
224
  * the package as the first part of a multipart body, the key in X-NuGet-ApiKey.
@@ -216,7 +237,28 @@ function publishRemote(archive, manifest, options, id, log) {
216
237
  const { bytes } = nupkg(archive, id, manifest, options);
217
238
 
218
239
  return (async () => {
219
- const { publish: endpoint, content } = await feedResources(options.repository);
240
+ const { publish: resource, content } = await feedResources(options.repository);
241
+ // With a trailing slash, as `dotnet nuget push` sends it: a GitLab feed recognises
242
+ // the upload only at that form of the URL, and refuses the other as invalid.
243
+ const endpoint = resource.replace(/\/?$/, "/");
244
+
245
+ // Idempotent before the push, not only after a 409: a feed that allows duplicate
246
+ // versions -- GitLab's default -- accepts a different package under a version it
247
+ // has, and serves that one from then on.
248
+ if (content) {
249
+ 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
+ }
261
+ }
220
262
  const boundary = `api-only-${crypto.createHash("sha256").update(bytes).digest("hex").slice(0, 32)}`;
221
263
  const body = Buffer.concat([
222
264
  Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="package"; filename="package.nupkg"\r\n` +