@arc-e-tect/api-only-publisher 0.15.0 → 0.16.1

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.15.0
9
+ :api-only-publisher-version: 0.16.1
10
10
 
11
11
  [NOTE]
12
12
  .About this documentation
@@ -182,7 +182,7 @@ At a terminal it asks the same shape of question `init` would, offering what is
182
182
  Without `--target`, it also fails on a fragment no target reaches, as <<unreferenced,A fragment no target reaches fails the lint §>> describes.
183
183
 
184
184
  |`targets`
185
- |List the declared targets, what each one builds, and whether it is published.
185
+ |List the declared targets, what each one builds, whether it is published, and to which channels.
186
186
 
187
187
  |`closure [--target <t>]...`
188
188
  |Show each target's dependency closure: how many files it reaches, and the hash of their content.
@@ -195,7 +195,7 @@ A target whose closure is untouched needs no new version, however much else in t
195
195
  |Archive each built, published target with a `manifest.json`, at the version its version file declares.
196
196
 
197
197
  |`publish [--pre-release <ids>] [--target <t>]... [--channel <c>]... [--out <dir>]`
198
- |Pack once, then ship those same bytes to every configured channel, or to each `--channel`.
198
+ |Pack each target once, then ship those same bytes to each of its channels, or only to those of them each `--channel` names (<<channel-routing,Which channels a target goes to §>>).
199
199
 
200
200
  |`split [--out <dir>] [--by kind\|target]`
201
201
  |Make the library safe to break into separate repositories.
