@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 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.16.1
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
- |Pack each target once, then ship those same bytes to each of its channels, or only to those of them each `--channel` names (<<channel-routing,Which channels a target goes to §>>).
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
- Publishing a version again writes it again and keeps the others (<<idempotent,Publishing the same version again §>>); with `clean: true` only the version being published is kept, for a directory that is a build output rather than a record of releases.
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 content changes nothing, on every channel.
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
- That rests on `pack` being reproducible.
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
- |`file`; `maven` and `nuget` to a directory; `npm` with `publish: false`
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 bytes: nothing is sent, and the log says `already published, identical`. Different bytes: `publish` fails, naming the version -- a published version is never replaced, so publish the change as a new version.
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
- `github-release` compares the asset it would upload with the one the release has, and never overwrites one.
722
- `npm` compares the integrity hash the registry records, or the tarball's SHA-1 where it records only that, as GitLab's does; its files are packed with fixed modes, so the package is the same bytes whatever the umask of the machine that publishes it.
723
- A NuGet feed that cannot return the package it has, so there is nothing to compare, fails as a different one would.
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 nothing.
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 package succeeds without a push, a different one fails, and a copy that cannot be read, other than one the feed does not have, fails rather than being pushed over (<<idempotent,Publishing the same version again §>>).
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` fails before anything is sent, naming both.
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: an identical file is left alone, a missing one is uploaded, and a different one fails `publish` (<<idempotent,Publishing the same version again §>>).
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: identical is left alone, missing is uploaded, different fails `publish`, naming the file.
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 fails `publish`, naming `metadata: false`; the version's files are published by then, and a re-run retries only the metadata.
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 bytes, is left alone (<<idempotent,Publishing the same version again §>>).
1313
- What fails is a changed contract whose version was not changed, which is the mistake `changed` exists to catch first.
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 is identical, and refuses it when it differs; `npm publish` or `dotnet nuget push` of an existing version fails instead.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
5
5
  "license": "MIT",
6
6
  "author": "Arc-E-Tect",
@@ -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 fs.readFileSync(file, "utf8").split(/\r?\n/)) {
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 };
@@ -1,7 +1,33 @@
1
1
  "use strict";
2
2
 
3
- // The error every distribution channel throws, in a module of its own so that a channel
4
- // implemented outside channels.js can throw it without requiring channels.js back.
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
- module.exports = { ChannelError };
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.rmSync(path.join(dir, manifest.target), { recursive: true, force: true });
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(path.join(targetDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
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: [archiveDest, path.join(targetDir, "manifest.json")] };
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 is the
179
- // same package, and refused when it is not -- npm never replaces a version.
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 (!held) {
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
- throw new ChannelError(
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 ChannelError(`could not ask npm whether ${name}@${version} is published: ${output.trim() || error.message}`);
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 is the same bytes, and refused when it is not. Never --clobber: that would
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
- if (fs.readFileSync(path.join(dir, asset)).equals(fs.readFileSync(archive))) {
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
- throw new ChannelError(
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 ChannelError(
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 ChannelError(`GET ${url} failed: ${response.status} ${response.statusText}. ` +
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 ChannelError(`the repository refused maven-metadata.xml for ${groupId}:${artifactId}: ${error.message.trim()} ` +
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 ChannelError(
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, file by file: each file the repository already has is left alone when
412
- // it is the same bytes, and refuses the whole version when it is not. Every file is
413
- // read back before any is uploaded, so a run interrupted after the artifact is
414
- // completed by the next one, and a version that differs anywhere is not touched.
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
- } else if (!existing.equals(file.bytes)) {
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}, identical; nothing to do`
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}`,