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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.4
9
+ :api-only-publisher-version: 0.16.0
10
10
 
11
11
  [NOTE]
12
12
  .About this documentation
@@ -182,7 +182,7 @@ At a terminal it asks the same shape of question `init` would, offering what is
182
182
  Without `--target`, it also fails on a fragment no target reaches, as <<unreferenced,A fragment no target reaches fails the lint §>> describes.
183
183
 
184
184
  |`targets`
185
- |List the declared targets, what each one builds, and whether it is published.
185
+ |List the declared targets, what each one builds, whether it is published, and to which channels.
186
186
 
187
187
  |`closure [--target <t>]...`
188
188
  |Show each target's dependency closure: how many files it reaches, and the hash of their content.
@@ -195,7 +195,7 @@ A target whose closure is untouched needs no new version, however much else in t
195
195
  |Archive each built, published target with a `manifest.json`, at the version its version file declares.
196
196
 
197
197
  |`publish [--pre-release <ids>] [--target <t>]... [--channel <c>]... [--out <dir>]`
198
- |Pack once, then ship those same bytes to every configured channel, or to each `--channel`.
198
+ |Pack each target once, then ship those same bytes to each of its channels, or only to those of them each `--channel` names (<<channel-routing,Which channels a target goes to §>>).
199
199
 
200
200
  |`split [--out <dir>] [--by kind\|target]`
201
201
  |Make the library safe to break into separate repositories.
@@ -641,7 +641,7 @@ Not a distribution mechanism between repositories: it makes the pipeline testabl
641
641
  Publishing a version again writes it again and keeps the others (<<idempotent,Publishing the same version again §>>); with `clean: true` only the version being published is kept, for a directory that is a build output rather than a record of releases.
642
642
 
643
643
  |`maven`
644
- |A path publishes into a repository layout on disk; an `http(s)` URL deploys to a remote repository with an HTTP `PUT` per file and a bearer token.
644
+ |A path publishes into a repository layout on disk; an `http(s)` URL deploys to a remote repository with an HTTP `PUT` per file, and keeps its `maven-metadata.xml` (<<maven-remote,Publishing to a remote Maven repository §>>).
645
645
  Native for Gradle consumers: version resolution, caching and conflict handling come free, which is what lets the Subscriber carry no HTTP client of its own.
646
646
 
647
647
  |`npm`
@@ -651,11 +651,52 @@ Native for Gradle consumers: version resolution, caching and conflict handling c
651
651
  |The language-neutral floor: immutable per tag, works for private repositories with a token, readable by a consumer with no JVM and no Node.js.
652
652
  No dependency-resolution semantics and no update notification, which is why it is the fallback rather than the default.
653
653
 
654
+ |`gitlab-package`
655
+ |A GitLab project's Generic Package Registry: the archive and `manifest.json` as a generic package per target, on gitlab.com or a self-managed GitLab.
656
+ In a GitLab CI job it needs no settings at all. See <<gitlab-package,The `gitlab-package` channel §>>.
657
+
654
658
  |`nuget`
655
659
  |A path writes a `.nupkg` into a local feed; an `http(s)` URL -- the feed's service index -- pushes to a remote one.
656
660
  Native for .NET consumers: a `PackageReference` at a version, restored from a feed. See <<nuget,The `nuget` channel §>>.
657
661
  |===
658
662
 
