@arc-e-tect/api-only-publisher 0.0.0 → 0.1.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
@@ -11,6 +11,16 @@ image:https://img.shields.io/github/v/release/Arc-E-Tect/SoftwareEngineeringDone
11
11
  image:https://img.shields.io/badge/Node-22%2B-339933?logo=node.js[Node 22+,link=https://nodejs.org]
12
12
  image:https://img.shields.io/badge/License-MIT-blue[MIT,link=LICENSE]
13
13
 
14
+ Links say where they go: § jumps to a section of this page, → opens another file in this repository, and ↗ leaves it.
15
+
16
+ // Remove this notice when 1.0.0 is released.
17
+ [IMPORTANT]
18
+ ====
19
+ Until 1.0.0 is released, any release may change the command line, `apionly.yaml` or the published artifacts incompatibly, without a major version bump.
20
+ There are no users to break yet, and the interface is still being settled; 1.0.0 is the first release whose interface is kept stable.
21
+ Pin an exact version, and read the changelog before upgrading.
22
+ ====
23
+
14
24
  == What it is
15
25
 
16
26
  `api-only-publisher` turns a library of small, reusable API description
@@ -53,23 +63,65 @@ pins, so they need no installation, and the only runtime dependency is `yaml`.
53
63
 
54
64
  == Installation
55
65
 
56
- As a command, without installing anything:
66
+ The Publisher behaves the same however it is started.
67
+ What differs is where its version is pinned, and what checks that pin.
68
+
69
+ === As a development dependency
70
+
71
+ The better fit for a specification library, and for any build that runs the Publisher more than once.
72
+
73
+ [source,console]
74
+ ----
75
+ npm install --save-dev --save-exact @arc-e-tect/api-only-publisher
76
+ ----
77
+
78
+ .package.json
79
+ [source,json]
80
+ ----
81
+ {
82
+ "private": true,
83
+ "scripts": {
84
+ "apionly": "api-only-publisher"
85
+ },
86
+ "devDependencies": {
87
+ "@arc-e-tect/api-only-publisher": "<version>"
88
+ }
89
+ }
90
+ ----
57
91
 
58
92
  [source,console]
59
93
  ----
60
- npx @arc-e-tect/api-only-publisher build
94
+ npm ci
95
+ npm run apionly -- build
96
+ npm run apionly -- publish --target user-account
61
97
  ----
62
98
 
63
- As a dependency of a specification library:
99
+ * The version is pinned in one place, `package.json`, and every command and every CI job runs that one.
100
+ * `package-lock.json` records the integrity hash of what was installed, so `npm ci` installs exactly those bytes or fails.
101
+ * A CI job can cache the installation, keyed on `package-lock.json`, instead of resolving the tool on every run.
102
+ * Renovate and Dependabot see the dependency, so an upgrade arrives as a pull request that moves the pin.
103
+ * The cost is a `package.json` and a `package-lock.json` in the library, and an `npm ci` before the first command.
104
+
105
+ === With `npx`, installing nothing
106
+
107
+ The better fit for trying the tool, for scaffolding a library with `init`, and for a one-off command.
64
108
 
65
109
  [source,console]
66
110
  ----
67
- npm install --save-dev @arc-e-tect/api-only-publisher
111
+ npx @arc-e-tect/api-only-publisher@<version> init my-api-library
112
+ npx @arc-e-tect/api-only-publisher@<version> build -C my-api-library
68
113
  ----
69
114
 
70
- Pin it exactly rather than with a range. This tool's output is expected to be
71
- reproducible a specification library's golden fixtures assert it byte for byte
72
- and a floating version quietly breaks that guarantee.
115
+ * Nothing is added to the repository.
116
+ * The version is pinned in every command instead, so each script and CI job that runs the Publisher has to name the same version, and nothing checks that they do.
117
+ * Without a version, `npx` runs whatever it resolves at that moment, which is not reproducible.
118
+ * Nothing records an integrity hash, and nothing notices when a newer version is released.
119
+
120
+ Either way, pin an exact version rather than a range.
121
+ This tool's output is expected to be reproducible — a specification library's golden fixtures assert it byte for byte — and a floating version quietly breaks that guarantee.
122
+
123
+ The bundlers are a separate matter.
124
+ However the Publisher itself is started, it fetches them with `npx`, at the versions `toolchain` in `apionly.yaml` pins.
73
125
 
74
126
  == Commands
75
127
 
@@ -82,7 +134,7 @@ reproducible — a specification library's golden fixtures assert it byte for by
82
134
  stands. This is how the *conventions* become reusable, as opposed to the code.
83
135
 