@@ -660,6 +660,43 @@ In a GitLab CI job it needs no settings at all. See <<gitlab-package,The `gitlab
660
660
  Native for .NET consumers: a `PackageReference` at a version, restored from a feed. See <<nuget,The `nuget` channel §>>.
661
661
  |===
662
662
 
663
+ [#channel-routing]
664
+ === Which channels a target goes to
665
+
666
+ A channel has a *name*, the key under `channels`, and a *type*, one of those above.
667
+ A channel named after a type is that type; any other name -- lowercase words joined by single hyphens, such as `partner-a` -- says its type with `type`.
668
+ So one `apionly.yaml` can hold several channels of one type, each with its own destination, settings and token:
669
+
670
+ [source,yaml]
671
+ ----
672
+ channels:
673
+ maven: # a Maven channel, named after its type
674
+ groupId: com.example.api
675
+ repository: https://maven.example.com/internal
676
+ partner-a: # another one, for one partner
677
+ type: maven
678
+ groupId: com.example.partner
679
+ repository: https://maven.partner-a.example
680
+ tokenEnv: PARTNER_A_TOKEN
681
+
682
+ targets:
683
+ orders:
684
+ channels: [maven, partner-a] # published to these two
685
+ openapi:
686
+ bundle: bundles/orders_openapi_structure.yaml
687
+ billing: # no list: every configured channel
688
+ openapi:
689
+ bundle: bundles/billing_openapi_structure.yaml
690
+ ----
691
+
692
+ * A target with a `channels` list is published to those channels; a target without one, to every configured channel.
693
+ * Everything is checked when `apionly.yaml` is read: a channel name that is not kebab-case, a name that is not a type without a `type`, a `type` that is not one, a type name given another `type`, and a target that lists a channel `apionly.yaml` does not configure, lists one twice, lists none (`channels: []`, where `publish: false` is meant), or is never published -- `publish: false`, or an aggregate.
694
+ * `--channel` narrows each target to the named channels it is routed to.
695
+ A target that is not routed to one is skipped, and `publish` says so: `payments: not published to npm (not in targets.payments.channels)`.
696
+ A run that would publish nothing at all fails instead of succeeding quietly.
697
+ * A `--channel` that `apionly.yaml` does not configure is refused, unless it is a type's name: `--channel github-release` is that channel with no settings, as it has always been.
698
+ * `targets` lists each published target with its channels.
699
+
663
700
  [#idempotent]
664
701
  === Publishing the same version again
665
702
 
@@ -712,7 +749,7 @@ Error: API_ONLY_PUBLISHER_REMOTE=never refuses the remote channels maven, github
712
749
  ----
713
750
 
714
751
  Any other value fails `publish`, whatever its channels: a typo in a switch that keeps the Publisher sealed is not ignored.
715
- `--channel` narrows what is checked: `publish --channel file` succeeds under `never`, whatever else `apionly.yaml` configures.
752
+ Only the channels a run would publish to are checked, by type: a channel named `partner-a` with `type: maven` and a URL is remote; `publish --channel file`, or a target routed only to local channels, succeeds under `never`, whatever else `apionly.yaml` configures.
716
753
  The Publisher used as a library keeps the same rule.
717
754
 
718
755
  The switch is a promise the Publisher keeps, not an enforcement: whoever runs it can change the variable.
@@ -728,7 +765,7 @@ Every `publish` run lists each file its channels wrote locally, with its SHA-256
728
765
  | File | Content
729
766
 
730
767
  | `packages.json`
731
- | `schemaVersion`, and `files`: for each file, its `path`, `sha256`, `target`, `version` and `channel`.
768
+ | `schemaVersion`, and `files`: for each file, its `path`, `sha256`, `target`, `version`, `channel` -- the channel's name -- and `type`, the channel's type.
732
769
 
733
770
  | `packages.sha256`
734
771
  | The same files in the format `sha256sum -c` checks.
@@ -990,6 +1027,7 @@ channels:
990
1027
 
991
1028
  targets:
992
1029
  orders:
1030
+ # channels: [file] # the channels it is published to; default: every one
993
1031
  # versionFile: versions/orders.properties
994
1032
  # default: orders.bundle.properties beside the first bundle root
995
1033
  openapi:
@@ -1161,20 +1199,30 @@ They are starting points, not boundaries: the situations describe where each sha
1161
1199
  [#recipes-two-facts]
1162
1200
  === Two facts every recipe rests on
1163
1201
 
1164
- *A channel is named after its type.*
1165
- `channels.maven` is the Maven channel, `channels.npm` the npm channel, and so on, so one `apionly.yaml` has at most one channel of each type, with one destination each.
1166
- Two consumers who both read Maven get the same Maven repository; <<recipe-business-partners,Business partners §>> shows what to do when they each need their own.
1202
+ *A channel has a name and a type.*
1203
+ A channel named after a type is that type -- `channels.maven` is a Maven channel -- and any other name says its type with `type`, so one `apionly.yaml` can hold two channels of one type, each with a destination of its own:
1204
+
1205
+ [source,yaml]
1206
+ ----
1207
+ channels:
1208
+ partner-a: { type: maven, groupId: com.example.partner, repository: https://maven.partner-a.example }
1209
+ partner-b: { type: maven, groupId: com.example.partner, repository: https://maven.partner-b.example }
1210
+ ----
1167
1211
 
1168
- *`apionly.yaml` says which channels exist, not which target goes where.*
1169
- Without `--channel`, `publish` ships every published target to every configured channel, packing each target once and sending the same bytes everywhere.
1170
- `--target` and `--channel` narrow a run, and both may be repeated:
1212
+ *`apionly.yaml` says which target goes where.*
1213
+ A target with a `channels` list is published to those channels; a target without one is published to every configured channel.
1214
+ `publish` packs each target once, and sends the same bytes to each of its channels:
1171
1215
 
1172
- [source,console]
1216
+ [source,yaml]
1173
1217
  ----
1174
- npm run apionly -- publish --target payments --channel nuget
1218
+ targets:
1219
+ payments:
1220
+ channels: [partner-a]
1221
+ openapi:
1222
+ bundle: bundles/payments_openapi_structure.yaml
1175
1223
  ----
1176
1224
 
1177
- So when different targets go to different channels, the routing lives in the commands that run `publish`, as <<recipe-ecosystem,Eco-system §>> shows, and not in `apionly.yaml`.
1225
+ `--target` and `--channel` narrow a run, and both may be repeated; `--channel` publishes each target only to the named channels it is routed to, and says which targets it skipped (<<channel-routing,Which channels a target goes to §>>).
1178
1226
 
1179
1227
  The excerpts below show only `channels` and `targets`; the rest of `apionly.yaml` is as <<configuration,Configuration §>> describes, and as `init` writes it.
1180
1228
  Every published target also needs its version file, as <<versions,Versions §>> describes, and a `build` before its `publish`.
@@ -1311,8 +1359,8 @@ The other channels already have the version, and would leave it alone anyway.
1311
1359
  * A pre-release, `publish --pre-release rc.1`, is how a partner gets an unfinished contract to start building against, without any partner's production build picking it up by accident (<<pre-releases,Pre-releases §>>).
1312
1360
  * Each channel takes its credential from the environment, never from `apionly.yaml`: `MAVEN_TOKEN`, `NUGET_API_KEY` and `GITLAB_TOKEN` by default, npm's own configuration for `npm`, and the GitHub CLI's for `github-release` (<<channel-text,What a package says about itself §>>).
1313
1361
  * A partner without a package manager gets a download: `github-release` where the library is on GitHub, `gitlab-package` where it is on GitLab.
1314
- * Partners that each need a repository of *the same type* -- two Maven repositories, one per partner -- cannot be two `maven` channels.
1315
- Publish to a local destination instead, `repository` or `directory` set to a directory, and let the pipeline copy the files it lists in `packages.sha256` to each partner's repository, as <<handoff,The hand-off §>> describes.
1362
+ * Partners that each need a repository of *the same type* -- two Maven repositories, one per partner -- are two channels with names of their own and `type: maven`, as <<recipes-two-facts,the two facts §>> show.
1363
+ * A partner that may see only some contracts gets only those: list its channel on those targets (<<recipe-ecosystem,Eco-system §>>).
1316
1364
 
1317
1365
  [#recipe-platform]
1318
1366
  === Platform: several targets, several channels, every target on every channel
@@ -1391,7 +1439,7 @@ Start from a routing table: which target goes to which channel.
1391
1439
  |`payments` |Yes | |Yes
1392
1440
  |===
1393
1441
 
1394
- `apionly.yaml` declares the channels and the targets, as for a <<recipe-platform,platform §>>:
1442
+ The routing table goes into `apionly.yaml` as it stands: each target lists the channels it is published to.
1395
1443
 
1396
1444
  .apionly.yaml (excerpt)
1397
1445
  [source,yaml]
@@ -1409,52 +1457,39 @@ channels:
1409
1457
 
1410
1458
  targets:
1411
1459
  catalog:
1460
+ channels: [maven, npm]
1412
1461
  openapi:
1413
1462
  bundle: bundles/catalog_openapi_structure.yaml
1414
1463
  customers:
1464
+ channels: [maven]
1415
1465
  openapi:
1416
1466
  bundle: bundles/customers_openapi_structure.yaml
1417
1467
  orders:
1468
+ channels: [maven, npm]
1418
1469
  openapi:
1419
1470
  bundle: bundles/orders_openapi_structure.yaml
1420
1471
  payments:
1472
+ channels: [maven, nuget]
1421
1473
  openapi:
1422
1474
  bundle: bundles/payments_openapi_structure.yaml
1423
1475
  ----
1424
1476
 
1425
- The routing table becomes one `publish` per channel, each naming its targets.
1426
- Keeping those commands as scripts in the library's `package.json` keeps the routing in one place, reviewed with the library, rather than spread across pipeline files:
1427
-
1428
- .package.json (excerpt)
1429
- [source,json]
1430
- ----
1431
- {
1432
- "scripts": {
1433
- "apionly": "api-only-publisher",
1434
- "publish:maven": "api-only-publisher publish --channel maven",
1435
- "publish:npm": "api-only-publisher publish --channel npm --target catalog --target orders",
1436
- "publish:nuget": "api-only-publisher publish --channel nuget --target payments",
1437
- "publish:all": "npm run publish:maven && npm run publish:npm && npm run publish:nuget"
1438
- }
1439
- }
1440
- ----
1441
-
1442
1477
  [source,console]
1443
1478
  ----
1444
1479
  npm run apionly -- build
1445
- npm run publish:all
1480
+ npm run apionly -- publish
1446
1481
  ----
1447
1482
 
1448
- `publish:maven` names no target, because every target goes there; a channel that takes only some names each of them.
1483
+ One `publish` sends each target to its own channels, packing it once.
1484
+ `npm run apionly -- targets` lists each target with its channels, so the routing can be read back.
1449
1485
 
1450
1486
  *Worth knowing.*
1451
1487
 
1452
- * A new target goes only where a script sends it.
1453
- `publish:maven` picks it up because it names no target; the npm and NuGet consumers do not see it until it is added to their script, which is the deliberate step an eco-system wants.
1454
- * Each script passes its arguments on: `npm run publish:npm -- --pre-release rc.1` publishes a pre-release of the npm targets alone.
1455
- * Each run packs again, and the archives are the same bytes every time (<<idempotent,Publishing the same version again §>>), so a target on two channels is identical on both.
1456
- * Each run replaces the hand-off files in the packages directory (<<handoff,The hand-off §>>).
1457
- A pipeline that publishes the files itself gives each run its own `--out`, such as `--out build/packages/npm`, and keeps every channel's list.
1488
+ * A target without a `channels` list goes to every configured channel.
1489
+ In an eco-system, give every target a list: a new target then goes nowhere until someone decides where, which is the deliberate step an eco-system wants.
1490
+ * `--channel` publishes to one channel alone, say after its registry was unreachable: `npm run apionly -- publish --channel npm` sends `catalog` and `orders`, says that it skipped `customers` and `payments`, and fails if it would publish nothing at all.
1491
+ * The routing is checked when `apionly.yaml` is read: a target that lists a channel `apionly.yaml` does not configure is refused, naming both, before anything is built.
1492
+ * A third party that needs a registry of a type another party already uses -- a second Maven repository -- gets a channel of its own, with `type: maven` (<<recipes-two-facts,the two facts §>>).
1458
1493
 
1459
1494
  [#behaviour]
1460
1495
  == Behaviour worth knowing about
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Builds, packs and publishes API description documents from a library of reusable fragments.",
5
5
  "license": "MIT",
6
6
  "author": "Arc-E-Tect",
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+
3
+ // The channel types, in a module of their own so that the configuration can check a
4
+ // channel's type without requiring the channels themselves.
5
+
6
+ const CHANNEL_TYPES = Object.freeze(["file", "maven", "npm", "github-release", "nuget", "gitlab-package"]);
7
+
8
+ /** The types as a sentence names them: "file, maven, ... or gitlab-package". */
9
+ function describeTypes() {
10
+ return `${CHANNEL_TYPES.slice(0, -1).join(", ")} or ${CHANNEL_TYPES[CHANNEL_TYPES.length - 1]}`;
11
+ }
12
+
13
+ module.exports = { CHANNEL_TYPES, describeTypes };
package/src/channels.js CHANGED
@@ -456,8 +456,13 @@ const CHANNELS = {
456
456
  "gitlab-package": publishGitlabPackage,
457
457
  };
458
458
 
459
+ /**
460
+ * Publishes to the channel named `channel`, of the type its settings' `type` names, or
461
+ * of the type it is named after -- `maven` is a Maven channel, as it has always been.
462
+ */
459
463
  function publish(archive, manifest, channel, options, log = () => {}) {
460
- const handler = CHANNELS[channel];
464
+ const { type = channel, ...settings } = options || {};
465
+ const handler = CHANNELS[type];
461
466
  if (!handler) {
462
467
  throw new ChannelError(
463
468
  `unknown channel '${channel}'; available channels are ${Object.keys(CHANNELS).join(", ")}`
@@ -465,7 +470,7 @@ function publish(archive, manifest, channel, options, log = () => {}) {
465
470
  }
466
471
  // Here as well as in the CLI, so the Publisher used as a library keeps it too.
467
472
  refuseRemote([channel], { [channel]: options || {} });
468
- return handler(archive, manifest, options || {}, log);
473
+ return handler(archive, manifest, settings, log);
469
474
  }
470
475
 
471
476
  module.exports = { publish, CHANNELS, ChannelError, pom, format, DEFAULT_DESCRIPTION };
package/src/cli.js CHANGED
@@ -18,6 +18,7 @@ const { changedSince, ChangedError } = require("./changed");
18
18
  const { split, SplitError } = require("./split");
19
19
  const { publish, ChannelError } = require("./channels");
20
20
  const { refuseRemote } = require("./remote");
21
+ const { CHANNEL_TYPES } = require("./channel-types");
21
22
  const { VersionError: PolicyError, describe } = require("./version-policy");
22
23
  const { versionOf, BundleVersionError } = require("./bundle-version");
23
24
  const { unreferenced } = require("./unreferenced");
@@ -324,7 +325,9 @@ async function main(argv, io = {}) {
324
325
  for (const name of Object.keys(config.targets)) {
325
326
  const kinds = ["openapi", "asyncapi"].filter((k) => config.targets[name][k]);
326
327
  const published = config.isPublished(name) ? "" : " (publish: false)";
327
- console.log(`${name} [${kinds.join(", ")}]${published}`);
328
+ const routed = config.isPublished(name) && Object.keys(config.channels || {}).length > 0
329
+ ? ` channels: ${config.channelsOf(name).join(", ")}` : "";
330
+ console.log(`${name} [${kinds.join(", ")}]${published}${routed}`);
328
331
  }
329
332
  return 0;
330
333
  }
@@ -448,20 +451,43 @@ async function main(argv, io = {}) {
448
451
  case "publish": {
449
452
  const outDir = options.out ? path.resolve(options.dir, options.out) : config.packagesDir();
450
453
  const configured = config.channels || {};
451
- const names = options.channels || Object.keys(configured);
452
- if (names.length === 0) {
454
+ for (const name of options.channels || []) {
455
+ // An unconfigured channel named after a type is that channel with no settings.
456
+ if (!configured[name] && !(name in configured) && !CHANNEL_TYPES.includes(name)) {
457
+ const names = Object.keys(configured);
458
+ throw new ConfigError(`--channel '${name}' is not a configured channel; ` +
459
+ `apionly.yaml configures ${names.length ? names.join(", ") : "no channels"}`);
460
+ }
461
+ }
462
+ if (!options.channels && Object.keys(configured).length === 0) {
453
463
  throw new ConfigError("no channels configured; add a `channels:` block or pass --channel");
454
464
  }
465
+ // Each target's channels: its own list, or every configured one, narrowed by --channel.
466
+ const shippable = shippedTargets(config, targets);
467
+ const routes = new Map(shippable.map((target) => {
468
+ const own = config.targets[target].channels;
469
+ return [target, options.channels
470
+ ? options.channels.filter((name) => !own || own.includes(name))
471
+ : config.channelsOf(target)];
472
+ }));
473
+ for (const target of shippable) {
474
+ const own = config.targets[target].channels;
475
+ for (const name of (own && options.channels) || []) {
476
+ if (!own.includes(name)) log(`${target}: not published to ${name} (not in targets.${target}.channels)`);
477
+ }
478
+ }
479
+ const shipped = shippable.filter((target) => routes.get(target).length > 0);
480
+ if (options.channels && shippable.length > 0 && shipped.length === 0) {
481
+ throw new ConfigError(`nothing was published: no target in this run is routed to ${options.channels.join(", ")}`);
482
+ }
455
483
  // Every channel this run uses, before anything is read, packed or sent.
456
- refuseRemote(names, configured);
457
- const shipped = shippedTargets(config, targets);
484
+ refuseRemote([...new Set(shipped.flatMap((target) => routes.get(target)))], configured);
458
485
  const versions = versionsOf(config, shipped, options.preRelease);
459
486
  const closures = forTargets(config, undefined, shipped);
460
487
  let published = 0;
461
488
  const handoff = [];
462
489
  for (const target of Object.keys(config.targets)) {
463
- if (targets && !targets.includes(target)) continue;
464
- if (!config.isPublished(target)) continue;
490
+ if (!shipped.includes(target)) continue;
465
491
  const closure = closures.get(target);
466
492
  // Packed once, then shipped unchanged to every channel.
467
493
  const { archive, manifest } = pack(config, target, {
@@ -469,11 +495,11 @@ async function main(argv, io = {}) {
469
495
  closureSha256: closure ? closure.sha256 : null,
470
496
  outDir, log,
471
497
  });
472
- for (const name of names) {
498
+ for (const name of routes.get(target)) {
473
499
  // A remote channel returns a promise; a local one does not.
474
500
  const result = await publish(archive, manifest, name, { ...(configured[name] || {}), baseDir: config.root }, log);
475
501
  for (const file of result.files || []) {
476
- handoff.push({ file, target, version: manifest.version, channel: name });
502
+ handoff.push({ file, target, version: manifest.version, channel: name, type: config.channelType(name) });
477
503
  }
478
504
  published += 1;
479
505
  }
package/src/config.js CHANGED
@@ -9,6 +9,8 @@ const fs = require("fs");
9
9
  const path = require("path");
10
10
  const YAML = require("yaml");
11
11
 
12
+ const { CHANNEL_TYPES, describeTypes } = require("./channel-types");
13
+
12
14
  const CONFIG_NAME = "apionly.yaml";
13
15
  const SUPPORTED_SCHEMA_VERSION = 1;
14
16
 
@@ -30,7 +32,7 @@ const REPORTS_KEYS = ["lint"];
30
32
  const LINT_KEYS = ["unreferenced", "examples"];
31
33
  const LINT_EXAMPLES_KEYS = ["openapi", "asyncapi"];
32
34
  const DISTRIBUTION_KEYS = ["root", "layout"];
33
- const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile"];
35
+ const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile", "channels"];
34
36
  const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
35
37
  const PORTFOLIO_KEYS = ["openapi", "security", "location"];
36
38
  const PORTFOLIO_OPENAPI_KEYS = ["paths", "operationIds", "tags"];
@@ -49,6 +51,73 @@ const PATH_STRATEGIES = ["target-prefix", "none"];
49
51
 
50
52
  class ConfigError extends Error {}
51
53
 
54
+ // A channel's name, as init asks of a target's: lowercase words joined by single hyphens.
55
+ const CHANNEL_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
56
+
57
+ /**
58
+ * Each configured channel's type, by name. A channel named after a type is that type;
59
+ * any other name says its type with `type`, which is how one apionly.yaml has two
60
+ * channels of one type -- a Maven repository per partner.
61
+ */
62
+ function channelTypes(channels) {
63
+ if (typeof channels !== "object" || Array.isArray(channels)) {
64
+ throw new ConfigError("channels must be a mapping of channel names to their settings");
65
+ }
66
+ const types = {};
67
+ for (const [name, settings] of Object.entries(channels)) {
68
+ if (!CHANNEL_NAME.test(name)) {
69
+ throw new ConfigError(`channel name '${name}': use lowercase letters and digits, in words joined by single ` +
70
+ "hyphens, starting with a letter, such as partner-a");
71
+ }
72
+ if (settings !== null && (typeof settings !== "object" || Array.isArray(settings))) {
73
+ throw new ConfigError(`channels.${name} must be a mapping of settings`);
74
+ }
75
+ const declared = settings ? settings.type : undefined;
76
+ if (CHANNEL_TYPES.includes(name)) {
77
+ if (declared !== undefined && declared !== name) {
78
+ throw new ConfigError(`channel '${name}' is named after a channel type, so its type is ${name}, ` +
79
+ `not ${declared}; give it another name`);
80
+ }
81
+ types[name] = name;
82
+ } else if (declared === undefined) {
83
+ throw new ConfigError(`channel '${name}' is not a channel type; say which one it is with type: ${describeTypes()}`);
84
+ } else if (!CHANNEL_TYPES.includes(declared)) {
85
+ throw new ConfigError(`channels.${name}.type '${declared}' is not a channel type: ${describeTypes()}`);
86
+ } else {
87
+ types[name] = declared;
88
+ }
89
+ }
90
+ return types;
91
+ }
92
+
93
+ /** Refuses a target's `channels` that cannot be what it means. */
94
+ function checkTargetChannels(name, target, channels, isAggregate) {
95
+ const list = target.channels;
96
+ if (list === undefined) return;
97
+ if (!Array.isArray(list) || list.some((c) => typeof c !== "string")) {
98
+ throw new ConfigError(`targets.${name}.channels must be a list of channel names`);
99
+ }
100
+ if (list.length === 0) {
101
+ throw new ConfigError(`targets.${name}.channels is empty; a target that is published nowhere says so with publish: false`);
102
+ }
103
+ if (isAggregate) {
104
+ throw new ConfigError(`target '${name}' is an aggregate, which is never published, so it has no channels`);
105
+ }
106
+ if (target.publish === false) {
107
+ throw new ConfigError(`target '${name}' is not published (publish: false), so it has no channels`);
108
+ }
109
+ const seen = new Set();
110
+ for (const channel of list) {
111
+ if (seen.has(channel)) throw new ConfigError(`target '${name}' lists channel '${channel}' twice`);
112
+ seen.add(channel);
113
+ if (!Object.prototype.hasOwnProperty.call(channels, channel)) {
114
+ const configured = Object.keys(channels);
115
+ throw new ConfigError(`target '${name}' lists channel '${channel}', which apionly.yaml does not configure; ` +
116
+ `it configures ${configured.length ? configured.join(", ") : "no channels"}`);
117
+ }
118
+ }
119
+ }
120
+
52
121
  /** Refuses a key `obj` has that is not in `allowed`, naming the key and where it is. */
53
122
  function checkKeys(obj, allowed, where) {
54
123
  if (!obj || typeof obj !== "object") return;
@@ -135,6 +204,9 @@ function load(configPath) {
135
204
  );
136
205
  }
137
206
 
207
+ const channels = parsed.channels || {};
208
+ const types = channelTypes(channels);
209
+
138
210
  const targets = parsed.targets;
139
211
  if (!targets || typeof targets !== "object" || Object.keys(targets).length === 0) {
140
212
  throw new ConfigError(`${configPath} declares no targets`);
@@ -176,6 +248,8 @@ function load(configPath) {
176
248
  );
177
249
  }
178
250
 
251
+ checkTargetChannels(name, target, channels, aggregateKinds.length > 0);
252
+
179
253
  for (const kind of kinds) {
180
254
  checkKeys(target[kind], TARGET_KIND_KEYS, `targets.${name}.${kind}`);
181
255
  if (target[kind].aggregate !== undefined) {
@@ -238,7 +312,7 @@ function load(configPath) {
238
312
  reports,
239
313
  lint,
240
314
  distribution: parsed.distribution || null,
241
- channels: parsed.channels || {},
315
+ channels,
242
316
  portfolio,
243
317
  targets,
244
318
 
@@ -326,6 +400,15 @@ function load(configPath) {
326
400
  // An aggregate is never published either, whether or not it says so --
327
401
  // load() already refuses the one case that would contradict this, `publish:
