@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 +262 -62
- package/package.json +1 -1
- package/src/channel-types.js +13 -0
- package/src/channels.js +80 -11
- package/src/cli.js +37 -8
- package/src/config.js +85 -2
- package/src/gitlab.js +187 -0
- package/src/handoff.js +2 -2
- package/src/remote.js +64 -0
- package/src/version-policy.js +27 -1
package/README.adoc
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
:source-highlighter: rouge
|
|
7
7
|
// The released version of this component: its snippets use it. The release
|
|
8
8
|
// workflow updates it; do not change it by hand.
|
|
9
|
-
:api-only-publisher-version: 0.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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 `
|
|
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
|
|
1046
|
-
`channels.maven` is
|
|
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
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1218
|
+
targets:
|
|
1219
|
+
payments:
|
|
1220
|
+
channels: [partner-a]
|
|
1221
|
+
openapi:
|
|
1222
|
+
bundle: bundles/payments_openapi_structure.yaml
|
|
1056
1223
|
----
|
|
1057
1224
|
|
|
1058
|
-
|
|
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 `
|
|
1193
|
-
*
|
|
1194
|
-
|
|
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`
|
|
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
|
|
1480
|
+
npm run apionly -- publish
|
|
1325
1481
|
----
|
|
1326
1482
|
|
|
1327
|
-
`publish
|
|
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
|
|
1332
|
-
|
|
1333
|
-
*
|
|
1334
|
-
*
|
|
1335
|
-
*
|
|
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
|
|
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
|
-
|
|
1747
|
+
In two steps, credentials never enter the image.
|
|
1560
1748
|
|
|
1561
1749
|
[IMPORTANT]
|
|
1562
|
-
.What the image does not
|
|
1750
|
+
.What the image does not publish itself yet
|
|
1563
1751
|
====
|
|
1564
|
-
The image
|
|
1565
|
-
|
|
1566
|
-
|
|
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
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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,
|
|
306
|
+
async function putFile(url, body, auth, contentType) {
|
|
305
307
|
const response = await fetch(url, {
|
|
306
308
|
method: "PUT",
|
|
307
309
|
headers: {
|
|
308
|
-
|
|
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,
|
|
326
|
-
const response = await fetch(url, { headers:
|
|
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
|
|
350
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
451
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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 };
|
package/src/version-policy.js
CHANGED
|
@@ -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 };
|