84
136
  |`build`
85
- |Stage, substitute placeholders, bundle, stamp the version, lint, and distribute.
137
+ |Stage, substitute placeholders, bundle, stamp each published target's version, lint, and distribute.
86
138
 
87
139
  |`lint`
88
140
  |Lint what is already built, without rebuilding, for fast local feedback.
@@ -99,10 +151,10 @@ of their content.
99
151
  drives a release: a target whose closure is untouched is not released, however
100
152
  much else in the repository moved.
101
153
 
102
- |`pack --version <v>`
103
- |Archive each built target with a `manifest.json`.
154
+ |`pack`
155
+ |Archive each built, published target with a `manifest.json`, at the version its version file declares.
104
156
 
105
- |`publish --version <v>`
157
+ |`publish`
106
158
  |Pack once, then ship those same bytes to every configured channel.
107
159
 
108
160
  |`split --out <dir>`
@@ -112,13 +164,15 @@ much else in the repository moved.
112
164
  [source,console]
113
165
  ----
114
166
  api-only-publisher init my-api-library
115
- api-only-publisher build -C my-api-library --version 2.3.1
167
+ api-only-publisher build -C my-api-library
116
168
  api-only-publisher build --target user-account --openapi
169
+ api-only-publisher publish --target user-account --pre-release rc.1
117
170
  api-only-publisher targets
118
171
  ----
119
172
 
120
173
  `--target` may be repeated. `-C <dir>` runs as if started in `<dir>`; without it
121
174
  the configuration is found by walking up from the current directory.
175
+ `--pre-release <ids>` appends pre-release identifiers to each target's version, as <<versions,Versions §>> describes.
122
176
 
123
177
  == What `build` actually does
124
178
 
@@ -139,13 +193,48 @@ indented YAML scalar.
139
193
  . **Bundle.** `@redocly/cli bundle` or `@asyncapi/cli bundle` resolves every
140
194
  `$ref` into one flat, self-contained document under `dist/<target>/`.
141
195
 
142
- . **Stamp the version**, if `--version` was given, on the *finished* document.
196
+ . **Stamp the version** of each published target on its *finished* document: the version its version file declares, with any `--pre-release` identifiers appended.
197
+ A `publish: false` target keeps the version its source declares.
143
198
 
144
199
  . **Lint** the finished document, so a broken fragment surfaces against the
145
200
  target it actually affects rather than at deploy time.
146
201
 
147
202
  . **Distribute**, unless the target is `publish: false`.
148
203
 
204
+ [[versions]]
205
+ == Versions
206
+
207
+ Every published target has a version of its own, and it is kept in the library, beside the fragments it describes.
208
+
209
+ .specs/openapi/bundles/user-account.bundle.properties
210
+ [source,properties]
211
+ ----
212
+ # The version of the user-account contract, for every document it builds.
213
+ # Semantic: major for a breaking change, minor for an additive one, patch for
214
+ # anything else. Change it in the same commit as the fragments it describes.
215
+ version=2.1.0
216
+ ----
217
+
218
+ By convention the file is `<target>.bundle.properties`, beside the target's first bundle root: its OpenAPI one, or its AsyncAPI one when it has no OpenAPI document.
219
+ A target that keeps its version elsewhere names the file with `versionFile`, relative to `apionly.yaml`.
220
+ The syntax is that of a Java properties file, so a JVM build can read the same file.
221
+
222
+ `build`, `pack` and `publish` read the version of every target they act on before they act on any of them.
223
+ A missing file, a file without a `version`, or a version that is not semantic stops the command before anything has been built or shipped.
224
+ A `publish: false` target is never shipped, so it needs no version file.
225
+
226
+ The version is not a command-line argument, and that is deliberate.
227
+ A version on the command line lets two builds of the same commit publish different versions, and lets through a version nobody reviewed.
228
+ A version in the library changes in the same commit as the fragments it describes, is reviewed with them, and is the same whoever builds that commit.
229
+
230
+ The file holds a release version.
231
+ A pre-release is that version with identifiers appended when it is built: `publish --pre-release rc.1` publishes `2.1.0-rc.1`.
232
+ Cutting one never means editing the file, and then remembering to edit it back, which is why a version file that declares a pre-release is refused.
233
+
234
+ A version names one set of documents.
235
+ 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.
236
+ Change the version in the same commit as the fragments instead.
237
+
149
238
  == Independent versioning, and the dependency closure
150
239
 
151
240
  Giving each target its own version has one non-obvious consequence, and the
@@ -245,9 +334,9 @@ rather than a silent overwrite.
245
334
  |Channel |What it is for