663
+ [#channel-routing]
664
+ === Which channels a target goes to
665
+
666
+ A channel has a *name*, the key under `channels`, and a *type*, one of those above.
667
+ A channel named after a type is that type; any other name -- lowercase words joined by single hyphens, such as `partner-a` -- says its type with `type`.
668
+ So one `apionly.yaml` can hold several channels of one type, each with its own destination, settings and token:
669
+
670
+ [source,yaml]
671
+ ----
672
+ channels:
673
+ maven: # a Maven channel, named after its type
674
+ groupId: com.example.api
675
+ repository: https://maven.example.com/internal
676
+ partner-a: # another one, for one partner
677
+ type: maven
678
+ groupId: com.example.partner
679
+ repository: https://maven.partner-a.example
680
+ tokenEnv: PARTNER_A_TOKEN
681
+
682
+ targets:
683
+ orders:
684
+ channels: [maven, partner-a] # published to these two
685
+ openapi:
686
+ bundle: bundles/orders_openapi_structure.yaml
687
+ billing: # no list: every configured channel
688
+ openapi:
689
+ bundle: bundles/billing_openapi_structure.yaml
690
+ ----
691
+
692
+ * A target with a `channels` list is published to those channels; a target without one, to every configured channel.
693
+ * Everything is checked when `apionly.yaml` is read: a channel name that is not kebab-case, a name that is not a type without a `type`, a `type` that is not one, a type name given another `type`, and a target that lists a channel `apionly.yaml` does not configure, lists one twice, lists none (`channels: []`, where `publish: false` is meant), or is never published -- `publish: false`, or an aggregate.
694
+ * `--channel` narrows each target to the named channels it is routed to.
695
+ A target that is not routed to one is skipped, and `publish` says so: `payments: not published to npm (not in targets.payments.channels)`.
696
+ A run that would publish nothing at all fails instead of succeeding quietly.
697
+ * A `--channel` that `apionly.yaml` does not configure is refused, unless it is a type's name: `--channel github-release` is that channel with no settings, as it has always been.
698
+ * `targets` lists each published target with its channels.
699
+
659
700
  [#idempotent]
660
701
  === Publishing the same version again
661
702
 
@@ -674,13 +715,46 @@ What happens when the destination already has the version:
674
715
  |`file`; `maven` and `nuget` to a directory; `npm` with `publish: false`
675
716
  |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.
676
717
 
677
- |`maven` and `nuget` to a URL, `npm`, `github-release`
718
+ |`maven` and `nuget` to a URL, `npm`, `github-release`, `gitlab-package`
678
719
  |The same bytes: nothing is sent, and the log says `already published, identical`. Different bytes: `publish` fails, naming the version -- a published version is never replaced, so publish the change as a new version.
720
+ `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.
679
721
  `github-release` compares the asset it would upload with the one the release has, and never overwrites one.
680
722
  `npm` compares the integrity hash the registry records, or the tarball's SHA-1 where it records only that, as GitLab's does; its files are packed with fixed modes, so the package is the same bytes whatever the umask of the machine that publishes it.
681
723
  A NuGet feed that cannot return the package it has, so there is nothing to compare, fails as a different one would.
682
724
  |===
683
725
 
726
+ [#remote-switch]
727
+ === Refusing remote publishing
728
+
729
+ A channel's *local mode* writes files: `file`, `maven` and `nuget` to a directory, `npm` with `publish: false`.
730
+ Its *remote mode* sends them somewhere: `maven` and `nuget` to a URL, `npm` with publishing, `github-release`, `gitlab-package`.
731
+
732
+ `API_ONLY_PUBLISHER_REMOTE` decides whether `publish` may use a remote mode:
733
+
734
+ [cols="1,3", options="header"]
735
+ |===
736
+ | Value | Effect
737
+
738
+ | `allow`, or unset
739
+ | Every channel works. The default outside the Docker image.
740
+
741
+ | `never`
742
+ | `publish` refuses a run that would use any remote channel, naming each of them, before it reads a version or packs anything; the local modes work as always.
743
+ The Publisher then contacts nothing. The default in the Docker image (<<docker-network,Nothing phones home §>>).
744
+ |===
745
+
746
+ [source,text]
747
+ ----
748
+ Error: API_ONLY_PUBLISHER_REMOTE=never refuses the remote channels maven, github-release, so nothing was built, packed or sent. Publish with their local modes, or set API_ONLY_PUBLISHER_REMOTE=allow where this run may contact them.
749
+ ----
750
+
751
+ Any other value fails `publish`, whatever its channels: a typo in a switch that keeps the Publisher sealed is not ignored.
752
+ Only the channels a run would publish to are checked, by type: a channel named `partner-a` with `type: maven` and a URL is remote; `publish --channel file`, or a target routed only to local channels, succeeds under `never`, whatever else `apionly.yaml` configures.
753
+ The Publisher used as a library keeps the same rule.
754
+
755
+ The switch is a promise the Publisher keeps, not an enforcement: whoever runs it can change the variable.
756
+ Where contacting nothing must be guaranteed, isolate the job's network on the platform as well -- `docker run --network none`, or a network policy for the runner.
757
+
684
758
  [#handoff]
685
759
  === The hand-off: what a `publish` run wrote
686
760
 
@@ -691,7 +765,7 @@ Every `publish` run lists each file its channels wrote locally, with its SHA-256
691
765
  | File | Content
692
766
 
693
767
  | `packages.json`
694
- | `schemaVersion`, and `files`: for each file, its `path`, `sha256`, `target`, `version` and `channel`.
768
+ | `schemaVersion`, and `files`: for each file, its `path`, `sha256`, `target`, `version`, `channel` -- the channel's name -- and `type`, the channel's type.
695
769
 
696
770
  | `packages.sha256`
697
771
  | The same files in the format `sha256sum -c` checks.
@@ -706,15 +780,15 @@ The hand-off is for a later step that publishes those files itself -- the second
706
780
  [#channel-text]
707
781
  === What a package says about itself
708
782
 
709
- The Maven POM, the npm `package.json`, the NuGet `.nuspec` and a GitHub release all describe the contract.
783
+ The Maven POM, the npm `package.json`, the NuGet `.nuspec`, a GitHub release and a GitLab release all describe the contract.
710
784
  By default each says `API description documents for <target>.`, and a GitHub release is titled `<target> <version>`.
711
- `channels.<channel>.description` replaces the description, and `channels.github-release.title` and `notes` the release's title and notes (notes default to the description); `{target}` and `{version}` are filled in, as in `tagFormat`.
785
+ `channels.<channel>.description` replaces the description, and `title` and `notes` on `channels.github-release` and `channels.gitlab-package` the release's title and notes (notes default to the description); `{target}` and `{version}` are filled in, as in `tagFormat`.
712
786
 
713
787
  npm publishes a release under the dist-tag `latest` and a pre-release under `next`.
714
788
  `channels.npm.releaseTag` and `prereleaseTag` name others: publishing a patch to an older line under `releaseTag: v1` keeps `latest` on the newest line, which publishing it under `latest` would move back.
715
789
 
716
790
  Credentials are read from the environment, never from configuration.
717
- `channels.maven.tokenEnv` names the variable, `MAVEN_TOKEN` by default, and `channels.nuget.tokenEnv` the NuGet API key's, `NUGET_API_KEY` by default; a configuration file gets committed, and a credential in a committed file is a credential that has leaked.
791
+ `channels.maven.tokenEnv` names the variable, `MAVEN_TOKEN` by default, `channels.nuget.tokenEnv` the NuGet API key's, `NUGET_API_KEY` by default, and `channels.gitlab-package.tokenEnv` the GitLab token's, `GITLAB_TOKEN` by default, with `CI_JOB_TOKEN` when that is unset; a configuration file gets committed, and a credential in a committed file is a credential that has leaked.
718
792
 
719
793
  link:../docs/reference/how-publisher-and-subscriber-work-together.adoc#channels[How the Publisher and the Subscriber work together →] lists each channel's layout and options.
720
794
 
@@ -784,6 +858,88 @@ Until a `.targets` file places and verifies the documents for a .NET build, a co
784
858
  The same packed archive always produces a byte-identical package: entries in a fixed order, a fixed timestamp.
785
859
  Since `pack` is reproducible, publishing the same commit again produces the same package too, so a republish to a local feed can be checked as unchanged.
786
860
 
861
+ [#gitlab-package]
862
+ === The `gitlab-package` channel
863
+
864
+ It publishes each target to a GitLab project's Generic Package Registry, on gitlab.com or a self-managed GitLab, without any GitLab tooling: a package is a few HTTP requests the Publisher sends itself.
865
+
866
+ [source,yaml]
867
+ ----
868
+ channels:
869
+ gitlab-package: # in a GitLab CI job, nothing below is needed
870
+ # url: https://gitlab.example.com/api/v4 # default: $CI_API_V4_URL, else https://gitlab.com/api/v4
871
+ # project: group/subgroup/api-library # an ID or a path; default: $CI_PROJECT_ID
872
+ # tokenEnv: GITLAB_TOKEN # default; when unset, $CI_JOB_TOKEN is used
873
+ # tokenType: private # private (default), deploy or job
874
+ # packageName: "{target}" # default
875
+ # release: false # true: also the tag's GitLab release
876
+ # tagFormat: "{target}-v{version}" # for release: true
877
+ # title, notes, description # as for github-release
878
+ ----
879
+
880
+ *The package* is named after the target, at the contract's version, and holds two files: `<target>-<version>.tgz` and `manifest.json`, the ones the `file` channel writes.
881
+ A consumer downloads them from `<url>/projects/<project>/packages/generic/<target>/<version>/<file>`, or from the project's *Deploy › Package registry* page.
882
+
883
+ *In a GitLab CI job* the channel needs no settings: the API URL, the project and the job's token come from GitLab's predefined variables `CI_API_V4_URL`, `CI_PROJECT_ID` and `CI_JOB_TOKEN`.
884
+ link:pipelines/gitlab-ci.yml[The reference pipeline →] publishes this way, from the Docker image, in one job (<<docker-gitlab,Reference pipeline: GitLab CI §>>).
885
+
886
+ *The credential* is the first of these the environment holds:
887
+
888
+ [cols="2,2,3", options="header"]
889
+ |===
890
+ | Variable | Sent as | For
891
+
892
+ | The one `tokenEnv` names, `GITLAB_TOKEN` by default
893
+ | `PRIVATE-TOKEN`; with `tokenType: deploy`, `DEPLOY-TOKEN`; with `tokenType: job`, `JOB-TOKEN`
894
+ | A personal or project access token with the `api` scope, or a deploy token with `write_package_registry` (and `read_package_registry`, for the read-back).
895
+
896
+ | `CI_JOB_TOKEN`
897
+ | `JOB-TOKEN`
898
+ | A GitLab CI job publishing to its own project.
899
+ |===
900
+
901
+ Without either, `publish` fails before anything is sent, naming both.
902
+ A deploy token is the least privilege for packages alone: it reaches one project's package registry and nothing else.
903
+
904
+ *The same version again.*
905
+ GitLab allows duplicate packages by default: it accepts a different file under a version it already has, and serves that one from then on.
906
+ So the channel reads every file back before uploading any: an identical file is left alone, a missing one is uploaded, and a different one fails `publish` (<<idempotent,Publishing the same version again §>>).
907
+ 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
+
909
+ *`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.
910
+ An existing release keeps everything it has, and only gets the links it lacks.
911
+ A pre-release publishes the package and no release: GitLab has no pre-release flag, so the release would become the project's latest.
912
+ A deploy token cannot create a release -- GitLab gives it the package registry only -- so `release: true` with `tokenType: deploy` is refused before anything is sent.
913
+
914
+ link:../docs/guides/publishing-to-gitlab/overview.adoc[Publishing to GitLab →] covers the rest: job-token permissions, self-managed instances and their certificates, and how consumers read the packages.
915
+
916
+ [#maven-remote]
917
+ === Publishing to a remote Maven repository
918
+
919
+ An `http(s)` `repository` deploys with an HTTP `PUT` per file: the artifact, its POM, and `-manifest.json`.
920
+
921
+ [source,yaml]
922
+ ----
923
+ channels:
924
+ maven:
925
+ groupId: com.example.api
926
+ repository: https://gitlab.example.com/api/v4/projects/1234/packages/maven
927
+ tokenEnv: CI_JOB_TOKEN # default: MAVEN_TOKEN
928
+ tokenHeader: Job-Token # default: Authorization, as a bearer token
929
+ # metadata: false # leave maven-metadata.xml to a repository that generates it
930
+ ----
931
+
932
+ *The token* goes as a bearer token, `Authorization: Bearer <token>`, unless `tokenHeader` names another header, which then carries it as it is.
933
+ 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
+
935
+ *Every file is read back* before any is uploaded: identical is left alone, missing is uploaded, different fails `publish`, naming the file.
936
+
937
+ *`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
+ Nexus, Artifactory and GitHub Packages keep it themselves; GitLab does not for files uploaded this way.
939
+ So after a version's files, the channel adds the version to it, as `mvn deploy` does: `latest` is the highest version, `release` the highest that is not a pre-release, and `lastUpdated` the time of the commit, so the same commit writes the same file.
940
+ It is written only when it lacks the version, or its `latest` or `release` would change.
941
+ A repository that refuses it fails `publish`, naming `metadata: false`; the version's files are published by then, and a re-run retries only the metadata.
942
+
787
943
  [#why-registries]
788
944
  === Why npm, Maven and NuGet rather than release assets alone
789
945
 
@@ -871,6 +1027,7 @@ channels:
871
1027
 
872
1028
  targets:
873
1029
  orders:
1030
+ # channels: [file] # the channels it is published to; default: every one
874
1031
  # versionFile: versions/orders.properties
875
1032
  # default: orders.bundle.properties beside the first bundle root
876
1033
  openapi:
@@ -999,7 +1156,7 @@ Two questions decide how a library is configured:
999
1156
  * *How many targets?*
1000
1157
  A target is one contract with a version of its own and consumers of its own: what gets shipped.
1001
1158
  * *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.
1159
+ A channel is one way of shipping it -- `file`, `maven`, `npm`, `nuget`, `github-release` or `gitlab-package` -- chosen to suit the technology of whoever consumes it: how it reaches them.
1003
1160
 
1004
1161
  Each recipe below answers both, for a situation in which the answer is typical.
1005
1162
  They are starting points, not boundaries: the situations describe where each shape usually turns up, and nothing stops a shape being used elsewhere.
@@ -1042,20 +1199,30 @@ They are starting points, not boundaries: the situations describe where each sha
1042
1199
  [#recipes-two-facts]
1043
1200
  === Two facts every recipe rests on
1044
1201
 
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.
1202
+ *A channel has a name and a type.*
1203
+ A channel named after a type is that type -- `channels.maven` is a Maven channel -- and any other name says its type with `type`, so one `apionly.yaml` can hold two channels of one type, each with a destination of its own:
1048
1204
 
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:
1205
+ [source,yaml]
1206
+ ----
1207
+ channels:
1208
+ partner-a: { type: maven, groupId: com.example.partner, repository: https://maven.partner-a.example }
1209
+ partner-b: { type: maven, groupId: com.example.partner, repository: https://maven.partner-b.example }
1210
+ ----
1052
1211
 
1053
- [source,console]
1212
+ *`apionly.yaml` says which target goes where.*
1213
+ A target with a `channels` list is published to those channels; a target without one is published to every configured channel.
1214
+ `publish` packs each target once, and sends the same bytes to each of its channels:
1215
+
1216
+ [source,yaml]
1054
1217
  ----
1055
- npm run apionly -- publish --target payments --channel nuget
1218
+ targets:
1219
+ payments:
1220
+ channels: [partner-a]
1221
+ openapi:
1222
+ bundle: bundles/payments_openapi_structure.yaml
1056
1223
  ----
1057
1224
 
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`.
1225
+ `--target` and `--channel` narrow a run, and both may be repeated; `--channel` publishes each target only to the named channels it is routed to, and says which targets it skipped (<<channel-routing,Which channels a target goes to §>>).
1059
1226
 
1060
1227
  The excerpts below show only `channels` and `targets`; the rest of `apionly.yaml` is as <<configuration,Configuration §>> describes, and as `init` writes it.
1061
1228
  Every published target also needs its version file, as <<versions,Versions §>> describes, and a `build` before its `publish`.
@@ -1093,7 +1260,8 @@ This publishes `com.example.contracts:orders:<version>@tgz`, which a build fetch
1093
1260
  *Variations.*
1094
1261
 
1095
1262
  * 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.
1263
+ * 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` or `gitlab-package` for anything else.
1264
+ * A library hosted on GitLab publishes to its own project's registries: `gitlab-package`, which needs no settings in a GitLab CI job, and `maven` to the project's Maven registry with `tokenHeader: Job-Token` (<<gitlab-package,The `gitlab-package` channel §>>).
1097
1265
 
1098
1266
  [#recipe-microservices]
1099
1267
  === Microservice architecture: several targets, one channel
@@ -1189,9 +1357,10 @@ One run publishes `com.example.partner:partner-api`, `@example-partner/partner-a
1189
1357
  * `--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
1358
  The other channels already have the version, and would leave it alone anyway.
1191
1359
  * 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.
1360
+ * Each channel takes its credential from the environment, never from `apionly.yaml`: `MAVEN_TOKEN`, `NUGET_API_KEY` and `GITLAB_TOKEN` by default, npm's own configuration for `npm`, and the GitHub CLI's for `github-release` (<<channel-text,What a package says about itself §>>).
1361
+ * A partner without a package manager gets a download: `github-release` where the library is on GitHub, `gitlab-package` where it is on GitLab.
1362
+ * Partners that each need a repository of *the same type* -- two Maven repositories, one per partner -- are two channels with names of their own and `type: maven`, as <<recipes-two-facts,the two facts §>> show.
1363
+ * A partner that may see only some contracts gets only those: list its channel on those targets (<<recipe-ecosystem,Eco-system §>>).
1195
1364
 
1196
1365
  [#recipe-platform]
1197
1366
  === Platform: several targets, several channels, every target on every channel
@@ -1270,7 +1439,7 @@ Start from a routing table: which target goes to which channel.
1270
1439
  |`payments` |Yes | |Yes
1271
1440
  |===
1272
1441
 
1273
- `apionly.yaml` declares the channels and the targets, as for a <<recipe-platform,platform §>>:
1442
+ The routing table goes into `apionly.yaml` as it stands: each target lists the channels it is published to.
1274
1443
 
1275
1444
  .apionly.yaml (excerpt)
1276
1445
  [source,yaml]
@@ -1288,52 +1457,39 @@ channels:
1288
1457
 
1289
1458
  targets:
1290
1459
  catalog:
1460
+ channels: [maven, npm]
1291
1461
  openapi:
1292
1462
  bundle: bundles/catalog_openapi_structure.yaml
1293
1463
  customers:
1464
+ channels: [maven]
1294
1465
  openapi:
1295
1466
  bundle: bundles/customers_openapi_structure.yaml
1296
1467
  orders:
1468
+ channels: [maven, npm]
1297
1469
  openapi:
1298
1470
  bundle: bundles/orders_openapi_structure.yaml
1299
1471
  payments:
1472
+ channels: [maven, nuget]
1300
1473
  openapi:
1301
1474
  bundle: bundles/payments_openapi_structure.yaml
1302
1475
  ----
1303
1476
 
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
1477
  [source,console]
1322
1478
  ----
1323
1479
  npm run apionly -- build
1324
- npm run publish:all
1480
+ npm run apionly -- publish
1325
1481
  ----
1326
1482
 
1327
- `publish:maven` names no target, because every target goes there; a channel that takes only some names each of them.
1483
+ One `publish` sends each target to its own channels, packing it once.
1484
+ `npm run apionly -- targets` lists each target with its channels, so the routing can be read back.
1328
1485
 
1329
1486
  *Worth knowing.*
1330
1487
 
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.
1488
+ * A target without a `channels` list goes to every configured channel.
1489
+ In an eco-system, give every target a list: a new target then goes nowhere until someone decides where, which is the deliberate step an eco-system wants.
1490
+ * `--channel` publishes to one channel alone, say after its registry was unreachable: `npm run apionly -- publish --channel npm` sends `catalog` and `orders`, says that it skipped `customers` and `payments`, and fails if it would publish nothing at all.
1491
+ * The routing is checked when `apionly.yaml` is read: a target that lists a channel `apionly.yaml` does not configure is refused, naming both, before anything is built.
1492
+ * A third party that needs a registry of a type another party already uses -- a second Maven repository -- gets a channel of its own, with `type: maven` (<<recipes-two-facts,the two facts §>>).
1337
1493
 
1338
1494
  [#behaviour]
1339
1495
  == Behaviour worth knowing about
@@ -1539,13 +1695,45 @@ The Publisher is also a Docker image, for CI pipelines that may run only what ha
1539
1695
  |===
1540
1696
 
1541
1697
  Vetting the npm package does not vet what it runs: it fetches Redocly CLI and AsyncAPI CLI with `npx` when it runs.
1542
- The image is one artefact, with one digest, holding one Publisher version *and* its toolchain, and it is *sealed*: it downloads nothing, and contacts nothing, when it runs.
1698
+ The image is one artefact, with one digest, holding one Publisher version *and* its toolchain, and it is *sealed*: it downloads nothing when it runs, and contacts nothing unless a job allows it to publish.
1543
1699
  It can be pulled by digest, its signature verified, its SBOM and provenance read, scanned, and mirrored, and then a pipeline knows exactly what it runs.
1544
1700
 
1545
1701
  Both registries serve the same image, with the same digest: Docker Hub gets a copy of what GHCR was given, never a rebuild.
1546
1702
 
1547
1703
  link:../docs/guides/publishing-from-ci/overview.adoc[Publishing from CI with the image →] is the guide, with prompts that set up the pipeline in your own repository.
1548
1704
 
1705
+ Publishing from the image takes one job or two:
1706
+
1707
+ [cols="1,2,2", options="header"]
1708
+ |===
1709
+ | | One job | Two steps
1710
+
1711
+ | What runs
1712
+ | The image, and nothing else.
1713
+ | The image packages; a job per registry publishes with that registry's own client, on an image of its own.
1714
+
1715
+ | Credentials and network
1716
+ | The publishing job allows the image to contact the registry (`API_ONLY_PUBLISHER_REMOTE: allow`) and hands it the token.
1717
+ | The image never holds a credential, and runs with no network.
1718
+
1719
+ | Channels
1720
+ | `gitlab-package`, and `maven` to a URL: the remote modes the image is tested with.
1721
+ | Every channel, in its local mode.
1722
+
1723
+ | Reference pipeline
1724
+ | <<docker-gitlab,GitLab CI §>>
1725
+ | <<docker-github-actions,GitHub Actions §>>, and the <<docker-gitlab,GitLab CI §>> variant
1726
+ |===
1727
+
1728
+ [#docker-one-job]
1729
+ === One job: the image publishes
1730
+
1731
+ The job runs `build`, `lint` and `publish` in the image, with `API_ONLY_PUBLISHER_REMOTE: allow` and the registry's token in its environment.
1732
+ Nothing else runs, so the image is the only artefact to vet, mirror and pin; the publishing clients and their images that two steps need are not.
1733
+ The image stays sealed in every other job, and the job contacts only the destinations `apionly.yaml` configures.
1734
+
1735
+ The remote modes the image is tested with are `gitlab-package` and `maven` to a URL: its smoke test publishes with both, as a non-root user on a read-only root, to a GitLab stand-in on a network with no route out (<<docker-verifying,Running the smoke test §>>).
1736
+
1549
1737
  [#docker-two-steps]
1550
1738
  === Two steps: package in the image, publish with the platform's tools
1551
1739
 
@@ -1556,14 +1744,15 @@ It needs no network and no credential.
1556
1744
  Every `publish` run writes a hand-off, <<handoff,The hand-off §>>, listing each file it wrote with its SHA-256.
1557
1745
  The publishing job checks the files against it, and publishes exactly those bytes: the bytes in the registry are the bytes the vetted tool produced.
1558
1746
 
1559
- Credentials never enter the image.
1747
+ In two steps, credentials never enter the image.
1560
1748
 
1561
1749
  [IMPORTANT]
1562
- .What the image does not do yet
1750
+ .What the image does not publish itself yet
1563
1751
  ====
1564
- The image does not publish to a remote registry itself.
1565
- The channels' remote modes -- `maven` or `nuget` to a URL, `npm` with publishing, `github-release` -- may work from inside it, but they are neither tested nor supported there.
1566
- Publish in a second step, as <<docker-pipelines>> shows.
1752
+ The image refuses every remote mode unless a job sets `API_ONLY_PUBLISHER_REMOTE: allow` (<<remote-switch,Refusing remote publishing §>>).
1753
+ Allowed, it supports `gitlab-package` and `maven` to a URL.
1754
+ `nuget` to a URL, `npm` with publishing and `github-release` may work from inside it too, but they are neither tested nor supported there yet -- `github-release` needs the GitHub CLI, which the image does not hold.
1755
+ Publish those in a second step, as <<docker-pipelines>> shows.
1567
1756
  ====
1568
1757
 
1569
1758
  [#docker-tags]
@@ -1803,6 +1992,10 @@ The image makes no outbound connection: it sets, in its environment, what switch
1803
1992
 
1804
1993
  This is tested, not assumed: the whole smoke test was run on a network whose every packet was captured, and the image sent none.
1805
1994
 
1995
+ Nor does `publish` contact anything: the image sets `API_ONLY_PUBLISHER_REMOTE=never`, so a run that would use a remote channel fails before it packs anything, naming the channels (<<remote-switch,Refusing remote publishing §>>).
1996
+ A pipeline that publishes from the image sets `API_ONLY_PUBLISHER_REMOTE: allow` on that job: a visible line, reviewed like any other.
1997
+ The variable is the Publisher's promise; a pipeline that must be sure the job contacts nothing also runs it without a network, `docker run --network none`.
1998
+
1806
1999
  [#docker-ci-job]
1807
2000
  ==== As a CI job image
1808
2001
 
@@ -1850,19 +2043,24 @@ Each publishing step checks for the version first, compares the bytes, and publi
1850
2043
  [#docker-gitlab]
1851
2044
  ==== Reference pipeline: GitLab CI
1852
2045
 
1853
- [WARNING]
1854
- ====
1855
- *Not exercised.*
1856
- This pipeline is validated against GitLab's published CI schema only; no GitLab pipeline has run it yet.
1857
- ====
2046
+ One job, on a `v*` tag, as the GitHub Actions pipeline: the image builds, lints and publishes the targets to the project's Generic Package Registry with `gitlab-package`, and, where `apionly.yaml` configures it, to the project's Maven registry, both with the job's `CI_JOB_TOKEN`.
2047
+ `apionly.yaml` needs no GitLab settings for it; the pipeline's header says what to add for the Maven registry.
2048
+ Copy it to `.gitlab-ci.yml`, then set the digest and the targets.
2049
+ The tag pattern keeps out the tag a GitLab release puts on the commit, `<target>-v<version>`, which would otherwise start the pipeline a second time; a library that tags its releases otherwise changes the rule, and keeps that one out.
1858
2050
 
1859
- The same two steps: `package` runs in the image, and one `publish:*` job per registry publishes to the project's package registry with `CI_JOB_TOKEN`: npm, Maven and NuGet, and the archives to the generic package registry.
2051
+ It has run on gitlab.com: with the job token, it published to the project's generic and Maven registries, created the release, and, run again, found everything published and changed nothing.
2052
+ It is validated against GitLab's published CI schema, the image's smoke test publishes as this job does to a GitLab stand-in, and the link:../.github/workflows/api-only-publisher-image-e2e.yml[end-to-end workflow →] publishes the same way to a gitlab.com test project, with a deploy token.
1860
2053
 
1861
2054
  [source,yaml]
1862
2055
  ----
1863
2056
  include::pipelines/gitlab-ci.yml[]
1864
2057
  ----
1865
2058
 
2059
+ link:../docs/guides/publishing-to-gitlab/overview.adoc[Publishing to GitLab →] is the guide: tokens, a self-managed GitLab, and what consumers need.
2060
+
2061
+ *The two-step variant*, link:pipelines/gitlab-ci-two-step.yml[`pipelines/gitlab-ci-two-step.yml` →], keeps the image sealed: `package` runs in the image, and one `publish:*` job per registry publishes to the project's package registry with that registry's own client and `CI_JOB_TOKEN`: npm, Maven and NuGet, and the archives to the generic package registry.
2062
+ It is validated against GitLab's published CI schema only; no GitLab pipeline has run it yet.
2063
+
1866
2064
  [#docker-mirror-in-pipelines]
1867
2065
  ==== Using a mirrored image
1868
2066
 
@@ -1956,12 +2154,14 @@ docker build -t api-only-publisher:local --build-arg VERSION=0.0.0-local .
1956
2154
  docker/smoke-test.sh api-only-publisher:local 0.0.0-local
1957
2155
  ----
1958
2156
 
1959
- It runs every step with no network, a read-only root and your own user: the version, the help, the toolchain labels against what is installed and against `init`'s pins, a library with two targets built, linted and packaged to every local channel in one run, ownership, byte-identical packaging twice, the hand-off, a foreign toolchain pin failing at once, the shell mode, and a build as an arbitrary UID.
2157
+ It runs every step with a read-only root and your own user, and all but the last with no network: the version, the help, the toolchain labels against what is installed and against `init`'s pins, a library with two targets built, linted and packaged to every local channel in one run, ownership, byte-identical packaging twice, the hand-off, a foreign toolchain pin failing at once, a remote channel refused while sealed, the shell mode, and a build as an arbitrary UID.
2158
+ The last step publishes with `gitlab-package` and `maven` to a GitLab stand-in, run from the same image, on a Docker network it creates with no route out (`docker network create --internal`) and removes afterwards; it checks every request went to the project's package registries with the job token, and that a re-run uploads nothing.
1960
2159
 
1961
2160
  link:../.github/workflows/api-only-publisher-image-e2e.yml[`api-only-publisher-image-e2e.yml` →] runs the GitHub Actions reference pipeline for real, by hand only.
1962
2161
  link:test/docker.test.js[`test/docker.test.js` →] holds it to what the link:pipelines/[reference pipelines →] do, the same actions, commands, inputs and environment, but not to the versions they pin.
1963
2162
  The reference pipelines are examples, maintained on their own, and the end-to-end workflow is maintained with this repository's other workflows, so a new version of an action in one never fails the Publisher's build while the other has not moved.
1964
2163
  Give it an image by digest; it publishes version `0.<run>.<attempt>` of two scratch targets, `apionly-e2e-alpha` and `apionly-e2e-beta`, to npm, Maven and NuGet on GitHub Packages and to a scratch release, publishes again to prove a re-run is safe, resolves each package with its own client and with the Subscriber, and removes what it published.
2164
+ Its `gitlab` job publishes the same targets from the image, in one job, to a gitlab.com test project's Generic Package Registry and Maven registry with a deploy token (secret `GITLAB_E2E_DEPLOY_TOKEN`), publishes again, checks GitLab serves the image's bytes and lists the version in `maven-metadata.xml`, and resolves a contract with the Subscriber; a deploy token cannot delete packages, so the run's summary lists the scratch versions to delete by hand.
1965
2165
  The image workflow, dispatched by hand, is the release's dry run: its defaults are the scratch names `ghcr.io/arc-e-tect/api-only-publisher-scratch` and `arcetect/api-only-publisher-scratch`, and it builds from the branch's source.
1966
2166
 
1967
2167
  [#adapting]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.14.4",
3
+ "version": "0.16.0",
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",
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+
3
+ // The channel types, in a module of their own so that the configuration can check a
4
+ // channel's type without requiring the channels themselves.
5
+
6
+ const CHANNEL_TYPES = Object.freeze(["file", "maven", "npm", "github-release", "nuget", "gitlab-package"]);
7
+
8
+ /** The types as a sentence names them: "file, maven, ... or gitlab-package". */
9
+ function describeTypes() {
10
+ return `${CHANNEL_TYPES.slice(0, -1).join(", ")} or ${CHANNEL_TYPES[CHANNEL_TYPES.length - 1]}`;
11
+ }
12
+
13
+ module.exports = { CHANNEL_TYPES, describeTypes };
package/src/channels.js CHANGED
@@ -12,9 +12,11 @@ const os = require("os");
12
12
  const path = require("path");
13
13
  const { execFileSync } = require("child_process");
14
14
 
15
- const { isPrerelease, npmDistTag } = require("./version-policy");
15
+ const { parse, compare, isPrerelease, npmDistTag } = require("./version-policy");
16
16
  const { ChannelError } = require("./channel-error");
17
17
  const { publishNuget } = require("./nuget");
18
+ const { publishGitlabPackage } = require("./gitlab");
19
+ const { refuseRemote } = require("./remote");
18
20
 
19
21
  /** What every package and release says it is, unless `description` says otherwise. */
20
22
  const DEFAULT_DESCRIPTION = "API description documents for {target}.";
@@ -301,11 +303,11 @@ function publishGithubRelease(archive, manifest, options, log) {
301
303
  * configuration file gets committed, and a credential in a committed file is a
302
304
  * credential that has leaked.
303
305
  */
304
- async function putFile(url, body, token, contentType) {
306
+ async function putFile(url, body, auth, contentType) {
305
307
  const response = await fetch(url, {
306
308
  method: "PUT",
307
309
  headers: {
308
- "Authorization": `Bearer ${token}`,
310
+ ...auth,
309
311
  "Content-Type": contentType,
310
312
  "Content-Length": String(body.length),
311
313
  },
@@ -322,8 +324,8 @@ async function putFile(url, body, token, contentType) {
322
324
  }
323
325
 
324
326
  /** What a Maven repository holds at `url`, or null when it holds nothing there. */
325
- async function getFile(url, token) {
326
- const response = await fetch(url, { headers: { "Authorization": `Bearer ${token}` } });
327
+ async function getFile(url, auth) {
328
+ const response = await fetch(url, { headers: auth });
327
329
  if (response.status === 404) return null;
328
330
  if (!response.ok) {
329
331
  throw new ChannelError(`GET ${url} failed: ${response.status} ${response.statusText}. ` +
@@ -333,6 +335,54 @@ async function getFile(url, token) {
333
335
  return Buffer.from(await response.arrayBuffer());
334
336
  }
335
337
 
338
+ /** An HTTP header name: RFC 9110's token. */
339
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
340
+
341
+ /** The highest of `versions` that parse as semver and pass `keep`, or null. */
342
+ function highest(versions, keep = () => true) {
343
+ const valid = versions.filter((v) => { try { parse(v); return keep(v); } catch { return false; } });
344
+ return valid.length ? valid.reduce((a, b) => (compare(a, b) >= 0 ? a : b)) : null;
345
+ }
346
+
347
+ /** The artifact's maven-metadata.xml, as `mvn deploy` keeps it. */
348
+ function metadataXml(groupId, artifactId, versions, lastUpdated) {
349
+ const latest = highest(versions);
350
+ const release = highest(versions, (v) => !isPrerelease(v));
351
+ return '<?xml version="1.0" encoding="UTF-8"?>\n<metadata>\n' +
352
+ ` <groupId>${groupId}</groupId>\n <artifactId>${artifactId}</artifactId>\n <versioning>\n` +
353
+ (latest ? ` <latest>${latest}</latest>\n` : "") + (release ? ` <release>${release}</release>\n` : "") +
354
+ ` <versions>\n${versions.map((v) => ` <version>${v}</version>\n`).join("")} </versions>\n` +
355
+ ` <lastUpdated>${lastUpdated}</lastUpdated>\n </versioning>\n</metadata>\n`;
356
+ }
357
+
358
+ /**
359
+ * Adds the version to the artifact's maven-metadata.xml, which a repository that does not
360
+ * generate it -- GitLab's -- otherwise never has, so that update bots never see a new
361
+ * version. Written only when it lacks the version, or its latest or release would change.
362
+ */
363
+ async function updateMetadata(url, groupId, artifactId, manifest, auth, log) {
364
+ const held = await getFile(url, auth);
365
+ const xml = held ? held.toString("utf8") : "";
366
+ const block = /<versions>([\s\S]*?)<\/versions>/.exec(xml);
367
+ const versions = block ? [...block[1].matchAll(/<version>\s*([^<\s]+)\s*<\/version>/g)].map((m) => m[1]) : [];
368
+ const tag = (name) => ((new RegExp(`<${name}>\\s*([^<\\s]+)\\s*</${name}>`).exec(xml)) || [])[1] || null;
369
+ const listed = versions.includes(manifest.version) ? versions : [...versions, manifest.version];
370
+ if (listed === versions && tag("latest") === highest(listed)
371
+ && tag("release") === highest(listed, (v) => !isPrerelease(v))) {
372
+ return;
373
+ }
374
+ // The commit's time, as the archive's producedAt, so the same commit writes the same file.
375
+ const lastUpdated = String(manifest.producedAt || new Date().toISOString()).replace(/\.\d+Z$/, "Z").replace(/[-:TZ]/g, "");
376
+ try {
377
+ await putFile(url, Buffer.from(metadataXml(groupId, artifactId, listed, lastUpdated), "utf8"), auth, "application/xml");
378
+ } catch (error) {
379
+ throw new ChannelError(`the repository refused maven-metadata.xml for ${groupId}:${artifactId}: ${error.message.trim()} ` +
380
+ "The version's files are published; a re-run retries only the metadata. For a repository that generates " +
381
+ "its own maven-metadata.xml, set channels.maven.metadata: false.");
382
+ }
383
+ log(`-- Listed ${manifest.version} in ${groupId}:${artifactId}'s maven-metadata.xml`);
384
+ }
385
+
336
386
  function publishMavenRemote(archive, manifest, options, log) {
337
387
  const tokenEnv = options.tokenEnv || "MAVEN_TOKEN";
338
388
  const token = process.env[tokenEnv];
@@ -342,12 +392,20 @@ function publishMavenRemote(archive, manifest, options, log) {
342
392
  `Remote publication needs one; set it in the release job's environment.`
343
393
  );
344
394
  }
395
+ // A bearer token unless tokenHeader names another header, which then carries the
396
+ // token as it is: GitLab takes a job, personal or deploy token as Job-Token,
397
+ // Private-Token or Deploy-Token, and Bearer only for OAuth.
398
+ if (options.tokenHeader !== undefined && !HEADER_NAME.test(options.tokenHeader)) {
399
+ throw new ChannelError(`channels.maven.tokenHeader must be an HTTP header name, such as Job-Token, ` +
400
+ `not ${JSON.stringify(options.tokenHeader)}`);
401
+ }
402
+ const auth = options.tokenHeader === undefined ? { "Authorization": `Bearer ${token}` } : { [options.tokenHeader]: token };
345
403
 
346
404
  const artifactId = options.artifactId || manifest.target;
347
405
  const version = manifest.version;
348
406
  const extension = options.extension || "tgz";
349
- const base = `${options.repository.replace(/\/+$/, "")}/` +
350
- `${options.groupId.split(".").join("/")}/${artifactId}/${version}/${artifactId}-${version}`;
407
+ const artifactDir = `${options.repository.replace(/\/+$/, "")}/${options.groupId.split(".").join("/")}/${artifactId}`;
408
+ const base = `${artifactDir}/${version}/${artifactId}-${version}`;
351
409
 
352
410
  const work = (async () => {
353
411
  // Idempotent, file by file: each file the repository already has is left alone when
@@ -363,7 +421,7 @@ function publishMavenRemote(archive, manifest, options, log) {
363
421
  ];
364
422
  const missing = [];
365
423
  for (const file of files) {
366
- const existing = await getFile(file.url, token);
424
+ const existing = await getFile(file.url, auth);
367
425
  if (!existing) {
368
426
  missing.push(file);
369
427
  } else if (!existing.equals(file.bytes)) {
@@ -373,7 +431,10 @@ function publishMavenRemote(archive, manifest, options, log) {
373
431
  "A published version is never replaced; publish the change as a new version.");
374
432
  }
375
433
  }
376
- for (const file of missing) await putFile(file.url, file.bytes, token, file.type);
434
+ for (const file of missing) await putFile(file.url, file.bytes, auth, file.type);
435
+ if (options.metadata !== false) {
436
+ await updateMetadata(`${artifactDir}/maven-metadata.xml`, options.groupId, artifactId, manifest, auth, log);
437
+ }
377
438
  return missing.length === 0;
378
439
  })();
379
440
 
@@ -392,16 +453,24 @@ function publishMavenRemote(archive, manifest, options, log) {
392
453
 
393
454
  const CHANNELS = {
394
455
  file: publishFile, maven: publishMaven, npm: publishNpm, "github-release": publishGithubRelease, nuget: publishNuget,
456
+ "gitlab-package": publishGitlabPackage,
395
457
  };
396
458
 
459
+ /**
460
+ * Publishes to the channel named `channel`, of the type its settings' `type` names, or
461
+ * of the type it is named after -- `maven` is a Maven channel, as it has always been.
462
+ */
397
463
  function publish(archive, manifest, channel, options, log = () => {}) {
398
- const handler = CHANNELS[channel];
464
+ const { type = channel, ...settings } = options || {};
465
+ const handler = CHANNELS[type];
399
466
  if (!handler) {
400
467
  throw new ChannelError(
401
468
  `unknown channel '${channel}'; available channels are ${Object.keys(CHANNELS).join(", ")}`
402
469
  );
403
470
  }
404
- return handler(archive, manifest, options || {}, log);
471
+ // Here as well as in the CLI, so the Publisher used as a library keeps it too.
472
+ refuseRemote([channel], { [channel]: options || {} });
473
+ return handler(archive, manifest, settings, log);
405
474
  }
406
475
 
407
476
  module.exports = { publish, CHANNELS, ChannelError, pom, format, DEFAULT_DESCRIPTION };
package/src/cli.js CHANGED
@@ -17,6 +17,8 @@ const { pack, PackError } = require("./pack");
17
17
  const { changedSince, ChangedError } = require("./changed");
18
18
  const { split, SplitError } = require("./split");
19
19
  const { publish, ChannelError } = require("./channels");
20
+ const { refuseRemote } = require("./remote");
21
+ const { CHANNEL_TYPES } = require("./channel-types");
20
22
  const { VersionError: PolicyError, describe } = require("./version-policy");
21
23
  const { versionOf, BundleVersionError } = require("./bundle-version");
22
24
  const { unreferenced } = require("./unreferenced");
@@ -323,7 +325,9 @@ async function main(argv, io = {}) {
323
325
  for (const name of Object.keys(config.targets)) {
324
326
  const kinds = ["openapi", "asyncapi"].filter((k) => config.targets[name][k]);
325
327
  const published = config.isPublished(name) ? "" : " (publish: false)";
326
- console.log(`${name} [${kinds.join(", ")}]${published}`);
328
+ const routed = config.isPublished(name) && Object.keys(config.channels || {}).length > 0
329
+ ? ` channels: ${config.channelsOf(name).join(", ")}` : "";
330
+ console.log(`${name} [${kinds.join(", ")}]${published}${routed}`);
327
331
  }
328
332
  return 0;
329
333
  }
@@ -447,18 +451,43 @@ async function main(argv, io = {}) {
447
451
  case "publish": {
448
452
  const outDir = options.out ? path.resolve(options.dir, options.out) : config.packagesDir();
449
453
  const configured = config.channels || {};
450
- const names = options.channels || Object.keys(configured);
451
- if (names.length === 0) {
454
+ for (const name of options.channels || []) {
455
+ // An unconfigured channel named after a type is that channel with no settings.
456
+ if (!configured[name] && !(name in configured) && !CHANNEL_TYPES.includes(name)) {
457
+ const names = Object.keys(configured);
458
+ throw new ConfigError(`--channel '${name}' is not a configured channel; ` +
459
+ `apionly.yaml configures ${names.length ? names.join(", ") : "no channels"}`);
460
+ }
461
+ }
462
+ if (!options.channels && Object.keys(configured).length === 0) {
452
463
  throw new ConfigError("no channels configured; add a `channels:` block or pass --channel");
453
464
  }
454
- const shipped = shippedTargets(config, targets);
465
+ // Each target's channels: its own list, or every configured one, narrowed by --channel.
466
+ const shippable = shippedTargets(config, targets);
467
+ const routes = new Map(shippable.map((target) => {
468
+ const own = config.targets[target].channels;
469
+ return [target, options.channels
470
+ ? options.channels.filter((name) => !own || own.includes(name))
471
+ : config.channelsOf(target)];
472
+ }));
473
+ for (const target of shippable) {
474
+ const own = config.targets[target].channels;
475
+ for (const name of (own && options.channels) || []) {
476
+ if (!own.includes(name)) log(`${target}: not published to ${name} (not in targets.${target}.channels)`);
477
+ }
478
+ }
479
+ const shipped = shippable.filter((target) => routes.get(target).length > 0);
480
+ if (options.channels && shippable.length > 0 && shipped.length === 0) {
481
+ throw new ConfigError(`nothing was published: no target in this run is routed to ${options.channels.join(", ")}`);
482
+ }
483
+ // Every channel this run uses, before anything is read, packed or sent.
484
+ refuseRemote([...new Set(shipped.flatMap((target) => routes.get(target)))], configured);
455
485
  const versions = versionsOf(config, shipped, options.preRelease);
456
486
  const closures = forTargets(config, undefined, shipped);
457
487
  let published = 0;
458
488
  const handoff = [];
459
489
  for (const target of Object.keys(config.targets)) {
460
- if (targets && !targets.includes(target)) continue;
461
- if (!config.isPublished(target)) continue;
490
+ if (!shipped.includes(target)) continue;
462
491
  const closure = closures.get(target);
463
492
  // Packed once, then shipped unchanged to every channel.
464
493
  const { archive, manifest } = pack(config, target, {
@@ -466,11 +495,11 @@ async function main(argv, io = {}) {
466
495
  closureSha256: closure ? closure.sha256 : null,
467
496
  outDir, log,
468
497
  });
469
- for (const name of names) {
498
+ for (const name of routes.get(target)) {
470
499
  // A remote channel returns a promise; a local one does not.
471
500
  const result = await publish(archive, manifest, name, { ...(configured[name] || {}), baseDir: config.root }, log);
472
501
  for (const file of result.files || []) {
473
- handoff.push({ file, target, version: manifest.version, channel: name });
502
+ handoff.push({ file, target, version: manifest.version, channel: name, type: config.channelType(name) });
474
503
  }
475
504
  published += 1;
476
505
  }
package/src/config.js CHANGED
@@ -9,6 +9,8 @@ const fs = require("fs");
9
9
  const path = require("path");
10
10
  const YAML = require("yaml");
11
11
 
12
+ const { CHANNEL_TYPES, describeTypes } = require("./channel-types");
13
+
12
14
  const CONFIG_NAME = "apionly.yaml";
13
15
  const SUPPORTED_SCHEMA_VERSION = 1;
14
16
 
@@ -30,7 +32,7 @@ const REPORTS_KEYS = ["lint"];
30
32
  const LINT_KEYS = ["unreferenced", "examples"];
31
33
  const LINT_EXAMPLES_KEYS = ["openapi", "asyncapi"];
32
34
  const DISTRIBUTION_KEYS = ["root", "layout"];
33
- const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile"];
35
+ const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile", "channels"];
34
36
  const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
35
37
  const PORTFOLIO_KEYS = ["openapi", "security", "location"];
36
38
  const PORTFOLIO_OPENAPI_KEYS = ["paths", "operationIds", "tags"];
@@ -49,6 +51,73 @@ const PATH_STRATEGIES = ["target-prefix", "none"];
49
51
 
50
52
  class ConfigError extends Error {}
51
53
 
54
+ // A channel's name, as init asks of a target's: lowercase words joined by single hyphens.
55
+ const CHANNEL_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
56
+
57
+ /**
58
+ * Each configured channel's type, by name. A channel named after a type is that type;
59
+ * any other name says its type with `type`, which is how one apionly.yaml has two
60
+ * channels of one type -- a Maven repository per partner.
61
+ */
62
+ function channelTypes(channels) {
63
+ if (typeof channels !== "object" || Array.isArray(channels)) {
64
+ throw new ConfigError("channels must be a mapping of channel names to their settings");
65
+ }
66
+ const types = {};
67
+ for (const [name, settings] of Object.entries(channels)) {
68
+ if (!CHANNEL_NAME.test(name)) {
69
+ throw new ConfigError(`channel name '${name}': use lowercase letters and digits, in words joined by single ` +
70
+ "hyphens, starting with a letter, such as partner-a");
71
+ }
72
+ if (settings !== null && (typeof settings !== "object" || Array.isArray(settings))) {
73
+ throw new ConfigError(`channels.${name} must be a mapping of settings`);
74
+ }
75
+ const declared = settings ? settings.type : undefined;
76
+ if (CHANNEL_TYPES.includes(name)) {
77
+ if (declared !== undefined && declared !== name) {
78
+ throw new ConfigError(`channel '${name}' is named after a channel type, so its type is ${name}, ` +
79
+ `not ${declared}; give it another name`);
80
+ }
81
+ types[name] = name;
82
+ } else if (declared === undefined) {
83
+ throw new ConfigError(`channel '${name}' is not a channel type; say which one it is with type: ${describeTypes()}`);
84
+ } else if (!CHANNEL_TYPES.includes(declared)) {
85
+ throw new ConfigError(`channels.${name}.type '${declared}' is not a channel type: ${describeTypes()}`);
86
+ } else {
87
+ types[name] = declared;
88
+ }
89
+ }
90
+ return types;
91
+ }
92
+
93
+ /** Refuses a target's `channels` that cannot be what it means. */
94
+ function checkTargetChannels(name, target, channels, isAggregate) {
95
+ const list = target.channels;
96
+ if (list === undefined) return;
97
+ if (!Array.isArray(list) || list.some((c) => typeof c !== "string")) {
98
+ throw new ConfigError(`targets.${name}.channels must be a list of channel names`);
99
+ }
100
+ if (list.length === 0) {
101
+ throw new ConfigError(`targets.${name}.channels is empty; a target that is published nowhere says so with publish: false`);
102
+ }
103
+ if (isAggregate) {
104
+ throw new ConfigError(`target '${name}' is an aggregate, which is never published, so it has no channels`);
105
+ }
106
+ if (target.publish === false) {
107
+ throw new ConfigError(`target '${name}' is not published (publish: false), so it has no channels`);
108
+ }
109
+ const seen = new Set();
110
+ for (const channel of list) {
111
+ if (seen.has(channel)) throw new ConfigError(`target '${name}' lists channel '${channel}' twice`);
112
+ seen.add(channel);
113
+ if (!Object.prototype.hasOwnProperty.call(channels, channel)) {
114
+ const configured = Object.keys(channels);
115
+ throw new ConfigError(`target '${name}' lists channel '${channel}', which apionly.yaml does not configure; ` +
116
+ `it configures ${configured.length ? configured.join(", ") : "no channels"}`);
117
+ }
118
+ }
119
+ }
120
+
52
121
  /** Refuses a key `obj` has that is not in `allowed`, naming the key and where it is. */
53
122
  function checkKeys(obj, allowed, where) {
54
123
  if (!obj || typeof obj !== "object") return;
@@ -135,6 +204,9 @@ function load(configPath) {
135
204
  );
136
205
  }
137
206
 
207
+ const channels = parsed.channels || {};
208
+ const types = channelTypes(channels);
209
+
138
210
  const targets = parsed.targets;
139
211
  if (!targets || typeof targets !== "object" || Object.keys(targets).length === 0) {
140
212
  throw new ConfigError(`${configPath} declares no targets`);
@@ -176,6 +248,8 @@ function load(configPath) {
176
248
  );
177
249
  }
178
250
 
251
+ checkTargetChannels(name, target, channels, aggregateKinds.length > 0);
252
+
179
253
  for (const kind of kinds) {
180
254
  checkKeys(target[kind], TARGET_KIND_KEYS, `targets.${name}.${kind}`);
181
255
  if (target[kind].aggregate !== undefined) {
@@ -238,7 +312,7 @@ function load(configPath) {
238
312
  reports,
239
313
  lint,
240
314
  distribution: parsed.distribution || null,
241
- channels: parsed.channels || {},
315
+ channels,
242
316
  portfolio,
243
317
  targets,
244
318
 
@@ -326,6 +400,15 @@ function load(configPath) {
326
400
  // An aggregate is never published either, whether or not it says so --
327
401
  // load() already refuses the one case that would contradict this, `publish:
328
402
  // true` alongside an aggregate, so nothing here needs to re-check that.
403
+ // A configured channel's type; a name that is not configured is taken for a type,
404
+ // as `publish --channel github-release` has always meant that channel unconfigured.
405
+ channelType(name) {
406
+ return types[name] || name;
407
+ },
408
+ // The channels a target is published to: its own list, or every configured one.
409
+ channelsOf(target) {
410
+ return this.targets[target].channels || Object.keys(this.channels);
411
+ },
329
412
  isPublished(target) {
330
413
  return this.targets[target].publish !== false && !this.isAggregate(target);
331
414
  },
package/src/gitlab.js ADDED
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+
3
+ // `gitlab-package` -- publish to a GitLab project's Generic Package Registry.
4
+ //
5
+ // One generic package per target, at the contract's version, holding the archive and
6
+ // manifest.json: the same two files the `file` channel writes. In a GitLab CI job it
7
+ // needs no settings at all: the API, the project and the job token come from GitLab's
8
+ // own CI variables. `release: true` also creates the tag's GitLab release, linking both
9
+ // files as packages.
10
+ //
11
+ // GitLab allows duplicate versions by default: a different file under a version it has
12
+ // is accepted, and served from then on. So every file is read back before it is
13
+ // uploaded, and the read-back, not GitLab, keeps a published version immutable.
14
+
15
+ const path = require("path");
16
+ const { execFileSync } = require("child_process");
17
+
18
+ const { isPrerelease } = require("./version-policy");
19
+ const { ChannelError } = require("./channel-error");
20
+
21
+ const GITLAB_COM = "https://gitlab.com/api/v4";
22
+ const TOKEN_HEADERS = { private: "PRIVATE-TOKEN", deploy: "DEPLOY-TOKEN", job: "JOB-TOKEN" };
23
+
24
+ /** The API, the project and the credential, from configuration first and GitLab CI variables second. */
25
+ function channelSettings(options, env = process.env) {
26
+ const tokenEnv = options.tokenEnv || "GITLAB_TOKEN";
27
+ let token = env[tokenEnv];
28
+ let tokenType = options.tokenType || "private";
29
+ if (!TOKEN_HEADERS[tokenType]) {
30
+ throw new ChannelError(`channels.gitlab-package.tokenType must be private, deploy or job, not ${JSON.stringify(tokenType)}`);
31
+ }
32
+ if (!token && env.CI_JOB_TOKEN) {
33
+ token = env.CI_JOB_TOKEN;
34
+ tokenType = "job";
35
+ }
36
+ if (!token) {
37
+ throw new ChannelError(
38
+ `no token in $${tokenEnv}, which channels.gitlab-package.tokenEnv names, and no $CI_JOB_TOKEN. ` +
39
+ "Set one in the release job's environment, or run in a GitLab CI job.");
40
+ }
41
+ const project = options.project === undefined ? env.CI_PROJECT_ID : String(options.project);
42
+ if (!project) {
43
+ throw new ChannelError("the gitlab-package channel needs a project: set channels.gitlab-package.project, " +
44
+ "or run in a GitLab CI job ($CI_PROJECT_ID)");
45
+ }
46
+ const url = (options.url || env.CI_API_V4_URL || GITLAB_COM).replace(/\/+$/, "");
47
+ return { url, project, tokenType, headers: { [TOKEN_HEADERS[tokenType]]: token } };
48
+ }
49
+
50
+ /** The commit a new release's tag points at: the CI job's, or the checkout's HEAD. */
51
+ function releaseRef(options, env = process.env) {
52
+ if (env.CI_COMMIT_SHA) return env.CI_COMMIT_SHA;
53
+ try {
54
+ return execFileSync("git", ["-C", path.resolve(options.baseDir || "."), "rev-parse", "HEAD"],
55
+ { encoding: "utf8", stdio: "pipe" }).trim();
56
+ } catch {
57
+ throw new ChannelError("release: true needs the commit to tag: $CI_COMMIT_SHA, or a git checkout to read HEAD from");
58
+ }
59
+ }
60
+
61
+ /** The status and GitLab's message, without the status twice: GitLab's messages often start with it. */
62
+ async function answered(response) {
63
+ const text = await response.text();
64
+ let message = text;
65
+ try {
66
+ const body = JSON.parse(text);
67
+ message = body.message || body.error || text;
68
+ } catch {
69
+ // Not JSON: a proxy's error page, say. Reported as it came.
70
+ }
71
+ return message.startsWith(String(response.status)) ? message : `${response.status} ${message}`;
72
+ }
73
+
74
+ const TOKEN_HINT = " Check the token, and that it may read and write this project's package registry.";
75
+
76
+ /** GitLab's copy of a package file: its bytes, or null when it has none. */
77
+ async function readBack(fileUrl, name, headers) {
78
+ const response = await fetch(fileUrl, { headers });
79
+ if (response.status === 404) return null;
80
+ if (!response.ok) {
81
+ throw new ChannelError(`could not read ${name} back from ${fileUrl}: ${await answered(response)}.` +
82
+ (response.status === 401 || response.status === 403 ? TOKEN_HINT : ""));
83
+ }
84
+ return Buffer.from(await response.arrayBuffer());
85
+ }
86
+
87
+ function notThisOne(name, manifest) {
88
+ return new ChannelError(`GitLab already has ${name} in ${manifest.target} ${manifest.version}, and it is not this one; ` +
89
+ "a published version is never replaced, so publish the change as a new version");
90
+ }
91
+
92
+ /** Uploads one file unless GitLab has it: true when it was already there, identical. */
93
+ async function upload(fileUrl, name, bytes, manifest, headers) {
94
+ const held = await readBack(fileUrl, name, headers);
95
+ if (held) {
96
+ if (held.equals(bytes)) return true;
97
+ throw notThisOne(name, manifest);
98
+ }
99
+ const response = await fetch(fileUrl, { method: "PUT", headers, body: bytes });
100
+ if (response.ok) return false;
101
+ const refused = `${await answered(response)}`;
102
+ // Refused, perhaps as a duplicate another run published meanwhile: compare that one.
103
+ const now = await readBack(fileUrl, name, headers).catch(() => null);
104
+ if (now && now.equals(bytes)) return true;
105
+ if (now) throw notThisOne(name, manifest);
106
+ throw new ChannelError(`GitLab refused ${name}: ${refused}.` +
107
+ (response.status === 401 || response.status === 403 ? TOKEN_HINT : ""));
108
+ }
109
+
110
+ async function api(url, headers, method = "GET", body) {
111
+ return fetch(url, {
112
+ method,
113
+ headers: body === undefined ? headers : { ...headers, "Content-Type": "application/json" },
114
+ body: body === undefined ? undefined : JSON.stringify(body),
115
+ });
116
+ }
117
+
118
+ /** Creates the tag's release, or adds the package links an existing one lacks; never changes anything else. */
119
+ async function release(settings, manifest, options, links, ref, log) {
120
+ const { format, DEFAULT_DESCRIPTION } = require("./channels");
121
+ const tag = format(options.tagFormat || "{target}-v{version}", manifest);
122
+ const releases = `${settings.url}/projects/${encodeURIComponent(settings.project)}/releases`;
123
+ const existing = await api(`${releases}/${encodeURIComponent(tag)}`, settings.headers);
124
+ if (existing.status === 404) {
125
+ const created = await api(releases, settings.headers, "POST", {
126
+ tag_name: tag,
127
+ ref,
128
+ name: format(options.title || "{target} {version}", manifest),
129
+ description: format(options.notes || options.description || DEFAULT_DESCRIPTION, manifest),
130
+ assets: { links },
131
+ });
132
+ if (!created.ok) throw new ChannelError(`GitLab refused the release ${tag}: ${await answered(created)}`);
133
+ log(`-- Created the GitLab release ${tag}`);
134
+ return;
135
+ }
136
+ if (!existing.ok) {
137
+ throw new ChannelError(`could not read the GitLab release ${tag}: ${await answered(existing)}`);
138
+ }
139
+ // A release's link names are unique: one already named, or already pointing here, is left as it is.
140
+ const held = ((await existing.json()).assets || {}).links || [];
141
+ for (const link of links.filter((l) => !held.some((h) => h.url === l.url || h.name === l.name))) {
142
+ const added = await api(`${releases}/${encodeURIComponent(tag)}/assets/links`, settings.headers, "POST", link);
143
+ if (!added.ok) throw new ChannelError(`GitLab refused a link on the release ${tag}: ${await answered(added)}`);
144
+ log(`-- Linked ${link.name} from the GitLab release ${tag}`);
145
+ }
146
+ }
147
+
148
+ function publishGitlabPackage(archive, manifest, options, log) {
149
+ const { format } = require("./channels");
150
+ const settings = channelSettings(options);
151
+ const wantsRelease = options.release === true && !isPrerelease(manifest.version);
152
+ if (wantsRelease && settings.tokenType === "deploy") {
153
+ throw new ChannelError("a deploy token cannot create a GitLab release: GitLab gives it the package registry only. " +
154
+ "Publish with $CI_JOB_TOKEN, or a personal or project access token with the api scope, or set release: false.");
155
+ }
156
+ const ref = wantsRelease ? releaseRef(options) : null;
157
+
158
+ const packageName = format(options.packageName || "{target}", manifest);
159
+ const base = `${settings.url}/projects/${encodeURIComponent(settings.project)}/packages/generic/` +
160
+ `${encodeURIComponent(packageName)}/${encodeURIComponent(manifest.version)}`;
161
+ 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") },
164
+ ].map((file) => ({ ...file, url: `${base}/${encodeURIComponent(file.name)}` }));
165
+
166
+ return (async () => {
167
+ let identical = 0;
168
+ for (const file of files) {
169
+ if (await upload(file.url, file.name, file.bytes, manifest, settings.headers)) identical += 1;
170
+ }
171
+ const alreadyPublished = identical === files.length;
172
+ log(alreadyPublished
173
+ ? `-- ${packageName} ${manifest.version} is already in GitLab project ${settings.project}, identical; nothing to do`
174
+ : `-- Published ${packageName} ${manifest.version} to GitLab project ${settings.project}`);
175
+
176
+ if (options.release === true && !wantsRelease) {
177
+ log(`-- no GitLab release for a pre-release: ${manifest.version} would become the latest release`);
178
+ }
179
+ if (wantsRelease) {
180
+ await release(settings, manifest, options,
181
+ files.map((file) => ({ name: file.name, url: file.url, link_type: "package" })), ref, log);
182
+ }
183
+ return { location: files[0].url, ...(alreadyPublished ? { alreadyPublished } : {}) };
184
+ })();
185
+ }
186
+
187
+ module.exports = { publishGitlabPackage, channelSettings };
package/src/handoff.js CHANGED
@@ -25,10 +25,10 @@ const HANDOFF_SCHEMA_VERSION = 1;
25
25
  */
26
26
  function writeHandoff(root, outDir, entries) {
27
27
  const files = entries
28
- .map(({ file, target, version, channel }) => ({
28
+ .map(({ file, target, version, channel, type = channel }) => ({
29
29
  path: path.relative(root, file).split(path.sep).join("/"),
30
30
  sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"),
31
- target, version, channel,
31
+ target, version, channel, type,
32
32
  }))
33
33
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
34
34
 
package/src/remote.js ADDED
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ // Whether `publish` may contact anything.
4
+ //
5
+ // A channel's local mode writes files; its remote mode sends them somewhere: a
6
+ // repository or feed at a URL, a registry, a release. API_ONLY_PUBLISHER_REMOTE=never
7
+ // refuses every remote mode, so the Publisher contacts nothing. The Docker image sets
8
+ // it, and is sealed unless a pipeline sets allow; outside the image it is unset, and
9
+ // the Publisher behaves as it always has.
10
+ //
11
+ // It is a promise the Publisher keeps, not an enforcement: whoever sets the variable
12
+ // can change it. A guarantee needs the platform to isolate the job's network.
13
+
14
+ const { ChannelError } = require("./channel-error");
15
+
16
+ const REMOTE_ENV = "API_ONLY_PUBLISHER_REMOTE";
17
+
18
+ /** `allow`, the default when unset or empty, or `never`; anything else is refused. */
19
+ function remoteMode(env = process.env) {
20
+ const value = env[REMOTE_ENV];
21
+ if (value === undefined || value === "") return "allow";
22
+ if (value !== "allow" && value !== "never") {
23
+ throw new ChannelError(`${REMOTE_ENV} must be allow or never, not ${JSON.stringify(value)}`);
24
+ }
25
+ return value;
26
+ }
27
+
28
+ const URL = /^https?:\/\//;
29
+
30
+ /** Whether `channel`, with these settings, would contact anything. */
31
+ function isRemote(channel, settings = {}) {
32
+ switch (channel) {
33
+ case "maven":
34
+ case "nuget":
35
+ return URL.test(settings.repository || "");
36
+ case "npm":
37
+ return settings.publish !== false;
38
+ case "github-release":
39
+ case "gitlab-package":
40
+ return true;
41
+ default:
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Refuses, under `never`, a run that would use any remote channel, naming each of them,
48
+ * before anything is built, packed or sent. Also refuses an invalid value, whatever the
49
+ * channels: a typo in a switch that keeps the Publisher sealed is not ignored.
50
+ */
51
+ function refuseRemote(names, configured = {}, env = process.env) {
52
+ if (remoteMode(env) === "allow") return;
53
+ // Judged by type: a channel named partner-a may be a Maven repository at a URL.
54
+ const remote = names.filter((name) => {
55
+ const settings = configured[name] || {};
56
+ return isRemote(settings.type || name, settings);
57
+ });
58
+ if (remote.length === 0) return;
59
+ throw new ChannelError(
60
+ `${REMOTE_ENV}=never refuses the remote channels ${remote.join(", ")}, so nothing was built, packed or sent. ` +
61
+ `Publish with their local modes, or set ${REMOTE_ENV}=allow where this run may contact them.`);
62
+ }
63
+
64
+ module.exports = { REMOTE_ENV, remoteMode, isRemote, refuseRemote };
@@ -33,6 +33,32 @@ function parse(version) {
33
33
  };
34
34
  }
35
35
 
36
+ /**
37
+ * Semver precedence: negative when `a` comes before `b`, positive after, 0 when equal.
38
+ * Build metadata is ignored; a release comes after its pre-releases; pre-release
39
+ * identifiers compare numerically when both are numbers, a number before a word.
40
+ */
41
+ function compare(a, b) {
42
+ const x = parse(a);
43
+ const y = parse(b);
44
+ for (const part of ["major", "minor", "patch"]) {
45
+ if (x[part] !== y[part]) return x[part] - y[part];
46
+ }
47
+ if (x.prerelease === y.prerelease) return 0;
48
+ if (x.prerelease === null) return 1;
49
+ if (y.prerelease === null) return -1;
50
+ const xs = x.prerelease.split(".");
51
+ const ys = y.prerelease.split(".");
52
+ for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
53
+ if (xs[i] === ys[i]) continue;
54
+ const [xn, yn] = [/^\d+$/.test(xs[i]), /^\d+$/.test(ys[i])];
55
+ if (xn && yn) return Number(xs[i]) - Number(ys[i]);
56
+ if (xn !== yn) return xn ? -1 : 1;
57
+ return xs[i] < ys[i] ? -1 : 1;
58
+ }
59
+ return xs.length - ys.length;
60
+ }
61
+
36
62
  function isPrerelease(version) {
37
63
  // -SNAPSHOT is not semver-legal as Maven spells it, but it is what Maven
38
64
  // consumers expect, so it is recognised rather than rejected.
@@ -65,4 +91,4 @@ function describe(version) {
65
91
  return { prerelease: true, kind: "other" };
66
92
  }
67
93
 
68
- module.exports = { parse, isPrerelease, npmDistTag, describe, VersionError, SEMVER };
94
+ module.exports = { parse, compare, isPrerelease, npmDistTag, describe, VersionError, SEMVER };