@arc-e-tect/api-only-publisher 0.16.2 → 0.18.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 +110 -22
- package/package.json +1 -1
- package/src/bundle-version.js +6 -2
- package/src/channel-error.js +29 -3
- package/src/channels.js +34 -17
- package/src/cli.js +70 -8
- package/src/config.js +22 -1
- package/src/gitlab.js +8 -8
- package/src/handoff.js +5 -3
- package/src/nuget.js +8 -8
- package/src/version-guard.js +158 -0
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.18.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 §>>).
|
|
@@ -716,13 +769,14 @@ What happens when the destination already has the version:
|
|
|
716
769
|
|===
|
|
717
770
|
|Destination |Already has the version
|
|
718
771
|
|
|
719
|
-
|`
|
|
772
|
+
|`maven` and `nuget` to a directory; `npm` with `publish: false`
|
|
720
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.
|
|
721
774
|
|
|
722
|
-
|`maven` and `nuget` to a URL, `npm`, `github-release`, `gitlab-package`
|
|
723
|
-
|The same documents: nothing is sent, and the log says `already published, identical`, or `with the same documents` when the copy came from another commit.
|
|
724
|
-
Other documents:
|
|
725
|
-
A copy that cannot be read, so there is nothing to compare,
|
|
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.
|
|
726
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.
|
|
727
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.
|
|
728
782
|
`github-release` reads the manifest in the asset the release has, and never overwrites one.
|
|
@@ -731,6 +785,27 @@ Its files are packed with fixed modes, so the package is the same bytes whatever
|
|
|
731
785
|
`nuget` reads the manifest in the package the feed returns.
|
|
732
786
|
|===
|
|
733
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
|
+
|
|
734
809
|
[#remote-switch]
|
|
735
810
|
=== Refusing remote publishing
|
|
736
811
|
|
|
@@ -774,6 +849,7 @@ Every `publish` run lists each file its channels wrote locally, with its SHA-256
|
|
|
774
849
|
|
|
775
850
|
| `packages.json`
|
|
776
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.
|
|
777
853
|
|
|
778
854
|
| `packages.sha256`
|
|
779
855
|
| The same files in the format `sha256sum -c` checks.
|
|
@@ -781,7 +857,7 @@ Every `publish` run lists each file its channels wrote locally, with its SHA-256
|
|
|
781
857
|
|
|
782
858
|
Paths are relative to the library root, with forward slashes, so `sha256sum -c build/packages/packages.sha256` checks them from there.
|
|
783
859
|
A run replaces both files.
|
|
784
|
-
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.
|
|
785
861
|
|
|
786
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.
|
|
787
863
|
|
|
@@ -840,7 +916,7 @@ A .NET build uses it with `dotnet nuget add source <repository>`, or a `nuget.co
|
|
|
840
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`.
|
|
841
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.
|
|
842
918
|
The `PUT` goes to that endpoint with a trailing `/`, as `dotnet nuget push` sends it; a GitLab feed recognises the upload only there.
|
|
843
|
-
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
|
|
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 §>>).
|
|
844
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.
|
|
845
921
|
A feed that answers the push with 409 -- the version appeared in the meantime -- is read back and compared the same way.
|
|
846
922
|
Without the variable set, the push fails before anything is sent, naming it.
|
|
@@ -906,12 +982,12 @@ link:pipelines/gitlab-ci.yml[The reference pipeline →] publishes this way, fro
|
|
|
906
982
|
| A GitLab CI job publishing to its own project.
|
|
907
983
|
|===
|
|
908
984
|
|
|
909
|
-
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 §>>).
|
|
910
986
|
A deploy token is the least privilege for packages alone: it reaches one project's package registry and nothing else.
|
|
911
987
|
|
|
912
988
|
*The same version again.*
|
|
913
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.
|
|
914
|
-
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
|
|
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 §>>).
|
|
915
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.
|
|
916
992
|
|
|
917
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.
|
|
@@ -940,13 +1016,13 @@ channels:
|
|
|
940
1016
|
*The token* goes as a bearer token, `Authorization: Bearer <token>`, unless `tokenHeader` names another header, which then carries it as it is.
|
|
941
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 `/`.
|
|
942
1018
|
|
|
943
|
-
*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
|
|
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.
|
|
944
1020
|
|
|
945
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.
|
|
946
1022
|
Nexus, Artifactory and GitHub Packages keep it themselves; GitLab does not for files uploaded this way.
|
|
947
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.
|
|
948
1024
|
It is written only when it lacks the version, or its `latest` or `release` would change.
|
|
949
|
-
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.
|
|
950
1026
|
|
|
951
1027
|
[#why-registries]
|
|
952
1028
|
=== Why npm, Maven and NuGet rather than release assets alone
|
|
@@ -1054,6 +1130,10 @@ targets:
|
|
|
1054
1130
|
|
|
1055
1131
|
portfolio: # absent is fine; an aggregate builds on documented defaults
|
|
1056
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
|
|
1057
1137
|
----
|
|
1058
1138
|
|
|
1059
1139
|
`targets` is the single source of truth for what gets built.
|
|
@@ -1318,7 +1398,7 @@ A service subscribes to its own contract to implement it, and to the contracts o
|
|
|
1318
1398
|
* Versions are per target, so a change to one service's fragments releases only that service's contract.
|
|
1319
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.
|
|
1320
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 §>>).
|
|
1321
|
-
What fails is a changed contract whose version was not changed, which is the mistake `
|
|
1401
|
+
What fails is a changed contract whose version was not changed, which is the mistake `check` exists to catch first.
|
|
1322
1402
|
* `landscape` is optional: a whole-landscape view for reading, generated on every build and never published, as <<aggregates,Aggregates §>> describes.
|
|
1323
1403
|
|
|
1324
1404
|
[#recipe-business-partners]
|
|
@@ -2030,8 +2110,9 @@ package:
|
|
|
2030
2110
|
[#docker-github-actions]
|
|
2031
2111
|
==== Reference pipeline: GitHub Actions
|
|
2032
2112
|
|
|
2033
|
-
Two jobs: `package` runs the image and hands the files on, `publish` checks them against the hand-off
|
|
2034
|
-
|
|
2113
|
+
Two jobs on a tag: `package` runs the image and hands the files on, `publish` checks them against the hand-off, publishes them to npm, Maven and NuGet on GitHub Packages, and to a GitHub Release, with the job's `GITHUB_TOKEN`, and tags each target's release, `<target>-v<version>`.
|
|
2114
|
+
On a pull request, a third job, `check`, runs the image's `check` with no network, against the merge-base with the pull request's base branch (<<version-guard,A changed target needs a new version §>>).
|
|
2115
|
+
Copy it into `.github/workflows/`, then set the digest, the targets and the trigger, and `TAG_FORMAT` if `versioning.tagFormat` names the tags otherwise.
|
|
2035
2116
|
The container-job variant above replaces its `package` job.
|
|
2036
2117
|
|
|
2037
2118
|
It is exercised end to end, both variants, by a workflow in this repository that publishes to scratch packages and resolves each one with its ecosystem's own client and with the API-Only Subscriber.
|
|
@@ -2043,8 +2124,11 @@ include::pipelines/github-actions.yml[]
|
|
|
2043
2124
|
|
|
2044
2125
|
What the two steps lose, and how the pipeline makes up for it:
|
|
2045
2126
|
|
|
2046
|
-
* *Idempotence.* A channel's remote mode leaves a version the registry has alone when it holds the same documents, and
|
|
2047
|
-
Each publishing step checks for the version first, compares the bytes, and publishes only what is missing, so a re-run is safe
|
|
2127
|
+
* *Idempotence.* A channel's remote mode leaves a version the registry has alone when it holds the same documents, and does not replace it when it holds others; `npm publish` or `dotnet nuget push` of an existing version fails instead.
|
|
2128
|
+
Each publishing step checks for the version first, compares the documents its `manifest.json` lists -- not the bytes, which differ by commit -- and publishes only what is missing, so a re-run is safe.
|
|
2129
|
+
A version held with other documents, or a registry that cannot be reached, is a `::warning` on the run, never a failed job, as for the Publisher's own channels (<<channel-problems,When a channel cannot take a version §>>).
|
|
2130
|
+
* *History.* Every job that runs the Publisher checks out the full history and the tags, `fetch-depth: 0`, which `check` and `publish` compare each target's version against.
|
|
2131
|
+
* *Release tags.* The last step creates each target's release tag at the commit it published, unless it exists; a tag it cannot create is a warning.
|
|
2048
2132
|
* *Maven checksums and metadata.* The local Maven layout holds the artifact, its POM and its manifest, and no `.md5`, `.sha1` or `maven-metadata.xml`.
|
|
2049
2133
|
`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.
|
|
2050
2134
|
|
|
@@ -2052,6 +2136,8 @@ Each publishing step checks for the version first, compares the bytes, and publi
|
|
|
2052
2136
|
==== Reference pipeline: GitLab CI
|
|
2053
2137
|
|
|
2054
2138
|
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`.
|
|
2139
|
+
In a merge request, a `check` job runs the image's `check` against the merge-base with the target branch; both jobs fetch the full history, `GIT_DEPTH: 0`, and the branch they compare with.
|
|
2140
|
+
`gitlab-package` with `release: true` creates each target's release, and with it the tag `<target>-v<version>` that `check` reads; without it, nothing creates that tag, and `check` compares with the merge-base alone.
|
|
2055
2141
|
`apionly.yaml` needs no GitLab settings for it; the pipeline's header says what to add for the Maven registry.
|
|
2056
2142
|
Copy it to `.gitlab-ci.yml`, then set the digest and the targets.
|
|
2057
2143
|
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.
|
|
@@ -2067,6 +2153,8 @@ include::pipelines/gitlab-ci.yml[]
|
|
|
2067
2153
|
link:../docs/guides/publishing-to-gitlab/overview.adoc[Publishing to GitLab →] is the guide: tokens, a self-managed GitLab, and what consumers need.
|
|
2068
2154
|
|
|
2069
2155
|
*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.
|
|
2156
|
+
As in the GitHub Actions pipeline, each compares documents and warns rather than fails, a merge request runs `check`, and a last job, `release:tags`, creates a GitLab release, and with it the tag, for each target's release; a pre-release gets none, as GitLab would make it the latest release.
|
|
2157
|
+
It runs on a `v*` tag, so the tags it creates do not start it again.
|
|
2070
2158
|
It is validated against GitLab's published CI schema only; no GitLab pipeline has run it yet.
|
|
2071
2159
|
|
|
2072
2160
|
[#docker-mirror-in-pipelines]
|
|
@@ -2162,7 +2250,7 @@ docker build -t api-only-publisher:local --build-arg VERSION=0.0.0-local .
|
|
|
2162
2250
|
docker/smoke-test.sh api-only-publisher:local 0.0.0-local
|
|
2163
2251
|
----
|
|
2164
2252
|
|
|
2165
|
-
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.
|
|
2253
|
+
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.
|
|
2166
2254
|
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.
|
|
2167
2255
|
|
|
2168
2256
|
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,7 +13,7 @@ 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");
|
|
@@ -33,26 +33,43 @@ function format(template, manifest) {
|
|
|
33
33
|
*
|
|
34
34
|
* Not a real distribution mechanism. It exists so the pipeline is testable end
|
|
35
35
|
* to end before any remote exists, and as a local-iteration escape hatch
|
|
36
|
-
* 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.
|
|
37
38
|
*/
|
|
38
39
|
function publishFile(archive, manifest, options, log) {
|
|
39
40
|
// Relative to the library root, not to wherever the CLI happened to be run.
|
|
40
41
|
const dir = path.resolve(options.baseDir || ".", options.directory || "publish");
|
|
41
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
|
+
}
|
|
42
53
|
// `clean: true` keeps only the version being published. Used where the
|
|
43
54
|
// directory is a build output rather than a record of releases: an older
|
|
44
55
|
// version is published again from the commit that declared it.
|
|
45
|
-
if (options.clean === true) {
|
|
46
|
-
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 };
|
|
47
66
|
}
|
|
48
67
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
49
|
-
|
|
50
|
-
const archiveDest = path.join(targetDir, path.basename(archive));
|
|
51
68
|
fs.copyFileSync(archive, archiveDest);
|
|
52
|
-
fs.writeFileSync(
|
|
69
|
+
fs.writeFileSync(manifestDest, JSON.stringify(manifest, null, 2) + "\n");
|
|
53
70
|
|
|
54
71
|
log(`-- Published ${manifest.target} ${manifest.version} to ${archiveDest}`);
|
|
55
|
-
return { location: archiveDest, files
|
|
72
|
+
return { location: archiveDest, files };
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
function pom(groupId, artifactId, version, packaging, description = `API description documents for ${artifactId}.`) {
|
|
@@ -192,7 +209,7 @@ function publishNpm(archive, manifest, options, log) {
|
|
|
192
209
|
log(`-- npm ${name}@${version} is already published, identical; nothing to do`);
|
|
193
210
|
return { location: name + "@" + version, distTag, name, alreadyPublished: true };
|
|
194
211
|
}
|
|
195
|
-
const refused = (why) => new
|
|
212
|
+
const refused = (why) => new ChannelConflict(`npm already has ${name}@${version}, and it ${why}. ` +
|
|
196
213
|
"A published version is never replaced; publish the change as a new version.");
|
|
197
214
|
const judged = judgeHeld(npmPublishedPackage(name, version, registry), "tgz", manifest);
|
|
198
215
|
if (judged.verdict === "unreadable") throw refused(`cannot be compared: ${judged.reason}`);
|
|
@@ -225,7 +242,7 @@ function npmPublishedDist(name, version, registry) {
|
|
|
225
242
|
} catch (error) {
|
|
226
243
|
const output = `${error.stdout || ""}${error.stderr || ""}`;
|
|
227
244
|
if (/E404/.test(output)) return null;
|
|
228
|
-
throw new
|
|
245
|
+
throw new ChannelUnavailable(`could not ask npm whether ${name}@${version} is published: ${output.trim() || error.message}`);
|
|
229
246
|
}
|
|
230
247
|
}
|
|
231
248
|
|
|
@@ -239,7 +256,7 @@ function npmPublishedPackage(name, version, registry) {
|
|
|
239
256
|
{ encoding: "utf8", stdio: "pipe" }));
|
|
240
257
|
} catch (error) {
|
|
241
258
|
const output = `${error.stdout || ""}${error.stderr || ""}`.trim();
|
|
242
|
-
throw new
|
|
259
|
+
throw new ChannelUnavailable(`could not read ${name}@${version} back from npm to compare: ${output || error.message}`);
|
|
243
260
|
}
|
|
244
261
|
return fs.readFileSync(path.join(dir, packed[0].filename));
|
|
245
262
|
} finally {
|
|
@@ -299,7 +316,7 @@ function publishGithubRelease(archive, manifest, options, log) {
|
|
|
299
316
|
} finally {
|
|
300
317
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
301
318
|
}
|
|
302
|
-
const refused = (why) => new
|
|
319
|
+
const refused = (why) => new ChannelConflict(`${repository} release ${tag} already has ${asset}, and it ${why}. ` +
|
|
303
320
|
"A published version is never replaced; publish the change as a new version.");
|
|
304
321
|
const judged = judgeHeld(held, "tgz", manifest);
|
|
305
322
|
if (judged.verdict === "unreadable") throw refused(`cannot be compared: ${judged.reason}`);
|
|
@@ -336,7 +353,7 @@ async function putFile(url, body, auth, contentType) {
|
|
|
336
353
|
body,
|
|
337
354
|
});
|
|
338
355
|
if (!response.ok) {
|
|
339
|
-
throw new
|
|
356
|
+
throw new ChannelUnavailable(
|
|
340
357
|
`PUT ${url} failed: ${response.status} ${response.statusText}. ` +
|
|
341
358
|
(response.status === 401 || response.status === 403
|
|
342
359
|
? "Check the token in the environment variable named by channels.maven.tokenEnv."
|
|
@@ -350,7 +367,7 @@ async function getFile(url, auth) {
|
|
|
350
367
|
const response = await fetch(url, { headers: auth });
|
|
351
368
|
if (response.status === 404) return null;
|
|
352
369
|
if (!response.ok) {
|
|
353
|
-
throw new
|
|
370
|
+
throw new ChannelUnavailable(`GET ${url} failed: ${response.status} ${response.statusText}. ` +
|
|
354
371
|
(response.status === 401 || response.status === 403
|
|
355
372
|
? "Check the token in the environment variable named by channels.maven.tokenEnv." : ""));
|
|
356
373
|
}
|
|
@@ -398,7 +415,7 @@ async function updateMetadata(url, groupId, artifactId, manifest, auth, log) {
|
|
|
398
415
|
try {
|
|
399
416
|
await putFile(url, Buffer.from(metadataXml(groupId, artifactId, listed, lastUpdated), "utf8"), auth, "application/xml");
|
|
400
417
|
} catch (error) {
|
|
401
|
-
throw new
|
|
418
|
+
throw new ChannelUnavailable(`the repository refused maven-metadata.xml for ${groupId}:${artifactId}: ${error.message.trim()} ` +
|
|
402
419
|
"The version's files are published; a re-run retries only the metadata. For a repository that generates " +
|
|
403
420
|
"its own maven-metadata.xml, set channels.maven.metadata: false.");
|
|
404
421
|
}
|
|
@@ -409,7 +426,7 @@ function publishMavenRemote(archive, manifest, options, log) {
|
|
|
409
426
|
const tokenEnv = options.tokenEnv || "MAVEN_TOKEN";
|
|
410
427
|
const token = process.env[tokenEnv];
|
|
411
428
|
if (!token) {
|
|
412
|
-
throw new
|
|
429
|
+
throw new ChannelUnavailable(
|
|
413
430
|
`no credential in $${tokenEnv}, which channels.maven.tokenEnv names. ` +
|
|
414
431
|
`Remote publication needs one; set it in the release job's environment.`
|
|
415
432
|
);
|
|
@@ -443,7 +460,7 @@ function publishMavenRemote(archive, manifest, options, log) {
|
|
|
443
460
|
{ url: `${base}-manifest.json`, type: "application/json", kind: "json",
|
|
444
461
|
bytes: Buffer.from(JSON.stringify(manifest, null, 2) + "\n", "utf8") },
|
|
445
462
|
];
|
|
446
|
-
const refused = (file, why) => new
|
|
463
|
+
const refused = (file, why) => new ChannelConflict(
|
|
447
464
|
`${options.repository} already has ${options.groupId}:${artifactId}:${version}, ` +
|
|
448
465
|
`and its ${file.url.split("/").pop()} ${why}. ` +
|
|
449
466
|
"A published version is never replaced; publish the change as a new version.");
|
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 { outcomeOf } = require("./channel-error");
|
|
21
|
+
const { guard, GuardError } = require("./version-guard");
|
|
20
22
|
const { refuseRemote } = require("./remote");
|
|
21
23
|
const { CHANNEL_TYPES } = require("./channel-types");
|
|
22
24
|
const { VersionError: PolicyError, describe } = require("./version-policy");
|
|
@@ -40,6 +42,7 @@ Usage:
|
|
|
40
42
|
api-only-publisher targets
|
|
41
43
|
api-only-publisher closure [--target <name>]...
|
|
42
44
|
api-only-publisher changed --since <ref>
|
|
45
|
+
api-only-publisher check [--target <name>]... [--pre-release <ids>] [--since <ref>]
|
|
43
46
|
api-only-publisher pack [--pre-release <ids>] [--target <name>]... [--out <dir>]
|
|
44
47
|
api-only-publisher publish [--pre-release <ids>] [--target <name>]... [--channel <name>]... [--out <dir>]
|
|
45
48
|
api-only-publisher split [--out <dir>] [--by kind|target]
|
|
@@ -63,7 +66,9 @@ Options:
|
|
|
63
66
|
init only: the value of that question; a flag always wins.
|
|
64
67
|
--portfolio-paths <target-prefix|none>, --portfolio-location <dir>
|
|
65
68
|
config only: the value of that question; a flag always wins.
|
|
66
|
-
--since <ref> changed
|
|
69
|
+
--since <ref> changed: the git ref to compare the working tree against.
|
|
70
|
+
check/publish: the ref to compare a target without a release
|
|
71
|
+
tag against, instead of the merge-base with the main branch.
|
|
67
72
|
--out <dir> pack/publish/split: where to write, instead of build.packages
|
|
68
73
|
or build.split in apionly.yaml.
|
|
69
74
|
--channel <name> publish only: repeat for several. Default: every configured channel.
|
|
@@ -76,6 +81,8 @@ Options:
|
|
|
76
81
|
What gets built, and where each document goes, is declared in apionly.yaml.
|
|
77
82
|
A published target's version is read from its version file:
|
|
78
83
|
<target>.bundle.properties beside its bundle root, or the file its versionFile names.
|
|
84
|
+
A target changed since its release, or since the main branch, needs a new version:
|
|
85
|
+
check says which do not have one, and publish refuses them.
|
|
79
86
|
`;
|
|
80
87
|
|
|
81
88
|
function parseArgs(argv) {
|
|
@@ -289,6 +296,20 @@ async function runConfig(options, positional, io, log) {
|
|
|
289
296
|
return 0;
|
|
290
297
|
}
|
|
291
298
|
|
|
299
|
+
/** Fails with every target the guard refused -- and, with `undecided`, every one it could not decide -- each with its reason. */
|
|
300
|
+
function refuseViolations(results, { undecided = false } = {}) {
|
|
301
|
+
const listed = (verdict) => results.filter((r) => r.verdict === verdict).map((r) => ` ${r.target} ${r.version}: ${r.reason}`);
|
|
302
|
+
const violations = listed("violation");
|
|
303
|
+
const unknown = undecided ? listed("undecided") : [];
|
|
304
|
+
const parts = [];
|
|
305
|
+
if (violations.length > 0) {
|
|
306
|
+
parts.push(`${violations.length} target(s) changed without a new version:\n${violations.join("\n")}\n` +
|
|
307
|
+
"A published version is never replaced: a changed specification needs a changed version.");
|
|
308
|
+
}
|
|
309
|
+
if (unknown.length > 0) parts.push(`${unknown.length} target(s) could not be checked:\n${unknown.join("\n")}`);
|
|
310
|
+
if (parts.length > 0) throw new GuardError(parts.join("\n\n"));
|
|
311
|
+
}
|
|
312
|
+
|
|
292
313
|
async function main(argv, io = {}) {
|
|
293
314
|
const { options, positional } = parseArgs(argv);
|
|
294
315
|
const command = positional[0];
|
|
@@ -425,6 +446,17 @@ async function main(argv, io = {}) {
|
|
|
425
446
|
log(`\n${changed.length} of ${results.length} target(s) changed since ${options.since}.`);
|
|
426
447
|
return 0;
|
|
427
448
|
}
|
|
449
|
+
case "check": {
|
|
450
|
+
for (const target of targets || []) {
|
|
451
|
+
if (!config.isPublished(target)) throw new ConfigError(`${target} is not published, so it has no version to check`);
|
|
452
|
+
}
|
|
453
|
+
const results = guard(config, shippedTargets(config, targets), { preRelease: options.preRelease, since: options.since });
|
|
454
|
+
for (const r of results) log(`${r.target.padEnd(18)} ${r.version.padEnd(14)} ${r.verdict === "ok" ? "ok" : r.verdict.toUpperCase()} ${r.reason}`);
|
|
455
|
+
// check is the gate: a target it cannot decide fails it too.
|
|
456
|
+
refuseViolations(results, { undecided: true });
|
|
457
|
+
log(`\n${results.length} target(s) checked.`);
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
428
460
|
case "pack": {
|
|
429
461
|
const outDir = options.out ? path.resolve(options.dir, options.out) : config.packagesDir();
|
|
430
462
|
const shipped = shippedTargets(config, targets);
|
|
@@ -483,9 +515,21 @@ async function main(argv, io = {}) {
|
|
|
483
515
|
// Every channel this run uses, before anything is read, packed or sent.
|
|
484
516
|
refuseRemote([...new Set(shipped.flatMap((target) => routes.get(target)))], configured);
|
|
485
517
|
const versions = versionsOf(config, shipped, options.preRelease);
|
|
518
|
+
// A changed target needs a new version: decided before anything is packed or sent.
|
|
519
|
+
try {
|
|
520
|
+
const checked = guard(config, shipped, { preRelease: options.preRelease, since: options.since });
|
|
521
|
+
refuseViolations(checked);
|
|
522
|
+
for (const r of checked.filter((c) => c.verdict === "undecided")) {
|
|
523
|
+
log(`warning: ${r.target} ${r.version}: version not checked: ${r.reason}`);
|
|
524
|
+
}
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (!(error instanceof GuardError && error.notARepository)) throw error;
|
|
527
|
+
log(`warning: versions not checked: ${error.message}`);
|
|
528
|
+
}
|
|
486
529
|
const closures = forTargets(config, undefined, shipped);
|
|
487
530
|
let published = 0;
|
|
488
531
|
const handoff = [];
|
|
532
|
+
const outcomes = [];
|
|
489
533
|
for (const target of Object.keys(config.targets)) {
|
|
490
534
|
if (!shipped.includes(target)) continue;
|
|
491
535
|
const closure = closures.get(target);
|
|
@@ -496,18 +540,36 @@ async function main(argv, io = {}) {
|
|
|
496
540
|
outDir, log,
|
|
497
541
|
});
|
|
498
542
|
for (const name of routes.get(target)) {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
543
|
+
const entry = { target, version: manifest.version, channel: name, type: config.channelType(name) };
|
|
544
|
+
let result;
|
|
545
|
+
try {
|
|
546
|
+
// A remote channel returns a promise; a local one does not.
|
|
547
|
+
result = await publish(archive, manifest, name, { ...(configured[name] || {}), baseDir: config.root }, log);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
// A channel is an external party: what it cannot take is reported, never fatal.
|
|
550
|
+
const outcome = outcomeOf(error);
|
|
551
|
+
if (!outcome) throw error;
|
|
552
|
+
outcomes.push({ ...entry, outcome, reason: error.message });
|
|
553
|
+
log(`warning: ${target} ${manifest.version} was not published to ${name}: ${error.message}`);
|
|
554
|
+
if (process.env.GITHUB_ACTIONS === "true") {
|
|
555
|
+
console.log(`::warning title=${target} ${manifest.version} not published to ${name}::${error.message.replace(/\r?\n/g, " ")}`);
|
|
556
|
+
}
|
|
557
|
+
continue;
|
|
503
558
|
}
|
|
559
|
+
outcomes.push({ ...entry, outcome: result.alreadyPublished ? "already-published" : "published" });
|
|
560
|
+
for (const file of result.files || []) handoff.push({ file, ...entry });
|
|
504
561
|
published += 1;
|
|
505
562
|
}
|
|
506
563
|
}
|
|
507
|
-
// What a later step publishes byte for byte; README.adoc#handoff.
|
|
508
|
-
const { sha256: listing } = writeHandoff(config.root, outDir, handoff);
|
|
564
|
+
// What a later step publishes byte for byte, and what each channel made of it; README.adoc#handoff.
|
|
565
|
+
const { sha256: listing } = writeHandoff(config.root, outDir, handoff, outcomes);
|
|
509
566
|
log(`\nPublished ${published} artifact(s).`);
|
|
510
567
|
log(`Listed ${handoff.length} file(s) in ${path.relative(config.root, listing)}.`);
|
|
568
|
+
const problems = outcomes.length - published;
|
|
569
|
+
if (problems > 0) {
|
|
570
|
+
log(`Channel problems: ${problems} of ${outcomes.length} publication(s) did not complete.` +
|
|
571
|
+
(published === 0 ? " Nothing was published." : ""));
|
|
572
|
+
}
|
|
511
573
|
return 0;
|
|
512
574
|
}
|
|
513
575
|
case "split": {
|
|
@@ -533,7 +595,7 @@ function report(error) {
|
|
|
533
595
|
error instanceof ClosureError || error instanceof PackError ||
|
|
534
596
|
error instanceof ChangedError || error instanceof SplitError ||
|
|
535
597
|
error instanceof ChannelError || error instanceof PolicyError ||
|
|
536
|
-
error instanceof BundleVersionError) {
|
|
598
|
+
error instanceof BundleVersionError || error instanceof GuardError) {
|
|
537
599
|
console.error(`Error: ${error.message}`);
|
|
538
600
|
process.exitCode = 1;
|
|
539
601
|
} else {
|
package/src/config.js
CHANGED
|
@@ -21,7 +21,7 @@ const SUPPORTED_SCHEMA_VERSION = 1;
|
|
|
21
21
|
// often a typo or a bad indent than a value nobody has used yet -- see checkKeys.
|
|
22
22
|
const ROOT_KEYS = [
|
|
23
23
|
"schemaVersion", "sources", "defaults", "build", "reports", "lint",
|
|
24
|
-
"distribution", "channels", "targets", "toolchain", "portfolio",
|
|
24
|
+
"distribution", "channels", "targets", "toolchain", "portfolio", "versioning",
|
|
25
25
|
];
|
|
26
26
|
const SOURCES_KEYS = ["root", "openapi", "asyncapi"];
|
|
27
27
|
const DEFAULTS_KEYS = ["openapi", "asyncapi", "placeholders"];
|
|
@@ -36,6 +36,7 @@ const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile", "channels"
|
|
|
36
36
|
const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
|
|
37
37
|
const PORTFOLIO_KEYS = ["openapi", "security", "location"];
|
|
38
38
|
const PORTFOLIO_OPENAPI_KEYS = ["paths", "operationIds", "tags"];
|
|
39
|
+
const VERSIONING_KEYS = ["tagFormat", "mainBranch"];
|
|
39
40
|
|
|
40
41
|
// What every portfolio setting is when apionly.yaml says nothing: safe for the
|
|
41
42
|
// ordinary case (a gateway routing /<target>/** to each service), and named rather
|
|
@@ -204,6 +205,16 @@ function load(configPath) {
|
|
|
204
205
|
);
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
const versioning = parsed.versioning || {};
|
|
209
|
+
checkKeys(versioning, VERSIONING_KEYS, "versioning");
|
|
210
|
+
if (versioning.tagFormat !== undefined) {
|
|
211
|
+
const format = requireString(versioning.tagFormat, "versioning.tagFormat");
|
|
212
|
+
if (!format.includes("{target}") || !format.includes("{version}")) {
|
|
213
|
+
throw new ConfigError(`versioning.tagFormat must contain {target} and {version}, not ${JSON.stringify(format)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (versioning.mainBranch !== undefined) requireString(versioning.mainBranch, "versioning.mainBranch");
|
|
217
|
+
|
|
207
218
|
const channels = parsed.channels || {};
|
|
208
219
|
const types = channelTypes(channels);
|
|
209
220
|
|
|
@@ -314,6 +325,7 @@ function load(configPath) {
|
|
|
314
325
|
distribution: parsed.distribution || null,
|
|
315
326
|
channels,
|
|
316
327
|
portfolio,
|
|
328
|
+
versioning,
|
|
317
329
|
targets,
|
|
318
330
|
|
|
319
331
|
// --- derived accessors, so callers never re-derive a path themselves ---
|
|
@@ -329,6 +341,15 @@ function load(configPath) {
|
|
|
329
341
|
stagingDir(kind) {
|
|
330
342
|
return path.join(this.stagingRoot(kind), requireString(this.sources[kind], `sources.${kind}`));
|
|
331
343
|
},
|
|
344
|
+
// How a target's releases are tagged in git -- by the pipeline that publishes them,
|
|
345
|
+
// never by the Publisher, which only reads the tags -- and the branch a change is
|
|
346
|
+
// compared with when a target has no release tag yet.
|
|
347
|
+
tagFormat() {
|
|
348
|
+
return this.versioning.tagFormat || "{target}-v{version}";
|
|
349
|
+
},
|
|
350
|
+
mainBranch() {
|
|
351
|
+
return this.versioning.mainBranch || "main";
|
|
352
|
+
},
|
|
332
353
|
// Where pack and publish write archives, and split its parts, unless --out says
|
|
333
354
|
// otherwise; relative to this configuration, as every other path here is.
|
|
334
355
|
packagesDir() {
|
package/src/gitlab.js
CHANGED
|
@@ -16,7 +16,7 @@ const path = require("path");
|
|
|
16
16
|
const { execFileSync } = require("child_process");
|
|
17
17
|
|
|
18
18
|
const { isPrerelease } = require("./version-policy");
|
|
19
|
-
const { ChannelError } = require("./channel-error");
|
|
19
|
+
const { ChannelError, ChannelConflict, ChannelUnavailable } = require("./channel-error");
|
|
20
20
|
const { judgeHeld } = require("./held-documents");
|
|
21
21
|
|
|
22
22
|
const GITLAB_COM = "https://gitlab.com/api/v4";
|
|
@@ -35,7 +35,7 @@ function channelSettings(options, env = process.env) {
|
|
|
35
35
|
tokenType = "job";
|
|
36
36
|
}
|
|
37
37
|
if (!token) {
|
|
38
|
-
throw new
|
|
38
|
+
throw new ChannelUnavailable(
|
|
39
39
|
`no token in $${tokenEnv}, which channels.gitlab-package.tokenEnv names, and no $CI_JOB_TOKEN. ` +
|
|
40
40
|
"Set one in the release job's environment, or run in a GitLab CI job.");
|
|
41
41
|
}
|
|
@@ -79,14 +79,14 @@ async function readBack(fileUrl, name, headers) {
|
|
|
79
79
|
const response = await fetch(fileUrl, { headers });
|
|
80
80
|
if (response.status === 404) return null;
|
|
81
81
|
if (!response.ok) {
|
|
82
|
-
throw new
|
|
82
|
+
throw new ChannelUnavailable(`could not read ${name} back from ${fileUrl}: ${await answered(response)}.` +
|
|
83
83
|
(response.status === 401 || response.status === 403 ? TOKEN_HINT : ""));
|
|
84
84
|
}
|
|
85
85
|
return Buffer.from(await response.arrayBuffer());
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function refused(name, manifest, why) {
|
|
89
|
-
return new
|
|
89
|
+
return new ChannelConflict(`GitLab already has ${name} in ${manifest.target} ${manifest.version}, and it ${why}; ` +
|
|
90
90
|
"a published version is never replaced, so publish the change as a new version");
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -111,7 +111,7 @@ async function upload(file, manifest, headers) {
|
|
|
111
111
|
// Refused, perhaps as a duplicate another run published meanwhile: compare that one.
|
|
112
112
|
const now = await readBack(file.url, file.name, headers).catch(() => null);
|
|
113
113
|
if (now) return { held: now, found: sameAs(now, file, manifest) };
|
|
114
|
-
throw new
|
|
114
|
+
throw new ChannelUnavailable(`GitLab refused ${file.name}: ${answer}.` +
|
|
115
115
|
(response.status === 401 || response.status === 403 ? TOKEN_HINT : ""));
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -137,18 +137,18 @@ async function release(settings, manifest, options, links, ref, log) {
|
|
|
137
137
|
description: format(options.notes || options.description || DEFAULT_DESCRIPTION, manifest),
|
|
138
138
|
assets: { links },
|
|
139
139
|
});
|
|
140
|
-
if (!created.ok) throw new
|
|
140
|
+
if (!created.ok) throw new ChannelUnavailable(`GitLab refused the release ${tag}: ${await answered(created)}`);
|
|
141
141
|
log(`-- Created the GitLab release ${tag}`);
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
144
|
if (!existing.ok) {
|
|
145
|
-
throw new
|
|
145
|
+
throw new ChannelUnavailable(`could not read the GitLab release ${tag}: ${await answered(existing)}`);
|
|
146
146
|
}
|
|
147
147
|
// A release's link names are unique: one already named, or already pointing here, is left as it is.
|
|
148
148
|
const held = ((await existing.json()).assets || {}).links || [];
|
|
149
149
|
for (const link of links.filter((l) => !held.some((h) => h.url === l.url || h.name === l.name))) {
|
|
150
150
|
const added = await api(`${releases}/${encodeURIComponent(tag)}/assets/links`, settings.headers, "POST", link);
|
|
151
|
-
if (!added.ok) throw new
|
|
151
|
+
if (!added.ok) throw new ChannelUnavailable(`GitLab refused a link on the release ${tag}: ${await answered(added)}`);
|
|
152
152
|
log(`-- Linked ${link.name} from the GitLab release ${tag}`);
|
|
153
153
|
}
|
|
154
154
|
}
|
package/src/handoff.js
CHANGED
|
@@ -21,9 +21,11 @@ const HANDOFF_SCHEMA_VERSION = 1;
|
|
|
21
21
|
/**
|
|
22
22
|
* Writes packages.json and packages.sha256 into `outDir`, replacing any from an
|
|
23
23
|
* earlier run, listing `entries` -- `{ file, target, version, channel }`, `file`
|
|
24
|
-
* absolute -- sorted by path.
|
|
24
|
+
* absolute -- sorted by path. packages.json also lists `outcomes`, what each channel
|
|
25
|
+
* made of each target: `published`, `already-published`, or a channel problem --
|
|
26
|
+
* `conflict` or `unavailable`, with its reason -- that did not fail the run.
|
|
25
27
|
*/
|
|
26
|
-
function writeHandoff(root, outDir, entries) {
|
|
28
|
+
function writeHandoff(root, outDir, entries, outcomes = []) {
|
|
27
29
|
const files = entries
|
|
28
30
|
.map(({ file, target, version, channel, type = channel }) => ({
|
|
29
31
|
path: path.relative(root, file).split(path.sep).join("/"),
|
|
@@ -35,7 +37,7 @@ function writeHandoff(root, outDir, entries) {
|
|
|
35
37
|
fs.mkdirSync(outDir, { recursive: true });
|
|
36
38
|
const json = path.join(outDir, HANDOFF_JSON);
|
|
37
39
|
const sha256 = path.join(outDir, HANDOFF_SHA256);
|
|
38
|
-
fs.writeFileSync(json, JSON.stringify({ schemaVersion: HANDOFF_SCHEMA_VERSION, files }, null, 2) + "\n");
|
|
40
|
+
fs.writeFileSync(json, JSON.stringify({ schemaVersion: HANDOFF_SCHEMA_VERSION, files, outcomes }, null, 2) + "\n");
|
|
39
41
|
fs.writeFileSync(sha256, files.map((f) => `${f.sha256} ${f.path}\n`).join(""));
|
|
40
42
|
return { json, sha256 };
|
|
41
43
|
}
|
package/src/nuget.js
CHANGED
|
@@ -24,7 +24,7 @@ const { execFileSync } = require("child_process");
|
|
|
24
24
|
|
|
25
25
|
const { parse, isPrerelease } = require("./version-policy");
|
|
26
26
|
const { zip } = require("./zip");
|
|
27
|
-
const { ChannelError } = require("./channel-error");
|
|
27
|
+
const { ChannelError, ChannelConflict, ChannelUnavailable } = require("./channel-error");
|
|
28
28
|
const { judgeHeld } = require("./held-documents");
|
|
29
29
|
|
|
30
30
|
/** Where the documents and manifest.json sit inside the package. */
|
|
@@ -174,13 +174,13 @@ function publishLocal(archive, manifest, options, id, log) {
|
|
|
174
174
|
async function feedResources(index) {
|
|
175
175
|
const response = await fetch(index);
|
|
176
176
|
if (!response.ok) {
|
|
177
|
-
throw new
|
|
177
|
+
throw new ChannelUnavailable(`could not read the NuGet service index at ${index}: ${response.status} ${response.statusText}`);
|
|
178
178
|
}
|
|
179
179
|
const resources = (await response.json()).resources || [];
|
|
180
180
|
const find = (type) => (resources.find((r) => r["@type"] === type) || {})["@id"] || null;
|
|
181
181
|
const publish = find(PUBLISH_RESOURCE);
|
|
182
182
|
if (!publish) {
|
|
183
|
-
throw new
|
|
183
|
+
throw new ChannelUnavailable(`the NuGet feed at ${index} has no ${PUBLISH_RESOURCE} resource, so it cannot be pushed to`);
|
|
184
184
|
}
|
|
185
185
|
return { publish, content: find(CONTENT_RESOURCE) };
|
|
186
186
|
}
|
|
@@ -207,7 +207,7 @@ async function readableCopy(content, id, version, key) {
|
|
|
207
207
|
*/
|
|
208
208
|
function judge(held, bytes, id, manifest) {
|
|
209
209
|
if (held.equals(bytes)) return "identical";
|
|
210
|
-
const refused = (why) => new
|
|
210
|
+
const refused = (why) => new ChannelConflict(`the NuGet feed already has ${id} ${manifest.version}, and it ${why}; ` +
|
|
211
211
|
"a published version is never replaced, so publish the change as a new version");
|
|
212
212
|
const judged = judgeHeld(held, "zip", manifest);
|
|
213
213
|
if (judged.verdict === "unreadable") throw refused(`cannot be compared: ${judged.reason}`);
|
|
@@ -225,7 +225,7 @@ async function publishedCopy(content, id, version, key) {
|
|
|
225
225
|
const response = await fetch(url, { headers: { "X-NuGet-ApiKey": key } });
|
|
226
226
|
if (response.status === 404) return null;
|
|
227
227
|
if (!response.ok) {
|
|
228
|
-
throw new
|
|
228
|
+
throw new ChannelUnavailable(`could not read ${id} ${version} back from ${url}: ${response.status} ${response.statusText}`.trim() +
|
|
229
229
|
(response.status === 401 || response.status === 403
|
|
230
230
|
? ". Check the key in the environment variable named by channels.nuget.tokenEnv." : ""));
|
|
231
231
|
}
|
|
@@ -243,7 +243,7 @@ function publishRemote(archive, manifest, options, id, log) {
|
|
|
243
243
|
const tokenEnv = options.tokenEnv || "NUGET_API_KEY";
|
|
244
244
|
const key = process.env[tokenEnv];
|
|
245
245
|
if (!key) {
|
|
246
|
-
throw new
|
|
246
|
+
throw new ChannelUnavailable(
|
|
247
247
|
`no API key in $${tokenEnv}, which channels.nuget.tokenEnv names. ` +
|
|
248
248
|
"Remote publication needs one; set it in the release job's environment.");
|
|
249
249
|
}
|
|
@@ -290,11 +290,11 @@ function publishRemote(archive, manifest, options, id, log) {
|
|
|
290
290
|
// Idempotent: the same documents again are success, others are not.
|
|
291
291
|
const held = await readableCopy(content, id, manifest.version, key);
|
|
292
292
|
if (held) return already(judge(held, bytes, id, manifest));
|
|
293
|
-
throw new
|
|
293
|
+
throw new ChannelConflict(`the NuGet feed already has ${id} ${manifest.version}, and it cannot be read back to compare; ` +
|
|
294
294
|
"a published version is never replaced, so publish the change as a new version");
|
|
295
295
|
}
|
|
296
296
|
if (!response.ok) {
|
|
297
|
-
throw new
|
|
297
|
+
throw new ChannelUnavailable(`the NuGet feed refused the package: ${response.status} ${response.statusText}. ` +
|
|
298
298
|
(response.status === 401 || response.status === 403
|
|
299
299
|
? "Check the key in the environment variable named by channels.nuget.tokenEnv." : ""));
|
|
300
300
|
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// A changed specification comes with a changed version.
|
|
4
|
+
//
|
|
5
|
+
// A subscriber locks a version's documents by their hashes, so a version published again
|
|
6
|
+
// with other documents breaks every build that already locked it. The registries refuse
|
|
7
|
+
// such a version, but only at publish time, only on remote channels, and only when they
|
|
8
|
+
// already hold it; this guard decides it from git alone, before anything is packed, so a
|
|
9
|
+
// pull request can fail on it.
|
|
10
|
+
//
|
|
11
|
+
// What a version was released from is known from the target's release tags, which the
|
|
12
|
+
// pipeline creates after it publishes (`versioning.tagFormat`); the Publisher only reads
|
|
13
|
+
// them. A target with no release tag yet is compared with the merge-base with the main
|
|
14
|
+
// branch, or with the ref `--since` names.
|
|
15
|
+
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const { execFileSync } = require("child_process");
|
|
19
|
+
|
|
20
|
+
const { prepare } = require("./pipeline");
|
|
21
|
+
const { changedSince } = require("./changed");
|
|
22
|
+
const { versionOf, parseProperties } = require("./bundle-version");
|
|
23
|
+
const { compare, isPrerelease, SEMVER } = require("./version-policy");
|
|
24
|
+
|
|
25
|
+
class GuardError extends Error {}
|
|
26
|
+
|
|
27
|
+
function git(args, cwd) {
|
|
28
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tryGit(args, cwd) {
|
|
32
|
+
try {
|
|
33
|
+
return git(args, cwd);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function escape(text) {
|
|
40
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The target's release tags, by version: every tag `tagFormat` gives for it with a semver version. */
|
|
44
|
+
function releaseTags(config, target, repoRoot) {
|
|
45
|
+
const [before, after] = config.tagFormat().replace(/\{target\}/g, target).split("{version}");
|
|
46
|
+
const pattern = new RegExp(`^${escape(before)}(.+)${escape(after)}$`);
|
|
47
|
+
const tags = new Map();
|
|
48
|
+
for (const tag of git(["tag", "--list"], repoRoot).split("\n")) {
|
|
49
|
+
const match = pattern.exec(tag);
|
|
50
|
+
if (match && SEMVER.test(match[1])) tags.set(match[1], tag);
|
|
51
|
+
}
|
|
52
|
+
return tags;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The ref a target without release tags is compared with, and how to name it; or, when
|
|
57
|
+
* there is no merge-base -- a shallow checkout, or no main branch -- why not.
|
|
58
|
+
*/
|
|
59
|
+
function fallbackBaseline(config, repoRoot, since) {
|
|
60
|
+
if (since) {
|
|
61
|
+
const commit = tryGit(["rev-parse", "--verify", "--quiet", `${since}^{commit}`], repoRoot);
|
|
62
|
+
if (!commit) throw new GuardError(`--since ${since} is not a commit in this repository`);
|
|
63
|
+
return { ref: commit, label: `${since} (${commit.slice(0, 12)})` };
|
|
64
|
+
}
|
|
65
|
+
const branch = config.mainBranch();
|
|
66
|
+
for (const candidate of [`origin/${branch}`, branch]) {
|
|
67
|
+
if (!tryGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot)) continue;
|
|
68
|
+
const base = tryGit(["merge-base", "HEAD", candidate], repoRoot);
|
|
69
|
+
if (base) return { ref: base, label: `the merge-base with ${branch} (${base.slice(0, 12)})` };
|
|
70
|
+
}
|
|
71
|
+
return { missing: `no merge-base with ${branch}, the branch versioning.mainBranch names, to compare a target without a release tag with. ` +
|
|
72
|
+
"In CI, fetch the full history and the branch (actions/checkout with fetch-depth: 0, or GIT_DEPTH: 0 on GitLab), " +
|
|
73
|
+
"or name the ref to compare with: --since <ref>." };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The version a target's version file -- `rel`, from the repository root -- declared at `ref`, or null when it was not there. */
|
|
77
|
+
function versionAt(ref, rel, repoRoot) {
|
|
78
|
+
const text = tryGit(["show", `${ref}:${rel}`], repoRoot);
|
|
79
|
+
return text === null ? null : parseProperties(text).version || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether each target's version may be published: a version released before, from other
|
|
84
|
+
* specifications, may not; nor one lower than what came before it.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} config a loaded configuration
|
|
87
|
+
* @param {string[]} targets the targets to judge; each needs a version file
|
|
88
|
+
* @param {{preRelease?: string|null, since?: string|null}} options
|
|
89
|
+
* A target the tags do not decide, with no merge-base to compare with, is `undecided`.
|
|
90
|
+
*
|
|
91
|
+
* @returns {Array<{target: string, version: string, verdict: "ok"|"violation"|"undecided", reason: string}>}
|
|
92
|
+
*/
|
|
93
|
+
function guard(config, targets, { preRelease = null, since = null } = {}) {
|
|
94
|
+
const top = tryGit(["rev-parse", "--show-toplevel"], config.root);
|
|
95
|
+
if (top === null) {
|
|
96
|
+
const error = new GuardError(`${config.root} is not in a git repository, so whether a changed target has a ` +
|
|
97
|
+
"changed version cannot be decided: the guard reads release tags and history from git");
|
|
98
|
+
error.notARepository = true;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
// Real paths on both sides: git answers with the canonical one, and a checkout reached
|
|
102
|
+
// through a symlink -- /var on macOS -- would otherwise never match.
|
|
103
|
+
const repoRoot = fs.realpathSync(top);
|
|
104
|
+
prepare(config);
|
|
105
|
+
|
|
106
|
+
const changes = new Map();
|
|
107
|
+
const changedSinceRef = (ref, target) => {
|
|
108
|
+
if (!changes.has(ref)) {
|
|
109
|
+
changes.set(ref, new Map(changedSince(config, ref).map((r) => [r.target, r.changed])));
|
|
110
|
+
}
|
|
111
|
+
return changes.get(ref).get(target) === true;
|
|
112
|
+
};
|
|
113
|
+
let fallback = null;
|
|
114
|
+
|
|
115
|
+
return targets.map((target) => {
|
|
116
|
+
const declared = versionOf(config, target);
|
|
117
|
+
const version = versionOf(config, target, { preRelease });
|
|
118
|
+
const file = path.relative(repoRoot, fs.realpathSync(config.versionFile(target))).split(path.sep).join("/");
|
|
119
|
+
const ok = (reason) => ({ target, version, verdict: "ok", reason });
|
|
120
|
+
const violation = (reason) => ({ target, version, verdict: "violation", reason });
|
|
121
|
+
const tags = releaseTags(config, target, repoRoot);
|
|
122
|
+
|
|
123
|
+
// Released before: exact, whatever else is true.
|
|
124
|
+
if (tags.has(version)) {
|
|
125
|
+
const tag = tags.get(version);
|
|
126
|
+
return changedSinceRef(tag, target)
|
|
127
|
+
? violation(`${version} was released as ${tag}, and ${target} has changed since: raise the version in ${file}`)
|
|
128
|
+
: ok(`unchanged since its release tag ${tag}`);
|
|
129
|
+
}
|
|
130
|
+
if (preRelease && tags.has(declared)) {
|
|
131
|
+
return violation(`${version} is a pre-release of ${declared}, which is already released as ${tags.get(declared)}: ` +
|
|
132
|
+
`it would sort before the release; declare the next version in ${file}`);
|
|
133
|
+
}
|
|
134
|
+
const releases = [...tags.keys()].filter((v) => !isPrerelease(v)).sort(compare);
|
|
135
|
+
if (releases.length > 0) {
|
|
136
|
+
const latest = releases[releases.length - 1];
|
|
137
|
+
return compare(declared, latest) < 0
|
|
138
|
+
? violation(`${declared} is lower than its latest release, ${latest} (${tags.get(latest)}): raise the version in ${file}`)
|
|
139
|
+
: ok(`${declared} is above its latest release, ${latest}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Never released, as far as the tags tell: the merge-base, or --since, decides.
|
|
143
|
+
fallback = fallback || fallbackBaseline(config, repoRoot, since);
|
|
144
|
+
if (fallback.missing) return { target, version, verdict: "undecided", reason: fallback.missing };
|
|
145
|
+
const before = versionAt(fallback.ref, file, repoRoot);
|
|
146
|
+
if (before === null) return ok(`new: no version file at ${fallback.label}`);
|
|
147
|
+
if (compare(declared, before) < 0) {
|
|
148
|
+
return violation(`${declared} is lower than ${before} at ${fallback.label}: raise the version in ${file}`);
|
|
149
|
+
}
|
|
150
|
+
if (!changedSinceRef(fallback.ref, target)) return ok(`unchanged since ${fallback.label}`);
|
|
151
|
+
if (compare(declared, before) > 0) return ok(`${before} -> ${declared} since ${fallback.label}`);
|
|
152
|
+
if (preRelease) return ok(`pre-release ${version}: its identifiers make it a new version`);
|
|
153
|
+
return violation(`${target} changed since ${fallback.label}, but its version is still ${declared}: ` +
|
|
154
|
+
`raise it in ${file}`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { guard, releaseTags, GuardError };
|