@arc-e-tect/api-only-publisher 0.14.1 → 0.14.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 +358 -4
- package/package.json +1 -1
- package/src/channels.js +24 -12
- package/src/nuget.js +47 -5
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.
|
|
9
|
+
:api-only-publisher-version: 0.14.2
|
|
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
|
-
|
|
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
|
|
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
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" }))
|
|
178
|
-
const published =
|
|
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 (${
|
|
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
|
|
205
|
-
function
|
|
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
|
|
219
|
+
const out = execFileSync("npm", ["view", `${name}@${version}`, "dist", "--json", ...registry],
|
|
208
220
|
{ encoding: "utf8", stdio: "pipe" }).trim();
|
|
209
|
-
return out
|
|
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;
|
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
|
|
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:
|
|
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` +
|