@arc-e-tect/api-only-publisher 0.16.1 → 0.17.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 +111 -23
- package/package.json +1 -1
- package/src/bundle-version.js +6 -2
- package/src/channel-error.js +29 -3
- package/src/channels.js +102 -47
- package/src/cli.js +70 -8
- package/src/config.js +22 -1
- package/src/gitlab.js +46 -28
- package/src/handoff.js +5 -3
- package/src/held-documents.js +91 -0
- package/src/nuget.js +39 -35
- package/src/tar.js +37 -1
- package/src/version-guard.js +158 -0
- package/src/zip.js +42 -1
package/README.adoc
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
:source-highlighter: rouge
|
|
7
7
|
// The released version of this component: its snippets use it. The release
|
|
8
8
|
// workflow updates it; do not change it by hand.
|
|
9
|
-
:api-only-publisher-version: 0.
|
|
9
|
+
:api-only-publisher-version: 0.17.0
|
|
10
10
|
|
|
11
11
|
[NOTE]
|
|
12
12
|
.About this documentation
|
|
@@ -191,11 +191,16 @@ Without `--target`, it also fails on a fragment no target reaches, as <<unrefere
|
|
|
191
191
|
|Report which targets' closures changed between a git ref and the working tree.
|
|
192
192
|
A target whose closure is untouched needs no new version, however much else in the repository moved.
|
|
193
193
|
|
|
194
|
+
|`check [--target <t>]... [--pre-release <ids>] [--since <ref>]`
|
|
195
|
+
|Fail on every published target that changed without a new version, from git alone, as <<version-guard,A changed target needs a new version §>> describes.
|
|
196
|
+
Made for a pull request's pipeline: no network, no credentials, nothing built.
|
|
197
|
+
|
|
194
198
|
|`pack [--pre-release <ids>] [--target <t>]... [--out <dir>]`
|
|
195
199
|
|Archive each built, published target with a `manifest.json`, at the version its version file declares.
|
|
196
200
|
|
|
197
|
-
|`publish [--pre-release <ids>] [--target <t>]... [--channel <c>]... [--out <dir>]`
|
|
198
|
-
|
|
|
201
|
+
|`publish [--pre-release <ids>] [--target <t>]... [--channel <c>]... [--since <ref>] [--out <dir>]`
|
|
202
|
+
|Check every target as `check` does, then pack each target once and 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 §>>).
|
|
203
|
+
A channel that cannot take a version is a warning, not a failure (<<channel-problems,When a channel cannot take a version §>>).
|
|
199
204
|
|
|
200
205
|
|`split [--out <dir>] [--by kind\|target]`
|
|
201
206
|
|Make the library safe to break into separate repositories.
|
|
@@ -212,6 +217,7 @@ A target whose closure is untouched needs no new version, however much else in t
|
|
|
212
217
|
npm run apionly -- build --target orders --openapi
|
|
213
218
|
npm run apionly -- publish --target orders --pre-release rc.1
|
|
214
219
|
npm run apionly -- changed --since origin/main --quiet
|
|
220
|
+
npm run apionly -- check
|
|
215
221
|
----
|
|
216
222
|
|
|
217
223
|
[#init]
|
|
@@ -408,7 +414,54 @@ Cutting one never means editing the file, and then remembering to edit it back,
|
|
|
408
414
|
|
|
409
415
|
A version names one set of documents.
|
|
410
416
|
Publishing different documents under a version that was published before, after editing a fragment without changing the version file, is refused by the API-Only Subscriber in every project that locked the earlier ones.
|
|
411
|
-
Change the version in the same commit as the fragments instead.
|
|
417
|
+
Change the version in the same commit as the fragments instead; `check` and `publish` refuse a target that does not (<<version-guard,A changed target needs a new version §>>).
|
|
418
|
+
|
|
419
|
+
[#version-guard]
|
|
420
|
+
=== A changed target needs a new version
|
|
421
|
+
|
|
422
|
+
`check` decides, for every published target, whether its version may be published, and fails naming each one that may not.
|
|
423
|
+
`publish` decides the same before it packs or sends anything, so a target that fails it is never published anywhere.
|
|
424
|
+
Both read only git: the target's release tags, its history, and the working tree, changes not yet committed included.
|
|
425
|
+
|
|
426
|
+
[cols="1,3", options="header"]
|
|
427
|
+
|===
|
|
428
|
+
|What git tells |What `check` requires
|
|
429
|
+
|
|
430
|
+
|A release tag for this exact version
|
|
431
|
+
|The target unchanged since that tag: the version was released from what the tag points at, and publishing it again is safe only with the same specifications.
|
|
432
|
+
|
|
433
|
+
|Release tags, but none for this version
|
|
434
|
+
|A version above the latest release; one below it is refused.
|
|
435
|
+
|
|
436
|
+
|No release tags at all
|
|
437
|
+
|Compared with the merge-base with the main branch, or with the ref `--since` names: a target changed since then needs a higher version than its version file declared there, and a version lower than it was there is refused.
|
|
438
|
+
A target whose version file was not there yet is new, and passes.
|
|
439
|
+
|===
|
|
440
|
+
|
|
441
|
+
Release tags are named by `versioning.tagFormat`, `{target}-v{version}` by default: `orders-v2.1.0`.
|
|
442
|
+
The Publisher never creates them: the pipeline that publishes does, after it has published, because a tool that pushes to git needs write access to the repository it builds from.
|
|
443
|
+
The reference pipelines show the step, and the `gitlab-package` channel's `release: true` creates the tag with the release.
|
|
444
|
+
The main branch is `versioning.mainBranch`, `main` by default; its remote-tracking branch, `origin/main`, is preferred, as that is what a CI checkout has.
|
|
445
|
+
|
|
446
|
+
A pre-release follows the same rules, by its full version:
|
|
447
|
+
|
|
448
|
+
* Its version file need not change: `--pre-release rc.2` already makes a new version, so a target changed since the main branch passes with an unchanged file.
|
|
449
|
+
* A pre-release that was released before -- a tag `orders-v2.1.0-rc.1` -- is refused when the target changed since, as a release is.
|
|
450
|
+
* A pre-release of a version already released is refused: `2.1.0-rc.1` sorts before `2.1.0`, so it can never be the next version.
|
|
451
|
+
|
|
452
|
+
Where the guard cannot decide, it says so, and only `check` fails on it: `check` is the gate, while `publish` fails on a proven violation alone.
|
|
453
|
+
Outside a git repository, `check` fails and `publish` warns that the versions were not checked.
|
|
454
|
+
A target with no release tag, in a checkout that has no merge-base with the main branch -- a shallow clone, or no main branch at all -- is `undecided`: `check` fails naming it, and `publish` warns and publishes it; a target with a release tag is decided all the same.
|
|
455
|
+
In CI, fetch the full history and the tags -- `actions/checkout` with `fetch-depth: 0`, or `GIT_DEPTH: 0` on GitLab -- so that nothing is left undecided.
|
|
456
|
+
Without release tags, the check on the main branch itself compares the main branch with itself, so it is in the pull request that it catches a missing bump; the tags are what make it exact everywhere.
|
|
457
|
+
|
|
458
|
+
To catch it before a push, call `check` from whatever runs the repository's git hooks -- a `pre-push` hook, or a hook manager such as husky or lefthook:
|
|
459
|
+
|
|
460
|
+
[source,sh]
|
|
461
|
+
----
|
|
462
|
+
#!/bin/sh
|
|
463
|
+
exec npx --no-install api-only-publisher check
|
|
464
|
+
----
|
|
412
465
|
|
|
413
466
|
[#closure]
|
|
414
467
|
== Independent versioning, and the dependency closure
|
|
@@ -638,7 +691,7 @@ Configured portfolio: paths=none, location=portfolios
|
|
|
638
691
|
|`file`
|
|
639
692
|
|A local directory, laid out as `<directory>/<target>/<version>/`.
|
|
640
693
|
Not a distribution mechanism between repositories: it makes the pipeline testable end to end with no infrastructure, lets a library share one build with the projects that implement it, and is a local-iteration escape hatch.
|
|
641
|
-
|
|
694
|
+
Like a registry, it never replaces a version it holds with other documents (<<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
695
|
|
|
643
696
|
|`maven`
|
|
644
697
|
|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 §>>).
|
|
@@ -700,9 +753,13 @@ A run that would publish nothing at all fails instead of succeeding quietly.
|
|
|
700
753
|
[#idempotent]
|
|
701
754
|
=== Publishing the same version again
|
|
702
755
|
|
|
703
|
-
`publish` is idempotent: publishing a version again with the same
|
|
756
|
+
`publish` is idempotent: publishing a version again with the same documents changes nothing, on every channel.
|
|
757
|
+
|
|
758
|
+
A published version is its documents: the names and SHA-256 hashes that `manifest.json` lists under `files`, which are exactly what a subscriber records in its lockfile.
|
|
759
|
+
A remote channel compares those, not the bytes of what it holds.
|
|
760
|
+
The manifest also records the commit a version was packed from, and that commit's time, so the same documents packed from a later commit are another archive, and the same version.
|
|
704
761
|
|
|
705
|
-
|
|
762
|
+
Packing the same commit again gives the same bytes, because `pack` is reproducible.
|
|
706
763
|
The same documents always pack to the same archive, byte for byte: the archive is written with a fixed mode, no owner and a fixed timestamp, and `manifest.json`'s `producedAt` is not the time `pack` ran but the time of the commit being packed.
|
|
707
764
|
Set `SOURCE_DATE_EPOCH` (seconds since 1970, the https://reproducible-builds.org/specs/source-date-epoch/[reproducible-builds convention ↗]) to choose another; outside a git repository, where there is no commit, it is the only way to make two packs identical, and without it `producedAt` is the current time.
|
|
708
765
|
|
|
@@ -712,17 +769,43 @@ What happens when the destination already has the version:
|
|
|
712
769
|
|===
|
|
713
770
|
|Destination |Already has the version
|
|
714
771
|
|
|
715
|
-
|`
|
|
772
|
+
|`maven` and `nuget` to a directory; `npm` with `publish: false`
|
|
716
773
|
|Written again. With the same content the bytes are the same; with different content the version is replaced, and every subscriber whose lockfile recorded the old bytes fails its build until the change is published as a new version.
|
|
717
774
|
|
|
718
|
-
|`maven` and `nuget` to a URL, `npm`, `github-release`, `gitlab-package`
|
|
719
|
-
|The same
|
|
775
|
+
|`file`; `maven` and `nuget` to a URL, `npm`, `github-release`, `gitlab-package`
|
|
776
|
+
|The same documents: nothing is written or sent, and the log says `already published, identical`, or `with the same documents` when the copy came from another commit.
|
|
777
|
+
Other documents: the version is not published to that channel, and the run warns, naming the version -- a published version is never replaced, so publish the change as a new version (<<channel-problems,When a channel cannot take a version §>>).
|
|
778
|
+
A copy that cannot be read, so there is nothing to compare, is treated as one with other documents, saying why it `cannot be compared`.
|
|
779
|
+
`file` compares the `manifest.json` in the version's directory, and with `clean: true` removes the other versions, never this one.
|
|
720
780
|
`maven` and `gitlab-package` read back every file before uploading any, so a run interrupted half-way is completed by the next one; a GitLab registry that allows duplicate packages, as it does by default, would otherwise replace a version.
|
|
721
|
-
|
|
722
|
-
`
|
|
723
|
-
|
|
781
|
+
A version completed beside an archive from another commit gets that archive's `manifest.json`, so the two agree on where the version came from; a POM carries no documents, and one already there is left as it is.
|
|
782
|
+
`github-release` reads the manifest in the asset the release has, and never overwrites one.
|
|
783
|
+
`npm` takes the same integrity hash the registry records, or the same tarball SHA-1 where it records only that, as GitLab's does, for the same package; otherwise it downloads the registry's copy and compares its documents.
|
|
784
|
+
Its files are packed with fixed modes, so the package is the same bytes whatever the umask of the machine that publishes it.
|
|
785
|
+
`nuget` reads the manifest in the package the feed returns.
|
|
724
786
|
|===
|
|
725
787
|
|
|
788
|
+
[#channel-problems]
|
|
789
|
+
=== When a channel cannot take a version
|
|
790
|
+
|
|
791
|
+
A channel is an external party, so what it does or does not do never fails `publish` on its own.
|
|
792
|
+
When a channel holds the version with other documents, cannot be reached, refuses the request, or has no credential in this run, the target is not published to that channel; the run warns, naming the target, the version, the channel and the reason, and goes on with every other channel and target:
|
|
793
|
+
|
|
794
|
+
[source,text]
|
|
795
|
+
----
|
|
796
|
+
warning: orders 2.1.0 was not published to partner: no credential in $PARTNER_TOKEN, which channels.maven.tokenEnv names. ...
|
|
797
|
+
|
|
798
|
+
Published 3 artifact(s).
|
|
799
|
+
Listed 2 file(s) in build/packages/packages.sha256.
|
|
800
|
+
Channel problems: 1 of 4 publication(s) did not complete.
|
|
801
|
+
----
|
|
802
|
+
|
|
803
|
+
The last line appears only when something did not complete, and ends `Nothing was published.` when nothing did; `publish` still exits 0.
|
|
804
|
+
In GitHub Actions each problem is also a `::warning` annotation on the run.
|
|
805
|
+
The hand-off records every channel's outcome (<<handoff,The hand-off §>>), and publishing is idempotent, so running `publish` again completes what is missing.
|
|
806
|
+
|
|
807
|
+
What fails the run is the library's to fix: a channel's settings in `apionly.yaml`, such as a `maven` channel without a `groupId`; the remote switch (<<remote-switch,Refusing remote publishing §>>); and a target changed without a new version (<<version-guard,A changed target needs a new version §>>), which is checked before anything is sent.
|
|
808
|
+
|
|
726
809
|
[#remote-switch]
|
|
727
810
|
=== Refusing remote publishing
|
|
728
811
|
|
|
@@ -766,6 +849,7 @@ Every `publish` run lists each file its channels wrote locally, with its SHA-256
|
|
|
766
849
|
|
|
767
850
|
| `packages.json`
|
|
768
851
|
| `schemaVersion`, and `files`: for each file, its `path`, `sha256`, `target`, `version`, `channel` -- the channel's name -- and `type`, the channel's type.
|
|
852
|
+
And `outcomes`: for each target and channel of the run, its `target`, `version`, `channel`, `type` and `outcome` -- `published`, `already-published`, `conflict` or `unavailable` -- with the `reason` for the last two.
|
|
769
853
|
|
|
770
854
|
| `packages.sha256`
|
|
771
855
|
| The same files in the format `sha256sum -c` checks.
|
|
@@ -773,7 +857,7 @@ Every `publish` run lists each file its channels wrote locally, with its SHA-256
|
|
|
773
857
|
|
|
774
858
|
Paths are relative to the library root, with forward slashes, so `sha256sum -c build/packages/packages.sha256` checks them from there.
|
|
775
859
|
A run replaces both files.
|
|
776
|
-
A channel in a remote mode writes nothing locally, so it adds
|
|
860
|
+
A channel in a remote mode writes nothing locally, so it adds no files; a channel that did not take the version adds none either, only its outcome.
|
|
777
861
|
|
|
778
862
|
The hand-off is for a later step that publishes those files itself -- the second of <<docker-two-steps,the image's two steps §>> -- so that it ships exactly the bytes the Publisher produced, and nothing else.
|
|
779
863
|
|
|
@@ -832,7 +916,7 @@ A .NET build uses it with `dotnet nuget add source <repository>`, or a `nuget.co
|
|
|
832
916
|
*A remote feed* is configured by its service index, the URL a feed's settings show and that ends in `index.json`, such as `https://api.nuget.org/v3/index.json`.
|
|
833
917
|
The Publisher reads the service index for its `PackagePublish/2.0.0` endpoint, then pushes the package there with one `PUT`, as `multipart/form-data`, with the key from the environment variable `tokenEnv` names in the `X-NuGet-ApiKey` header.
|
|
834
918
|
The `PUT` goes to that endpoint with a trailing `/`, as `dotnet nuget push` sends it; a GitLab feed recognises the upload only there.
|
|
835
|
-
Before the push, the Publisher reads the feed's copy of the version back (its `PackageBaseAddress/3.0.0` resource): the same
|
|
919
|
+
Before the push, the Publisher reads the feed's copy of the version back (its `PackageBaseAddress/3.0.0` resource): a package with the same documents succeeds without a push, one with other documents is not pushed over, and neither is a copy that cannot be read, other than one the feed does not have; `publish` warns about both (<<idempotent,Publishing the same version again §>>).
|
|
836
920
|
The check comes first because a feed may accept a different package under a version it already has: GitLab does, by default, and serves the new one from then on.
|
|
837
921
|
A feed that answers the push with 409 -- the version appeared in the meantime -- is read back and compared the same way.
|
|
838
922
|
Without the variable set, the push fails before anything is sent, naming it.
|
|
@@ -898,12 +982,12 @@ link:pipelines/gitlab-ci.yml[The reference pipeline →] publishes this way, fro
|
|
|
898
982
|
| A GitLab CI job publishing to its own project.
|
|
899
983
|
|===
|
|
900
984
|
|
|
901
|
-
Without either, `publish`
|
|
985
|
+
Without either, nothing is sent to GitLab, and `publish` warns, naming both (<<channel-problems,When a channel cannot take a version §>>).
|
|
902
986
|
A deploy token is the least privilege for packages alone: it reaches one project's package registry and nothing else.
|
|
903
987
|
|
|
904
988
|
*The same version again.*
|
|
905
989
|
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:
|
|
990
|
+
So the channel reads every file back before uploading any: a file with the same documents is left alone, a missing one is uploaded, and one with other documents is never replaced; `publish` warns about it (<<idempotent,Publishing the same version again §>>).
|
|
907
991
|
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
992
|
|
|
909
993
|
*`release: true`* also creates the tag's GitLab release, tagging `$CI_COMMIT_SHA` or, outside CI, the checkout's `HEAD`, and links both files as packages.
|
|
@@ -932,13 +1016,13 @@ channels:
|
|
|
932
1016
|
*The token* goes as a bearer token, `Authorization: Bearer <token>`, unless `tokenHeader` names another header, which then carries it as it is.
|
|
933
1017
|
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
1018
|
|
|
935
|
-
*Every file is read back* before any is uploaded:
|
|
1019
|
+
*Every file is read back* before any is uploaded: the artifact and the manifest with the same documents are left alone, a missing file is uploaded, and other documents are never replaced, and `publish` warns, naming the file; the POM carries no documents, and one already there is left alone.
|
|
936
1020
|
|
|
937
1021
|
*`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
1022
|
Nexus, Artifactory and GitHub Packages keep it themselves; GitLab does not for files uploaded this way.
|
|
939
1023
|
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
1024
|
It is written only when it lacks the version, or its `latest` or `release` would change.
|
|
941
|
-
A repository that refuses it
|
|
1025
|
+
A repository that refuses it is a warning naming `metadata: false`; the version's files are published by then, and a re-run retries only the metadata.
|
|
942
1026
|
|
|
943
1027
|
[#why-registries]
|
|
944
1028
|
=== Why npm, Maven and NuGet rather than release assets alone
|
|
@@ -1046,6 +1130,10 @@ targets:
|
|
|
1046
1130
|
|
|
1047
1131
|
portfolio: # absent is fine; an aggregate builds on documented defaults
|
|
1048
1132
|
location: portfolios # see Aggregates §
|
|
1133
|
+
|
|
1134
|
+
versioning: # absent is fine; see A changed target needs a new version §
|
|
1135
|
+
tagFormat: "{target}-v{version}" # the release tags the pipeline creates; the default
|
|
1136
|
+
mainBranch: main # compared with when a target has no release tag; the default
|
|
1049
1137
|
----
|
|
1050
1138
|
|
|
1051
1139
|
`targets` is the single source of truth for what gets built.
|
|
@@ -1309,8 +1397,8 @@ A service subscribes to its own contract to implement it, and to the contracts o
|
|
|
1309
1397
|
|
|
1310
1398
|
* Versions are per target, so a change to one service's fragments releases only that service's contract.
|
|
1311
1399
|
A change to a fragment several targets share changes all of them, and `changed` names which, as <<closure,Independent versioning, and the dependency closure §>> describes.
|
|
1312
|
-
* Publishing every target on every run is safe: a version that is already published, with the same
|
|
1313
|
-
What fails is a changed contract whose version was not changed, which is the mistake `
|
|
1400
|
+
* Publishing every target on every run is safe: a version that is already published, with the same documents, is left alone, whichever commit it was packed from (<<idempotent,Publishing the same version again §>>).
|
|
1401
|
+
What fails is a changed contract whose version was not changed, which is the mistake `check` exists to catch first.
|
|
1314
1402
|
* `landscape` is optional: a whole-landscape view for reading, generated on every build and never published, as <<aggregates,Aggregates §>> describes.
|
|
1315
1403
|
|
|
1316
1404
|
[#recipe-business-partners]
|
|
@@ -2035,7 +2123,7 @@ include::pipelines/github-actions.yml[]
|
|
|
2035
2123
|
|
|
2036
2124
|
What the two steps lose, and how the pipeline makes up for it:
|
|
2037
2125
|
|
|
2038
|
-
* *Idempotence.* A channel's remote mode leaves a version the registry has alone when it
|
|
2126
|
+
* *Idempotence.* A channel's remote mode leaves a version the registry has alone when it holds the same documents, and refuses it when it does not; `npm publish` or `dotnet nuget push` of an existing version fails instead.
|
|
2039
2127
|
Each publishing step checks for the version first, compares the bytes, and publishes only what is missing, so a re-run is safe and a real conflict still fails.
|
|
2040
2128
|
* *Maven checksums and metadata.* The local Maven layout holds the artifact, its POM and its manifest, and no `.md5`, `.sha1` or `maven-metadata.xml`.
|
|
2041
2129
|
`mvn deploy:deploy-file` writes those when it uploads, so the repository ends up as a remote `maven` channel would leave it, and Gradle resolves it.
|
|
@@ -2154,7 +2242,7 @@ docker build -t api-only-publisher:local --build-arg VERSION=0.0.0-local .
|
|
|
2154
2242
|
docker/smoke-test.sh api-only-publisher:local 0.0.0-local
|
|
2155
2243
|
----
|
|
2156
2244
|
|
|
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.
|
|
2245
|
+
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, checked -- a change without a new version refused -- 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
2246
|
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.
|
|
2159
2247
|
|
|
2160
2248
|
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.
|
package/package.json
CHANGED
package/src/bundle-version.js
CHANGED
|
@@ -22,8 +22,12 @@ class BundleVersionError extends Error {}
|
|
|
22
22
|
// -- so a JVM build can read the same file with java.util.Properties. Only what a
|
|
23
23
|
// version file needs: no escapes, no continuation lines.
|
|
24
24
|
function readProperties(file) {
|
|
25
|
+
return parseProperties(fs.readFileSync(file, "utf8"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseProperties(text) {
|
|
25
29
|
const properties = {};
|
|
26
|
-
for (const raw of
|
|
30
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
27
31
|
const line = raw.trim();
|
|
28
32
|
if (line === "" || line.startsWith("#") || line.startsWith("!")) continue;
|
|
29
33
|
const separator = line.search(/[=:]/);
|
|
@@ -77,4 +81,4 @@ function versionOf(config, target, { preRelease = null } = {}) {
|
|
|
77
81
|
return version;
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
module.exports = { versionOf, readProperties, BundleVersionError };
|
|
84
|
+
module.exports = { versionOf, readProperties, parseProperties, BundleVersionError };
|
package/src/channel-error.js
CHANGED
|
@@ -1,7 +1,33 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
// The
|
|
4
|
-
// implemented outside channels.js can throw
|
|
3
|
+
// The errors every distribution channel throws, in a module of its own so that a channel
|
|
4
|
+
// implemented outside channels.js can throw them without requiring channels.js back.
|
|
5
|
+
//
|
|
6
|
+
// A ChannelError is the library's to fix -- a channel's settings in apionly.yaml -- and
|
|
7
|
+
// fails the run. Its two subclasses are about the channel, an external party, and never
|
|
8
|
+
// fail the run on their own: `publish` reports them and goes on with the other channels.
|
|
9
|
+
|
|
5
10
|
class ChannelError extends Error {}
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
/** The channel holds this version with other documents, or with a copy that cannot be compared. */
|
|
13
|
+
class ChannelConflict extends ChannelError {}
|
|
14
|
+
|
|
15
|
+
/** The channel could not be reached, refused the request, or this run has no credential for it. */
|
|
16
|
+
class ChannelUnavailable extends ChannelError {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What became of a publication that threw `error`: `conflict` or `unavailable`, or null
|
|
20
|
+
* when the error is the library's own and must fail the run. A request that never got an
|
|
21
|
+
* answer -- fetch's "fetch failed" -- and a registry client that exited with an error,
|
|
22
|
+
* such as npm or gh, are the channel being unavailable.
|
|
23
|
+
*/
|
|
24
|
+
function outcomeOf(error) {
|
|
25
|
+
if (error instanceof ChannelConflict) return "conflict";
|
|
26
|
+
if (error instanceof ChannelUnavailable) return "unavailable";
|
|
27
|
+
if (error instanceof ChannelError) return null;
|
|
28
|
+
if (error instanceof TypeError && error.message === "fetch failed") return "unavailable";
|
|
29
|
+
if (error && typeof error.status === "number" && "stderr" in error) return "unavailable";
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { ChannelError, ChannelConflict, ChannelUnavailable, outcomeOf };
|
package/src/channels.js
CHANGED
|
@@ -13,10 +13,11 @@ const path = require("path");
|
|
|
13
13
|
const { execFileSync } = require("child_process");
|
|
14
14
|
|
|
15
15
|
const { parse, compare, isPrerelease, npmDistTag } = require("./version-policy");
|
|
16
|
-
const { ChannelError } = require("./channel-error");
|
|
16
|
+
const { ChannelError, ChannelConflict, ChannelUnavailable } = require("./channel-error");
|
|
17
17
|
const { publishNuget } = require("./nuget");
|
|
18
18
|
const { publishGitlabPackage } = require("./gitlab");
|
|
19
19
|
const { refuseRemote } = require("./remote");
|
|
20
|
+
const { judgeHeld } = require("./held-documents");
|
|
20
21
|
|
|
21
22
|
/** What every package and release says it is, unless `description` says otherwise. */
|
|
22
23
|
const DEFAULT_DESCRIPTION = "API description documents for {target}.";
|
|
@@ -32,26 +33,43 @@ function format(template, manifest) {
|
|
|
32
33
|
*
|
|
33
34
|
* Not a real distribution mechanism. It exists so the pipeline is testable end
|
|
34
35
|
* to end before any remote exists, and as a local-iteration escape hatch
|
|
35
|
-
* afterwards.
|
|
36
|
+
* afterwards. Like the registries, it never replaces a version: one it holds with
|
|
37
|
+
* the same documents is left alone, and one with other documents is refused.
|
|
36
38
|
*/
|
|
37
39
|
function publishFile(archive, manifest, options, log) {
|
|
38
40
|
// Relative to the library root, not to wherever the CLI happened to be run.
|
|
39
41
|
const dir = path.resolve(options.baseDir || ".", options.directory || "publish");
|
|
40
42
|
const targetDir = path.join(dir, manifest.target, manifest.version);
|
|
43
|
+
const archiveDest = path.join(targetDir, path.basename(archive));
|
|
44
|
+
const manifestDest = path.join(targetDir, "manifest.json");
|
|
45
|
+
const files = [archiveDest, manifestDest];
|
|
46
|
+
|
|
47
|
+
const held = fs.existsSync(manifestDest) ? judgeHeld(fs.readFileSync(manifestDest), "json", manifest) : null;
|
|
48
|
+
if (held && held.verdict !== "same") {
|
|
49
|
+
throw new ChannelConflict(`${dir} already has ${manifest.target} ${manifest.version}, and it ` +
|
|
50
|
+
`${held.verdict === "different" ? "is not this one" : `cannot be compared: ${held.reason}`}; ` +
|
|
51
|
+
"a published version is never replaced, so publish the change as a new version");
|
|
52
|
+
}
|
|
41
53
|
// `clean: true` keeps only the version being published. Used where the
|
|
42
54
|
// directory is a build output rather than a record of releases: an older
|
|
43
55
|
// version is published again from the commit that declared it.
|
|
44
|
-
if (options.clean === true) {
|
|
45
|
-
fs.
|
|
56
|
+
if (options.clean === true && fs.existsSync(path.join(dir, manifest.target))) {
|
|
57
|
+
for (const version of fs.readdirSync(path.join(dir, manifest.target))) {
|
|
58
|
+
if (version !== manifest.version) fs.rmSync(path.join(dir, manifest.target, version), { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (held && fs.existsSync(archiveDest)) {
|
|
62
|
+
const identical = fs.readFileSync(archiveDest).equals(fs.readFileSync(archive));
|
|
63
|
+
log(`-- ${manifest.target} ${manifest.version} is already published, ` +
|
|
64
|
+
`${identical ? "identical" : "with the same documents"}, in ${dir}; nothing to do`);
|
|
65
|
+
return { location: archiveDest, files, alreadyPublished: true };
|
|
46
66
|
}
|
|
47
67
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
48
|
-
|
|
49
|
-
const archiveDest = path.join(targetDir, path.basename(archive));
|
|
50
68
|
fs.copyFileSync(archive, archiveDest);
|
|
51
|
-
fs.writeFileSync(
|
|
69
|
+
fs.writeFileSync(manifestDest, JSON.stringify(manifest, null, 2) + "\n");
|
|
52
70
|
|
|
53
71
|
log(`-- Published ${manifest.target} ${manifest.version} to ${archiveDest}`);
|
|
54
|
-
return { location: archiveDest, files
|
|
72
|
+
return { location: archiveDest, files };
|
|
55
73
|
}
|
|
56
74
|
|
|
57
75
|
function pom(groupId, artifactId, version, packaging, description = `API description documents for ${artifactId}.`) {
|
|
@@ -175,29 +193,29 @@ function publishNpm(archive, manifest, options, log) {
|
|
|
175
193
|
return { location, distTag, name, files: [location] };
|
|
176
194
|
}
|
|
177
195
|
|
|
178
|
-
// Idempotent: a version the registry already has is left alone when it
|
|
179
|
-
// same
|
|
196
|
+
// Idempotent: a version the registry already has is left alone when it holds the
|
|
197
|
+
// same documents, and refused when it does not -- npm never replaces a version.
|
|
180
198
|
const registry = options.registry ? ["--registry", options.registry] : [];
|
|
181
199
|
const [local] = JSON.parse(execFileSync("npm", ["pack", "--dry-run", "--json"],
|
|
182
200
|
{ cwd: workDir, encoding: "utf8", stdio: "pipe" }));
|
|
183
201
|
const published = npmPublishedDist(name, version, registry);
|
|
184
202
|
if (published) {
|
|
185
203
|
// The SHA-512 integrity when the registry records one; the tarball's SHA-1
|
|
186
|
-
// otherwise, as GitLab's npm registry records only that.
|
|
204
|
+
// otherwise, as GitLab's npm registry records only that. The same hash is the
|
|
205
|
+
// same package; any other answer is settled by the documents in the registry's copy.
|
|
187
206
|
const [held, mine] = published.integrity ? [published.integrity, local.integrity]
|
|
188
207
|
: published.shasum ? [published.shasum, local.shasum] : [null, null];
|
|
189
|
-
if (
|
|
190
|
-
throw new ChannelError(
|
|
191
|
-
`npm already has ${name}@${version}, and it cannot be compared: the registry records neither ` +
|
|
192
|
-
"an integrity nor a shasum for it. A published version is never replaced; publish the change as a new version.");
|
|
193
|
-
}
|
|
194
|
-
if (held === mine) {
|
|
208
|
+
if (held && held === mine) {
|
|
195
209
|
log(`-- npm ${name}@${version} is already published, identical; nothing to do`);
|
|
196
210
|
return { location: name + "@" + version, distTag, name, alreadyPublished: true };
|
|
197
211
|
}
|
|
198
|
-
|
|
199
|
-
`npm already has ${name}@${version}, and it is not this package (${held}, not ${mine}). ` +
|
|
212
|
+
const refused = (why) => new ChannelConflict(`npm already has ${name}@${version}, and it ${why}. ` +
|
|
200
213
|
"A published version is never replaced; publish the change as a new version.");
|
|
214
|
+
const judged = judgeHeld(npmPublishedPackage(name, version, registry), "tgz", manifest);
|
|
215
|
+
if (judged.verdict === "unreadable") throw refused(`cannot be compared: ${judged.reason}`);
|
|
216
|
+
if (judged.verdict === "different") throw refused(held ? `is not this package (${held}, not ${mine})` : "is not this package");
|
|
217
|
+
log(`-- npm ${name}@${version} is already published, with the same documents; nothing to do`);
|
|
218
|
+
return { location: name + "@" + version, distTag, name, alreadyPublished: true };
|
|
201
219
|
}
|
|
202
220
|
|
|
203
221
|
const args = ["publish", "--tag", distTag, ...registry];
|
|
@@ -224,7 +242,25 @@ function npmPublishedDist(name, version, registry) {
|
|
|
224
242
|
} catch (error) {
|
|
225
243
|
const output = `${error.stdout || ""}${error.stderr || ""}`;
|
|
226
244
|
if (/E404/.test(output)) return null;
|
|
227
|
-
throw new
|
|
245
|
+
throw new ChannelUnavailable(`could not ask npm whether ${name}@${version} is published: ${output.trim() || error.message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The package npm holds as `name@version`, downloaded with the registry's own credentials. */
|
|
250
|
+
function npmPublishedPackage(name, version, registry) {
|
|
251
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "api-only-npm-held-"));
|
|
252
|
+
try {
|
|
253
|
+
let packed;
|
|
254
|
+
try {
|
|
255
|
+
packed = JSON.parse(execFileSync("npm", ["pack", `${name}@${version}`, "--pack-destination", dir, "--json", ...registry],
|
|
256
|
+
{ encoding: "utf8", stdio: "pipe" }));
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const output = `${error.stdout || ""}${error.stderr || ""}`.trim();
|
|
259
|
+
throw new ChannelUnavailable(`could not read ${name}@${version} back from npm to compare: ${output || error.message}`);
|
|
260
|
+
}
|
|
261
|
+
return fs.readFileSync(path.join(dir, packed[0].filename));
|
|
262
|
+
} finally {
|
|
263
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
228
264
|
}
|
|
229
265
|
}
|
|
230
266
|
|
|
@@ -265,26 +301,29 @@ function publishGithubRelease(archive, manifest, options, log) {
|
|
|
265
301
|
}
|
|
266
302
|
|
|
267
303
|
// Idempotent, as the registries are: an asset the release already has is left alone
|
|
268
|
-
// when it
|
|
269
|
-
// replace a published contract under the same version.
|
|
304
|
+
// when it holds the same documents, and refused when it does not. Never --clobber: that
|
|
305
|
+
// would replace a published contract under the same version.
|
|
270
306
|
const asset = path.basename(archive);
|
|
271
307
|
const assets = exists
|
|
272
308
|
? gh(["release", "view", tag, "--repo", repository, "--json", "assets", "--jq", ".assets[].name"]).split("\n").map((a) => a.trim())
|
|
273
309
|
: [];
|
|
274
310
|
if (assets.includes(asset)) {
|
|
275
311
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "api-only-gh-"));
|
|
312
|
+
let held;
|
|
276
313
|
try {
|
|
277
314
|
gh(["release", "download", tag, "--repo", repository, "--pattern", asset, "--dir", dir]);
|
|
278
|
-
|
|
279
|
-
log(`-- ${repository} release ${tag} already has ${asset}, identical; nothing to do`);
|
|
280
|
-
return { location: `${repository}@${tag}`, tag, alreadyPublished: true };
|
|
281
|
-
}
|
|
315
|
+
held = fs.readFileSync(path.join(dir, asset));
|
|
282
316
|
} finally {
|
|
283
317
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
284
318
|
}
|
|
285
|
-
|
|
286
|
-
`${repository} release ${tag} already has ${asset}, and it is not this archive. ` +
|
|
319
|
+
const refused = (why) => new ChannelConflict(`${repository} release ${tag} already has ${asset}, and it ${why}. ` +
|
|
287
320
|
"A published version is never replaced; publish the change as a new version.");
|
|
321
|
+
const judged = judgeHeld(held, "tgz", manifest);
|
|
322
|
+
if (judged.verdict === "unreadable") throw refused(`cannot be compared: ${judged.reason}`);
|
|
323
|
+
if (judged.verdict === "different") throw refused("is not this archive");
|
|
324
|
+
const identical = held.equals(fs.readFileSync(archive));
|
|
325
|
+
log(`-- ${repository} release ${tag} already has ${asset}, ${identical ? "identical" : "with the same documents"}; nothing to do`);
|
|
326
|
+
return { location: `${repository}@${tag}`, tag, alreadyPublished: true };
|
|
288
327
|
}
|
|
289
328
|
gh(["release", "upload", tag, archive, "--repo", repository]);
|
|
290
329
|
log(`-- Published ${manifest.target} ${manifest.version} to ${repository} release ${tag}`);
|
|
@@ -314,7 +353,7 @@ async function putFile(url, body, auth, contentType) {
|
|
|
314
353
|
body,
|
|
315
354
|
});
|
|
316
355
|
if (!response.ok) {
|
|
317
|
-
throw new
|
|
356
|
+
throw new ChannelUnavailable(
|
|
318
357
|
`PUT ${url} failed: ${response.status} ${response.statusText}. ` +
|
|
319
358
|
(response.status === 401 || response.status === 403
|
|
320
359
|
? "Check the token in the environment variable named by channels.maven.tokenEnv."
|
|
@@ -328,7 +367,7 @@ async function getFile(url, auth) {
|
|
|
328
367
|
const response = await fetch(url, { headers: auth });
|
|
329
368
|
if (response.status === 404) return null;
|
|
330
369
|
if (!response.ok) {
|
|
331
|
-
throw new
|
|
370
|
+
throw new ChannelUnavailable(`GET ${url} failed: ${response.status} ${response.statusText}. ` +
|
|
332
371
|
(response.status === 401 || response.status === 403
|
|
333
372
|
? "Check the token in the environment variable named by channels.maven.tokenEnv." : ""));
|
|
334
373
|
}
|
|
@@ -376,7 +415,7 @@ async function updateMetadata(url, groupId, artifactId, manifest, auth, log) {
|
|
|
376
415
|
try {
|
|
377
416
|
await putFile(url, Buffer.from(metadataXml(groupId, artifactId, listed, lastUpdated), "utf8"), auth, "application/xml");
|
|
378
417
|
} catch (error) {
|
|
379
|
-
throw new
|
|
418
|
+
throw new ChannelUnavailable(`the repository refused maven-metadata.xml for ${groupId}:${artifactId}: ${error.message.trim()} ` +
|
|
380
419
|
"The version's files are published; a re-run retries only the metadata. For a repository that generates " +
|
|
381
420
|
"its own maven-metadata.xml, set channels.maven.metadata: false.");
|
|
382
421
|
}
|
|
@@ -387,7 +426,7 @@ function publishMavenRemote(archive, manifest, options, log) {
|
|
|
387
426
|
const tokenEnv = options.tokenEnv || "MAVEN_TOKEN";
|
|
388
427
|
const token = process.env[tokenEnv];
|
|
389
428
|
if (!token) {
|
|
390
|
-
throw new
|
|
429
|
+
throw new ChannelUnavailable(
|
|
391
430
|
`no credential in $${tokenEnv}, which channels.maven.tokenEnv names. ` +
|
|
392
431
|
`Remote publication needs one; set it in the release job's environment.`
|
|
393
432
|
);
|
|
@@ -408,41 +447,57 @@ function publishMavenRemote(archive, manifest, options, log) {
|
|
|
408
447
|
const base = `${artifactDir}/${version}/${artifactId}-${version}`;
|
|
409
448
|
|
|
410
449
|
const work = (async () => {
|
|
411
|
-
// Idempotent,
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
450
|
+
// Idempotent, by documents: each file the repository already has that carries them --
|
|
451
|
+
// the artifact and the manifest -- is left alone when it holds the same documents, and
|
|
452
|
+
// refuses the whole version when it does not; the POM carries none, and is left alone.
|
|
453
|
+
// Every file is read back before any is uploaded, so a run interrupted after the
|
|
454
|
+
// artifact is completed by the next one, and a version that differs anywhere is not
|
|
455
|
+
// touched.
|
|
415
456
|
const files = [
|
|
416
|
-
{ url: `${base}.${extension}`, bytes: fs.readFileSync(archive), type: "application/octet-stream" },
|
|
457
|
+
{ url: `${base}.${extension}`, bytes: fs.readFileSync(archive), type: "application/octet-stream", kind: "tgz" },
|
|
417
458
|
{ url: `${base}.pom`, type: "application/xml",
|
|
418
459
|
bytes: Buffer.from(pom(options.groupId, artifactId, version, extension, pomDescription(options, manifest)), "utf8") },
|
|
419
|
-
{ url: `${base}-manifest.json`, type: "application/json",
|
|
460
|
+
{ url: `${base}-manifest.json`, type: "application/json", kind: "json",
|
|
420
461
|
bytes: Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8") },
|
|
421
462
|
];
|
|
463
|
+
const refused = (file, why) => new ChannelConflict(
|
|
464
|
+
`${options.repository} already has ${options.groupId}:${artifactId}:${version}, ` +
|
|
465
|
+
`and its ${file.url.split("/").pop()} ${why}. ` +
|
|
466
|
+
"A published version is never replaced; publish the change as a new version.");
|
|
422
467
|
const missing = [];
|
|
468
|
+
let identical = true;
|
|
469
|
+
let heldArchiveManifest = null;
|
|
423
470
|
for (const file of files) {
|
|
424
471
|
const existing = await getFile(file.url, auth);
|
|
425
472
|
if (!existing) {
|
|
426
473
|
missing.push(file);
|
|
427
|
-
|
|
428
|
-
throw new ChannelError(
|
|
429
|
-
`${options.repository} already has ${options.groupId}:${artifactId}:${version}, ` +
|
|
430
|
-
`and its ${file.url.split("/").pop()} is not this one. ` +
|
|
431
|
-
"A published version is never replaced; publish the change as a new version.");
|
|
474
|
+
continue;
|
|
432
475
|
}
|
|
476
|
+
identical = identical && existing.equals(file.bytes);
|
|
477
|
+
if (!file.kind) continue;
|
|
478
|
+
const judged = judgeHeld(existing, file.kind, manifest);
|
|
479
|
+
if (judged.verdict === "unreadable") throw refused(file, `cannot be compared: ${judged.reason}`);
|
|
480
|
+
if (judged.verdict === "different") throw refused(file, "is not this one");
|
|
481
|
+
if (file.kind === "tgz") heldArchiveManifest = judged.held.bytes;
|
|
482
|
+
}
|
|
483
|
+
for (const file of missing) {
|
|
484
|
+
// A version completed beside an artifact from another commit gets that artifact's
|
|
485
|
+
// manifest, so the two agree on where the version came from.
|
|
486
|
+
const bytes = file.kind === "json" && heldArchiveManifest ? heldArchiveManifest : file.bytes;
|
|
487
|
+
await putFile(file.url, bytes, auth, file.type);
|
|
433
488
|
}
|
|
434
|
-
for (const file of missing) await putFile(file.url, file.bytes, auth, file.type);
|
|
435
489
|
if (options.metadata !== false) {
|
|
436
490
|
await updateMetadata(`${artifactDir}/maven-metadata.xml`, options.groupId, artifactId, manifest, auth, log);
|
|
437
491
|
}
|
|
438
|
-
return missing.length === 0;
|
|
492
|
+
return { alreadyPublished: missing.length === 0, identical };
|
|
439
493
|
})();
|
|
440
494
|
|
|
441
495
|
// The CLI is synchronous throughout; surfacing the promise here would make
|
|
442
496
|
// every caller async for one channel's benefit.
|
|
443
|
-
return work.then((alreadyPublished) => {
|
|
497
|
+
return work.then(({ alreadyPublished, identical }) => {
|
|
444
498
|
log(alreadyPublished
|
|
445
|
-
? `-- ${options.groupId}:${artifactId}:${version} is already in ${options.repository},
|
|
499
|
+
? `-- ${options.groupId}:${artifactId}:${version} is already in ${options.repository}, ` +
|
|
500
|
+
`${identical ? "identical" : "with the same documents"}; nothing to do`
|
|
446
501
|
: `-- Deployed ${options.groupId}:${artifactId}:${version} to ${options.repository}`);
|
|
447
502
|
return {
|
|
448
503
|
location: `${base}.${extension}`, coordinates: `${options.groupId}:${artifactId}:${version}@${extension}`,
|