246
335
 
247
336
  |`file`
248
- |A local directory. Not a distribution mechanism -- it exists so the pipeline is
249
- testable end to end with no infrastructure, and as a local-iteration escape
250
- hatch afterwards.
337
+ |A local directory, laid out as `<directory>/<target>/<version>/`.
338
+ Not a distribution mechanism between repositories: it exists so the pipeline is testable end to end with no infrastructure, so a library can share one build with the projects that implement it, and as a local-iteration escape hatch.
339
+ Publishing a version again replaces that version and keeps the others; with `clean: true` only the version being published is kept, for a directory that is a build output rather than a record of releases.
251
340
 
252
341
  |`maven`
253
342
  |A path publishes into a repository layout on disk; an `http(s)` URL deploys to a
@@ -290,6 +379,7 @@ guarantees.
290
379
 
291
380
  So a pre-release version is first-class: `2.1.0-rc.1`, or Maven's `-SNAPSHOT`
292
381
  spelling, which is not semver-legal but is what Maven consumers expect.
382
+ It is cut from the version in the target's version file: `publish --pre-release rc.1` publishes `2.1.0-rc.1` when the file declares `2.1.0`.
293
383
 
294
384
  The hard rule is the other half of it. A pre-release must never quietly satisfy a
295
385
  production build:
@@ -328,13 +418,22 @@ defaults:
328
418
  build:
329
419
  staging: build/staging
330
420
  dist: dist
421
+ reports:
422
+ lint: build/reports/lint
331
423
 
332
424
  toolchain:
333
425
  redocly: "@redocly/cli@2.52.0"
334
426
  asyncapi: "@asyncapi/cli@6.0.2"
335
427
 
428
+ channels:
429
+ file:
430
+ directory: build/publish
431
+ clean: true # keep only the version being published
432
+
336
433
  targets:
337
434
  user-account:
435
+ # versionFile: versions/user-account.properties
436
+ # default: user-account.bundle.properties beside the first bundle root
338
437
  openapi:
339
438
  bundle: bundles/user-account_openapi_structure.yaml
340
439
  asyncapi:
@@ -411,11 +510,17 @@ layout. In order of what usually needs changing:
411
510
  |The lint rules
412
511
  |`defaults.openapi.lint`
413
512
 
513
+ |Where lint reports go
514
+ |`reports.lint`, defaulting to `build/reports/lint/<target>/<kind>.txt`
515
+
414
516
  |Where built documents go
415
517
  |`build.dist`, and `distribution` while it still exists
416
518
 
417
519
  |Where artifacts are published
418
520
  |`channels`
521
+
522
+ |Where a target's version is kept
523
+ |Its version file, or `targets.<target>.versionFile`
419
524
  |===
420
525
 
421
526
  Beyond that: the `common/` versus `<product>/<target>/` split is one convention
@@ -435,6 +540,8 @@ commit altered, and every manifest records the closure hash of what was
435
540
  published, so the hooks a diff gate would need already exist -- but the gate
436
541
  itself is deliberately not built.
437
542
 
543
+ A breaking change is declared where it is made: the major version in the target's version file changes in the same commit as the fragments it breaks.
544
+
438
545
  A specification library should write down its own policy. The one this repository
439
546
  uses is at `usable-suspects/docs/breaking-changes.adoc`, and is a reasonable
440
547
  starting point to copy.
@@ -480,7 +587,7 @@ npm test
480
587
  ----
481
588
 
482
589
  Covers placeholder substitution (including each of the three defects fixed on
483
- the way in), version stamping, configuration parsing, and the scaffold.
590
+ the way in), version stamping, version files, configuration parsing, every command, and the scaffold.
484
591
 
485
592
  The end-to-end guarantee lives elsewhere, in the specification library's own
486
593
  golden fixtures: they assert that the documents this tool produces are unchanged,
@@ -493,6 +600,18 @@ fetches and verifies. They share one seam — an archive plus a `manifest.json`
493
600
  and nothing else, which is why they can be versioned independently while living
494
601
  in one repository.
495
602
 
603
+ This document is a reference for this half alone. For how the two are put
604
+ together in a real project, see link:../docs/README.adoc[the manuals], which are
605
+ organised by where the API descriptions live relative to the code that implements
606
+ them: link:../docs/in-the-implementation-project.adoc[in the implementation
607
+ project], link:../docs/elsewhere-in-the-repository.adoc[elsewhere in the same
608
+ repository], link:../docs/in-its-own-repository.adoc[in a repository of their
609
+ own]. The first uses only `build` and reads the result directly; the second
610
+ publishes to the `file` channel, and the third to a registry. If you are
611
+ replacing a script that copies a document into `src/main/resources`, start at
612
+ link:../docs/migrating-a-checked-in-document.adoc[Migrating a checked-in
613
+ document].
614
+
496
615
  == License