328
402
  // true` alongside an aggregate, so nothing here needs to re-check that.
403
+ // A configured channel's type; a name that is not configured is taken for a type,
404
+ // as `publish --channel github-release` has always meant that channel unconfigured.
405
+ channelType(name) {
406
+ return types[name] || name;
407
+ },
408
+ // The channels a target is published to: its own list, or every configured one.
409
+ channelsOf(target) {
410
+ return this.targets[target].channels || Object.keys(this.channels);
411
+ },
329
412
  isPublished(target) {
330
413
  return this.targets[target].publish !== false && !this.isAggregate(target);
331
414
  },
package/src/handoff.js CHANGED
@@ -25,10 +25,10 @@ const HANDOFF_SCHEMA_VERSION = 1;
25
25
  */
26
26
  function writeHandoff(root, outDir, entries) {
27
27
  const files = entries
28
- .map(({ file, target, version, channel }) => ({
28
+ .map(({ file, target, version, channel, type = channel }) => ({
29
29
  path: path.relative(root, file).split(path.sep).join("/"),
30
30
  sha256: crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"),
31
- target, version, channel,
31
+ target, version, channel, type,
32
32
  }))
33
33
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
34
34
 
package/src/remote.js CHANGED
@@ -50,7 +50,11 @@ function isRemote(channel, settings = {}) {
50
50
  */
51
51
  function refuseRemote(names, configured = {}, env = process.env) {
52
52
  if (remoteMode(env) === "allow") return;
53
- const remote = names.filter((name) => isRemote(name, configured[name] || {}));
53
+ // Judged by type: a channel named partner-a may be a Maven repository at a URL.
54
+ const remote = names.filter((name) => {
55
+ const settings = configured[name] || {};
56
+ return isRemote(settings.type || name, settings);
57
+ });
54
58
  if (remote.length === 0) return;
55
59
  throw new ChannelError(
56
60
  `${REMOTE_ENV}=never refuses the remote channels ${remote.join(", ")}, so nothing was built, packed or sent. ` +