497
616
 
498
617
  MIT. See link:LICENSE[LICENSE].
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arc-e-tect/api-only-publisher",
3
- "version": "0.0.0",
3
+ "version": "0.1.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",
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ // A target's version.
4
+ //
5
+ // It is read from a version file kept with the target's specification, rather
6
+ // than passed in by whatever runs the build. A version given on the command line
7
+ // lets two builds of one commit publish different versions, and lets through a
8
+ // version nobody reviewed. A version in the library changes in the same commit as
9
+ // the fragments it describes, and is reviewed with them.
10
+ //
11
+ // The file holds a release version. A pre-release is that version with
12
+ // identifiers appended when it is built, so cutting one never means editing the
13
+ // file -- and then remembering to edit it back.
14
+
15
+ const fs = require("fs");
16
+
17
+ const { parse, isPrerelease } = require("./version-policy");
18
+
19
+ class BundleVersionError extends Error {}
20
+
21
+ // Java-properties style -- `key=value` or `key: value`, with `#` and `!` comments
22
+ // -- so a JVM build can read the same file with java.util.Properties. Only what a
23
+ // version file needs: no escapes, no continuation lines.
24
+ function readProperties(file) {
25
+ const properties = {};
26
+ for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
27
+ const line = raw.trim();
28
+ if (line === "" || line.startsWith("#") || line.startsWith("!")) continue;
29
+ const separator = line.search(/[=:]/);
30
+ if (separator < 0) continue;
31
+ properties[line.slice(0, separator).trim()] = line.slice(separator + 1).trim();
32
+ }
33
+ return properties;
34
+ }
35
+
36
+ /**
37
+ * The version `target` is built, packed and published as.
38
+ *
39
+ * @param {object} config a loaded configuration
40
+ * @param {string} target the target whose version to read
41
+ * @param {{preRelease?: string|null}} options pre-release identifiers to append, such as `rc.1`
42
+ * @returns {string}
43
+ */
44
+ function versionOf(config, target, { preRelease = null } = {}) {
45
+ const file = config.versionFile(target);
46
+ if (!fs.existsSync(file)) {
47
+ throw new BundleVersionError(
48
+ `target '${target}': no version file at ${file}. Create it with a line such as 'version=1.0.0', ` +
49
+ `or point targets.${target}.versionFile at the file that holds its version.`
50
+ );
51
+ }
52
+ const declared = readProperties(file).version;
53
+ if (!declared) {
54
+ throw new BundleVersionError(`target '${target}': ${file} declares no version`);
55
+ }
56
+ try {
57
+ parse(declared);
58
+ } catch (error) {
59
+ throw new BundleVersionError(`target '${target}': ${file}: ${error.message}`);
60
+ }
61
+ if (isPrerelease(declared)) {
62
+ throw new BundleVersionError(
63
+ `target '${target}': ${file} declares '${declared}', but a version file holds a release version. ` +
64
+ `Declare the release it leads to, and cut the pre-release with --pre-release <identifiers>.`
65
+ );
66
+ }
67
+ if (!preRelease) return declared;
68
+
69
+ const version = `${declared}-${preRelease}`;
70
+ try {
71
+ parse(version);
72
+ } catch (error) {
73
+ throw new BundleVersionError(
74
+ `--pre-release '${preRelease}' does not make a semantic version of ${declared}: ${error.message}`
75
+ );
76
+ }
77
+ return version;
78
+ }
79
+
80
+ module.exports = { versionOf, readProperties, BundleVersionError };
package/src/channels.js CHANGED
@@ -27,6 +27,12 @@ function publishFile(archive, manifest, options, log) {
27
27
  // Relative to the library root, not to wherever the CLI happened to be run.
28
28
  const dir = path.resolve(options.baseDir || ".", options.directory || "publish");
29
29
  const targetDir = path.join(dir, manifest.target, manifest.version);
30
+ // `clean: true` keeps only the version being published. Used where the
31
+ // directory is a build output rather than a record of releases: an older
32
+ // version is published again from the commit that declared it.
33
+ if (options.clean === true) {
34
+ fs.rmSync(path.join(dir, manifest.target), { recursive: true, force: true });
35
+ }
30
36
  fs.mkdirSync(targetDir, { recursive: true });
31
37
 
32
38
  const archiveDest = path.join(targetDir, path.basename(archive));
package/src/cli.js CHANGED
@@ -14,23 +14,26 @@ const { changedSince, ChangedError } = require("./changed");
14
14
  const { split, SplitError } = require("./split");
15
15
  const { publish, ChannelError } = require("./channels");
16
16
  const { VersionError: PolicyError, describe } = require("./version-policy");
17
+ const { versionOf, BundleVersionError } = require("./bundle-version");
17
18
 
18
19
  const USAGE = `api-only-publisher -- build and distribute API description documents
19
20
 
20
21
  Usage:
21
22
  api-only-publisher init [dir] [--force]
22
- api-only-publisher build [--target <name>]... [--version <v>] [--openapi|--asyncapi]
23
+ api-only-publisher build [--target <name>]... [--pre-release <ids>] [--openapi|--asyncapi]
23
24
  api-only-publisher lint [--target <name>]...
24
25
  api-only-publisher targets
25
26
  api-only-publisher closure [--target <name>]...
26
27
  api-only-publisher changed --since <ref>
27
- api-only-publisher pack --version <v> [--target <name>]... [--out <dir>]
28
- api-only-publisher publish --version <v> [--channel <name>]... [--out <dir>]
28
+ api-only-publisher pack [--pre-release <ids>] [--target <name>]... [--out <dir>]
29
+ api-only-publisher publish [--pre-release <ids>] [--target <name>]... [--channel <name>]... [--out <dir>]
29
30
  api-only-publisher split --out <dir> [--by kind|target]
30
31
 
31
32
  Options:
32
33
  --target <name> Restrict to one target; repeat for several. Default: all.
33
- --version <v> Stamp info.version on every built document.
34
+ --pre-release <ids>
35
+ build/pack/publish: append pre-release identifiers, such as
36
+ rc.1, to each target's version.
34
37
  --openapi Only build OpenAPI documents.
35
38
  --asyncapi Only build AsyncAPI documents.
36
39
  --force init only: overwrite files that already exist.
@@ -43,11 +46,13 @@ Options:
43
46
  -h, --help Show this help.
44
47
 
45
48
  What gets built, and where each document goes, is declared in apionly.yaml.
49
+ A published target's version is read from its version file:
50
+ <target>.bundle.properties beside its bundle root, or the file its versionFile names.
46
51
  `;
47
52
 
48
53
  function parseArgs(argv) {
49
54
  const options = {
50
- targets: [], kinds: null, version: null, quiet: false, force: false,
55
+ targets: [], kinds: null, preRelease: null, quiet: false, force: false,
51
56
  dir: process.cwd(), since: null, out: null, channels: null, by: "kind",
52
57
  };
53
58
  const positional = [];
@@ -60,7 +65,13 @@ function parseArgs(argv) {
60
65
  };
61
66
  switch (arg) {
62
67
  case "--target": options.targets.push(next()); break;
63
- case "--version": options.version = next(); break;
68
+ case "--pre-release": options.preRelease = next(); break;
69
+ case "--version":
70
+ throw new ConfigError(
71
+ "--version is no longer accepted: each published target's version is read from its version " +
72
+ "file, <target>.bundle.properties beside its bundle root. Pass --pre-release <ids> to cut a " +
73
+ "pre-release of it."
74
+ );
64
75
  case "--openapi": options.kinds = (options.kinds || []).concat("openapi"); break;
65
76
  case "--asyncapi": options.kinds = (options.kinds || []).concat("asyncapi"); break;
66
77
  case "--force": options.force = true; break;
@@ -79,6 +90,21 @@ function parseArgs(argv) {
79
90
  return { options, positional };
80
91
  }
81
92
 
93
+ // The targets pack and publish write an archive for: those selected, less any
94
+ // that are never published. Only these need a closure, so only these need to have
95
+ // been staged -- building one target must not depend on every other being staged.
96
+ function shippedTargets(config, targets) {
97
+ return Object.keys(config.targets)
98
+ .filter((target) => (!targets || targets.includes(target)) && config.isPublished(target));
99
+ }
100
+
101
+ // The version of each target in `names`, keyed by target. Every one is resolved
102
+ // before any target is acted on, so a missing or malformed version file stops the
103
+ // command before it has built or shipped anything.
104
+ function versionsOf(config, names, preRelease) {
105
+ return new Map(names.map((target) => [target, versionOf(config, target, { preRelease })]));
106
+ }
107
+
82
108
  async function main(argv) {
83
109
  const { options, positional } = parseArgs(argv);
84
110
  const command = positional[0];
@@ -121,7 +147,16 @@ async function main(argv) {
121
147
  return 0;
122
148
  }
123
149
  case "build": {
124
- const results = build(config, { targets, version: options.version, kinds: options.kinds || undefined, log });
150
+ // A target is stamped only if it is published: a documentation view has no
151
+ // version of its own, and needs no version file.
152
+ const kinds = options.kinds || ["openapi", "asyncapi"];
153
+ const stamped = Object.keys(config.targets).filter((target) =>
154
+ (!targets || targets.includes(target)) && config.isPublished(target) &&
155
+ kinds.some((kind) => config.targets[target][kind]));
156
+ const versions = versionsOf(config, stamped, options.preRelease);
157
+ const results = build(config, {
158
+ targets, kinds, log, versionOf: (target) => versions.get(target) || null,
159
+ });
125
160
  log(`\nBuilt ${results.length} document(s).`);
126
161
  return 0;
127
162
  }
@@ -139,7 +174,10 @@ async function main(argv) {
139
174
  throw new BuildError(`${file} does not exist; run 'build' first`);
140
175
  }
141
176
  log(`=== ${target} (${kind}) ===`);
142
- lint(config, kind, file, log);
177
+ lint(config, kind, file, log, {
178
+ report: true,
179
+ reportFile: config.lintReport(target, kind),
180
+ });
143
181
  linted += 1;
144
182
  }
145
183
  }
@@ -167,9 +205,10 @@ async function main(argv) {
167
205
  return 0;
168
206
  }
169
207
  case "pack": {
170
- if (!options.version) throw new ConfigError("pack requires --version <v>");
171
208
  const outDir = path.resolve(options.dir, options.out || "build/packages");
172
- const closures = forTargets(config);
209
+ const shipped = shippedTargets(config, targets);
210
+ const versions = versionsOf(config, shipped, options.preRelease);
211
+ const closures = forTargets(config, undefined, shipped);
173
212
  let packed = 0;
174
213
  for (const target of Object.keys(config.targets)) {
175
214
  if (targets && !targets.includes(target)) continue;
@@ -179,7 +218,7 @@ async function main(argv) {
179
218
  }
180
219
  const closure = closures.get(target);
181
220
  pack(config, target, {
182
- version: options.version,
221
+ version: versions.get(target),
183
222
  closureSha256: closure ? closure.sha256 : null,
184
223
  outDir, log,
185
224
  });
@@ -189,14 +228,15 @@ async function main(argv) {
189
228
  return 0;
190
229
  }
191
230
  case "publish": {
192
- if (!options.version) throw new ConfigError("publish requires --version <v>");
193
231
  const outDir = path.resolve(options.dir, options.out || "build/packages");
194
232
  const configured = config.channels || {};
195
233
  const names = options.channels || Object.keys(configured);
196
234
  if (names.length === 0) {
197
235
  throw new ConfigError("no channels configured; add a `channels:` block or pass --channel");
198
236
  }
199
- const closures = forTargets(config);
237
+ const shipped = shippedTargets(config, targets);
238
+ const versions = versionsOf(config, shipped, options.preRelease);
239
+ const closures = forTargets(config, undefined, shipped);
200
240
  let published = 0;
201
241
  for (const target of Object.keys(config.targets)) {
202
242
  if (targets && !targets.includes(target)) continue;
@@ -204,7 +244,7 @@ async function main(argv) {
204
244
  const closure = closures.get(target);
205
245
  // Packed once, then shipped unchanged to every channel.
206
246
  const { archive, manifest } = pack(config, target, {
207
- version: options.version,
247
+ version: versions.get(target),
208
248
  closureSha256: closure ? closure.sha256 : null,
209
249
  outDir, log,
210
250
  });
@@ -239,7 +279,8 @@ function report(error) {
239
279
  error instanceof PlaceholderError || error instanceof VersionError ||
240
280
  error instanceof ClosureError || error instanceof PackError ||
241
281
  error instanceof ChangedError || error instanceof SplitError ||
242
- error instanceof ChannelError || error instanceof PolicyError) {
282
+ error instanceof ChannelError || error instanceof PolicyError ||
283
+ error instanceof BundleVersionError) {
243
284
  console.error(`Error: ${error.message}`);
244
285
  process.exitCode = 1;
245
286
  } else {
package/src/closure.js CHANGED
@@ -92,15 +92,18 @@ function hash(files, root) {
92
92
  * Closures for every target that declares a bundle of the given kinds.
93
93
  *
94
94
  * The staged tree must already exist; callers stage first so that the closure
95
- * covers substituted content.
95
+ * covers substituted content. Pass `only` to compute closures for those targets
96
+ * alone: a target outside it is never read, so its bundle root need not be staged.
96
97
  *
98
+ * @param {string[]|null} only the targets to compute closures for; every target when null
97
99
  * @returns {Map<string, {files: string[], sha256: string, byKind: object}>}
98
100
  */
99
- function forTargets(config, kinds = ["openapi", "asyncapi"]) {
101
+ function forTargets(config, kinds = ["openapi", "asyncapi"], only = null) {
100
102
  const result = new Map();
101
103
 
102
104
  for (const kind of kinds) {
103
105
  for (const target of config.targetsFor(kind)) {
106
+ if (only && !only.includes(target)) continue;
104
107
  const entry = config.bundlePath(target, kind);
105
108
  if (!fs.existsSync(entry)) {
106
109
  throw new ClosureError(
package/src/config.js CHANGED
@@ -92,6 +92,7 @@ function load(configPath) {
92
92
  defaults: parsed.defaults || {},
93
93
  toolchain: parsed.toolchain || {},
94
94
  build: parsed.build || {},
95
+ reports: parsed.reports || {},
95
96
  distribution: parsed.distribution || null,
96
97
  channels: parsed.channels || {},
97
98
  targets,
@@ -121,6 +122,10 @@ function load(configPath) {
121
122
  const lint = (this.defaults[kind] || {}).lint;
122
123
  return lint ? path.resolve(this.root, lint) : null;
123
124
  },
125
+ lintReport(target, kind) {
126
+ const reports = this.reports.lint || "build/reports/lint";
127
+ return path.resolve(this.root, reports, target, `${kind}.txt`);
128
+ },
124
129
  tool(name) {
125
130
  return requireString(this.toolchain[name], `toolchain.${name}`);
126
131
  },
@@ -136,6 +141,20 @@ function load(configPath) {
136
141
  bundlePath(target, kind) {
137
142
  return path.join(this.stagingDir(kind), this.targets[target][kind].bundle);
138
143
  },
144
+ // The file a target's version is read from: the target's own `versionFile`,
145
+ // relative to this configuration, or else <target>.bundle.properties beside
146
+ // the target's first bundle root in the hand-authored tree -- so a version
147
+ // sits with the fragments it describes, and changes in the same commit.
148
+ versionFile(target) {
149
+ const spec = this.targets[target];
150
+ if (spec.versionFile !== undefined) {
151
+ return path.resolve(this.root, requireString(spec.versionFile, `targets.${target}.versionFile`));
152
+ }
153
+ const kind = ["openapi", "asyncapi"].find((k) => spec[k]);
154
+ const bundleRoot = path.join(
155
+ this.sourceRoot(), requireString(this.sources[kind], `sources.${kind}`), spec[kind].bundle);
156
+ return path.join(path.dirname(bundleRoot), `${target}.bundle.properties`);
157
+ },
139
158
  // Where a distributed document is copied to, from the transitional
140
159
  // `distribution` block. Null once that block is gone.
141
160
  destinationDir(target) {
package/src/index.js CHANGED
@@ -3,15 +3,16 @@
3
3
  // The programmatic surface.
4
4
  //
5
5
  // The CLI is the usual way in, but a release job that wants to decide something
6
- // for itself -- which targets changed, what a closure hashes to, whether a
7
- // version is a pre-release -- should not have to parse console output to find
8
- // out. Everything the CLI does is available here directly.
6
+ // for itself -- which targets changed, what a closure hashes to, which version a
7
+ // target is at, whether a version is a pre-release -- should not have to parse
8
+ // console output to find out. Everything the CLI does is available here directly.
9
9
 
10
10
  module.exports = {
11
11
  ...require("./config"),
12
12
  ...require("./placeholders"),
13
13
  ...require("./version"),
14
14
  ...require("./version-policy"),
15
+ ...require("./bundle-version"),
15
16
  ...require("./closure"),
16
17
  ...require("./aggregate"),
17
18
  ...require("./pipeline"),
package/src/init.js CHANGED
@@ -147,6 +147,12 @@ dist/
147
147
  node_modules/
148
148
  `;
149
149
 
150
+ const VERSION = `# The version of the example-service contract, for every document it builds.
151
+ # Semantic: major for a breaking change, minor for an additive one, patch for
152
+ # anything else. Change it in the same commit as the fragments it describes.
153
+ version=0.1.0
154
+ `;
155
+
150
156
  const FILES = {
151
157
  "apionly.yaml": CONFIG,
152
158
  ".redocly.yaml": REDOCLY,
@@ -155,6 +161,7 @@ const FILES = {
155
161
  "specs/openapi/shared/conventions.md": CONVENTIONS,
156
162
  "specs/openapi/shared/servers.yaml": SERVERS,
157
163
  "specs/openapi/bundles/example-service_openapi_structure.yaml": BUNDLE,
164
+ "specs/openapi/bundles/example-service.bundle.properties": VERSION,
158
165
  "specs/openapi/paths/example/ExamplesV1.yaml": PATH_FRAGMENT,
159
166
  "specs/openapi/components/common/security/BearerAuth.yaml": SECURITY_SCHEME,
160
167
  "specs/openapi/components/common/responses/errors/InvalidRequestProblemV1.yaml": PROBLEM,
package/src/pack.js CHANGED
@@ -96,7 +96,8 @@ function pack(config, target, { version, closureSha256, outDir, log = () => {} }
96
96
  if (declared !== String(version)) {
97
97
  throw new PackError(
98
98
  `target '${target}': ${path.basename(document)} declares info.version '${declared}' ` +
99
- `but is being packed as '${version}'. Build with --version ${version} first.`
99
+ `but is being packed as '${version}'. Build it again first: its version file, or the ` +
100
+ `--pre-release it was built with, has changed since.`
100
101
  );
101
102
  }
102
103
  }
package/src/pipeline.js CHANGED
@@ -12,11 +12,20 @@ const { generateAsyncApi, isAggregate } = require("./aggregate");
12
12
 
13
13
  class BuildError extends Error {}
14
14
 
15
- function run(command, args, { quiet }) {
15
+ function run(command, args, { quiet, reportFile = null } = {}) {
16
16
  try {
17
17
  const out = execFileSync(command, args, { encoding: "utf8", stdio: quiet ? "pipe" : "inherit" });
18
+ if (reportFile) {
19
+ fs.mkdirSync(path.dirname(reportFile), { recursive: true });
20
+ fs.writeFileSync(reportFile, out);
21
+ }
18
22
  return out;
19
23
  } catch (error) {
24
+ const output = (error.stdout || "") + (error.stderr || "");
25
+ if (reportFile) {
26
+ fs.mkdirSync(path.dirname(reportFile), { recursive: true });
27
+ fs.writeFileSync(reportFile, output);
28
+ }
20
29
  throw new BuildError(
21
30
  `${command} ${args.join(" ")} failed` + (error.stdout ? `\n${error.stdout}` : "") +
22
31
  (error.stderr ? `\n${error.stderr}` : "")
@@ -91,13 +100,14 @@ function bundle(config, target, kind, outFile, log) {
91
100
  }
92
101
  }
93
102
 
94
- function lint(config, kind, file, log) {
103
+ function lint(config, kind, file, log, { report = false, reportFile = null } = {}) {
95
104
  const tool = kind === "openapi" ? config.tool("redocly") : config.tool("asyncapi");
96
105
  const args = kind === "openapi"
97
106
  ? ["--yes", tool, "lint"].concat(config.lintConfig("openapi") ? ["--config", config.lintConfig("openapi")] : []).concat([file])
98
107
  : ["--yes", tool, "validate", file];
99
108
  log(`-- Validating ${path.basename(file)}`);
100
- run("npx", args, { quiet: true });
109
+ const output = run("npx", args, { quiet: true, reportFile });
110
+ if (report && output.trim()) log(output.trimEnd());
101
111
  }
102
112
 
103
113
  /**
@@ -145,9 +155,12 @@ function prepare(config, { kinds = ["openapi", "asyncapi"], log = () => {} } = {
145
155
  /**
146
156
  * Build every requested target.
147
157
  *
158
+ * `versionOf(target)` names the version to stamp on a target's documents; a target
159
+ * it returns nothing for keeps the version its source declares.
160
+ *
148
161
  * @returns {Array<{target, kind, file, distributed}>}
149
162
  */
150
- function build(config, { targets, version, kinds = ["openapi", "asyncapi"], log = () => {} } = {}) {
163
+ function build(config, { targets, versionOf = () => null, kinds = ["openapi", "asyncapi"], log = () => {} } = {}) {
151
164
  const results = [];
152
165
  for (const kind of kinds) {
153
166
  const all = config.targetsFor(kind).filter((t) => !targets || targets.includes(t));
@@ -172,6 +185,7 @@ function build(config, { targets, version, kinds = ["openapi", "asyncapi"], log
172
185
  }
173
186
  const outFile = path.join(config.distDir(target), config.outputName(kind));
174
187
  bundle(config, target, kind, outFile, log);
188
+ const version = versionOf(target);
175
189
  if (version) {
176
190
  log(`-- Stamping version '${version}'`);
177
191
  stampFile(outFile, version);