@arc-e-tect/api-only-publisher 0.18.4 → 0.18.6
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 +142 -9
- package/package.json +1 -1
- package/src/cli.js +34 -4
- package/src/config.js +7 -4
- package/src/init-hook.js +135 -0
- package/src/init-questions.js +8 -0
- package/src/init.js +10 -2
- package/src/version-guard.js +2 -2
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.18.
|
|
9
|
+
:api-only-publisher-version: 0.18.6
|
|
10
10
|
|
|
11
11
|
[NOTE]
|
|
12
12
|
.About this documentation
|
|
@@ -23,6 +23,8 @@ image:https://img.shields.io/github/v/release/Arc-E-Tect/SoftwareEngineeringDone
|
|
|
23
23
|
image:https://img.shields.io/badge/Node-22.14%2B-339933?logo=node.js[Node 22.14+,link=https://nodejs.org]
|
|
24
24
|
image:https://img.shields.io/badge/License-MIT-blue[MIT,link=LICENSE]
|
|
25
25
|
|
|
26
|
+
Current release: *{api-only-publisher-version}*, as link:CHANGELOG.md[the changelog →] records it.
|
|
27
|
+
|
|
26
28
|
[NOTE]
|
|
27
29
|
.Where the links in this document go
|
|
28
30
|
====
|
|
@@ -291,6 +293,10 @@ The name becomes file names, a properties key, a Maven artifactId, a git tag and
|
|
|
291
293
|
|Broker host
|
|
292
294
|
|`--broker-host <host[:port]>`
|
|
293
295
|
|`kafka:9092`. Asked only for a library with AsyncAPI.
|
|
296
|
+
|
|
297
|
+
|Run the Publisher with
|
|
298
|
+
|`--runner <npm\|docker>`
|
|
299
|
+
|`npm`. How the pre-push hook runs `check`: `npx`, from the library's own `package.json`, or the Docker image of this Publisher's version (<<installation,Installation §>>).
|
|
294
300
|
|===
|
|
295
301
|
|
|
296
302
|
A flag always wins: its question is not asked, and an invalid value, or a flag for a kind the library does not hold, stops `init` before it writes anything.
|
|
@@ -303,6 +309,13 @@ What each kind of library gets::
|
|
|
303
309
|
* *Both*: both trees, one target building both documents, and one schema they share: `specs/openapi/components/common/schemas/ExampleIdV1.yaml`, which the HTTP response and the event payload both refer to.
|
|
304
310
|
That is the library's convention in miniature -- a definition used by both protocols is one fragment -- and why the whole source root is staged together (<<what-build-does,What `build` does §>>).
|
|
305
311
|
|
|
312
|
+
What every library gets as well::
|
|
313
|
+
A `pre-push` git hook, `.githooks/pre-push` at the top of the repository, that runs `check` before every push (<<version-hook,Checking versions before every push §>>).
|
|
314
|
+
Inside a git repository, `init` also points this clone's git at it, with `git config core.hooksPath .githooks`, unless `core.hooksPath` is already set.
|
|
315
|
+
When the repository's hooks are run by a hook manager -- a `.husky` directory, a `lefthook.yml`, a `.pre-commit-config.yaml`, or a `core.hooksPath` that points somewhere else -- `init` writes no hook, which would compete with it, and prints the command to add to the manager's `pre-push` entry instead.
|
|
316
|
+
A `core.hooksPath` in your global git configuration counts as well, deliberately: setting one for this repository would quietly switch off the hooks you run everywhere else, such as a company-wide secret scanner.
|
|
317
|
+
Add the command to those hooks, or set `core.hooksPath` for this repository yourself if that is what you want.
|
|
318
|
+
|
|
306
319
|
What it reports::
|
|
307
320
|
`init` never overwrites what is there unless told to, and says for every file what it did:
|
|
308
321
|
+
|
|
@@ -314,6 +327,8 @@ The message names every slot added -- see <<init-additive,Adding a kind to an ex
|
|
|
314
327
|
`differs`:: The file was there, and does not match: where the library has diverged from the scaffold, usually on purpose.
|
|
315
328
|
It is left alone.
|
|
316
329
|
`overwrote`:: The file differed, and `--force` replaced it.
|
|
330
|
+
`skipped`:: The pre-push hook was not written, because a hook manager runs the repository's hooks.
|
|
331
|
+
`configured`:: `core.hooksPath` was not set, and now points at `.githooks`.
|
|
317
332
|
+
|
|
318
333
|
At a terminal, `--force` first lists the files that still differ -- after any additive update apionly.yaml already got -- and asks once whether to overwrite them; without a terminal, or with `--yes`, it overwrites them without asking.
|
|
319
334
|
|
|
@@ -385,6 +400,19 @@ Only `build` does this; `lint` does not, and no report file is written.
|
|
|
385
400
|
[#versions]
|
|
386
401
|
== Versions
|
|
387
402
|
|
|
403
|
+
[WARNING]
|
|
404
|
+
.You maintain the version files yourself
|
|
405
|
+
====
|
|
406
|
+
The Publisher never writes a version file.
|
|
407
|
+
It does not raise a version when it builds, packs or publishes, and it does not work one out from commit messages the way semantic-release does.
|
|
408
|
+
The version it publishes is whatever the target's version file says.
|
|
409
|
+
It only writes that version into the built document's `info.version`.
|
|
410
|
+
|
|
411
|
+
So when you change a target's fragments, raise the version in that target's version file yourself, in the same commit.
|
|
412
|
+
The Publisher checks that you did: `check` and `publish` refuse a target that changed without a new version (<<version-guard,A changed target needs a new version §>>).
|
|
413
|
+
A `pre-push` git hook runs the same check on your own machine, before anything leaves it (<<version-hook,Checking versions before every push §>>).
|
|
414
|
+
====
|
|
415
|
+
|
|
388
416
|
Every published target has a version of its own, and it is kept in the library, beside the fragments it describes.
|
|
389
417
|
|
|
390
418
|
.specs/openapi/bundles/orders.bundle.properties
|
|
@@ -434,34 +462,135 @@ Both read only git: the target's release tags, its history, and the working tree
|
|
|
434
462
|
|A version above the latest release; one below it is refused.
|
|
435
463
|
|
|
436
464
|
|No release tags at all
|
|
437
|
-
|Compared with the merge-base with the
|
|
465
|
+
|Compared with the merge-base with the base 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
466
|
A target whose version file was not there yet is new, and passes.
|
|
439
467
|
|===
|
|
440
468
|
|
|
441
469
|
Release tags are named by `versioning.tagFormat`, `{target}-v{version}` by default: `orders-v2.1.0`.
|
|
442
470
|
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
471
|
The reference pipelines show the step, and the `gitlab-package` channel's `release: true` creates the tag with the release.
|
|
444
|
-
The
|
|
472
|
+
The base branch is `versioning.baseBranch`, `main` by default; its remote-tracking branch, `origin/<base branch>`, is preferred, as that is what a CI checkout has.
|
|
473
|
+
Set it to the branch that holds the release you validate against, which need not be `main`.
|
|
474
|
+
A library whose default branch is `pre-prod` cuts a release there and deploys it to staging, and later merges `pre-prod` into `main` only to record that the production deployment was approved.
|
|
475
|
+
For that library the last reviewed versions live on `pre-prod`, so that is the branch to compare with:
|
|
476
|
+
|
|
477
|
+
[source,yaml]
|
|
478
|
+
----
|
|
479
|
+
versioning:
|
|
480
|
+
baseBranch: pre-prod # compared with origin/pre-prod, or pre-prod when there is no remote copy
|
|
481
|
+
----
|
|
482
|
+
|
|
445
483
|
|
|
446
484
|
A pre-release follows the same rules, by its full version:
|
|
447
485
|
|
|
448
|
-
* Its version file need not change: `--pre-release rc.2` already makes a new version, so a target changed since the
|
|
486
|
+
* Its version file need not change: `--pre-release rc.2` already makes a new version, so a target changed since the base branch passes with an unchanged file.
|
|
449
487
|
* 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
488
|
* 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
489
|
|
|
452
490
|
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
491
|
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
|
|
492
|
+
A target with no release tag, in a checkout that has no merge-base with the base branch -- a shallow clone, or no such 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
493
|
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
|
|
494
|
+
Without release tags, the check on the base branch itself compares the base 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
495
|
|
|
458
|
-
|
|
496
|
+
[#version-hook]
|
|
497
|
+
=== Checking versions before every push
|
|
459
498
|
|
|
499
|
+
The pipeline catches a missing version bump, but only after you have pushed.
|
|
500
|
+
A git hook catches it before then.
|
|
501
|
+
A hook is a script that git runs on your machine at a fixed moment.
|
|
502
|
+
A `pre-push` hook runs when you type `git push`, and if it fails, git sends nothing.
|
|
503
|
+
The hook below runs `check`.
|
|
504
|
+
It needs no hook manager and nothing outside git and the Publisher.
|
|
505
|
+
|
|
506
|
+
`init` writes it, as `.githooks/pre-push` at the top of the repository (<<init,Scaffolding a library §>>).
|
|
507
|
+
`--runner` picks the variant: `npm`, the default, or `docker`.
|
|
508
|
+
When the library is in a subdirectory, the hook goes to the top all the same, because that is where git runs hooks from, and runs `check` in the library's directory.
|
|
509
|
+
A second library in the same repository finds the hook there already and reports it as `differs`: add its `check` to the hook by hand.
|
|
510
|
+
|
|
511
|
+
For a library that already exists, write the hook yourself:
|
|
512
|
+
|
|
513
|
+
. *Create the hook* as `.githooks/pre-push` in the repository, the one that holds `apionly.yaml`.
|
|
514
|
+
The file has no extension.
|
|
515
|
+
Pick the variant for the way the library runs the Publisher (<<installation,Installation §>>):
|
|
516
|
+
+
|
|
517
|
+
.`.githooks/pre-push`, with the Publisher in `package.json`
|
|
460
518
|
[source,sh]
|
|
461
519
|
----
|
|
462
520
|
#!/bin/sh
|
|
521
|
+
# Refuses the push when a published target changed without a new version in its version file.
|
|
522
|
+
# check compares with origin's base branch and the release tags: bring them up to date.
|
|
523
|
+
git fetch --quiet --tags origin || echo "pre-push: could not fetch origin; checking against what is here" >&2
|
|
463
524
|
exec npx --no-install api-only-publisher check
|
|
464
525
|
----
|
|
526
|
+
+
|
|
527
|
+
.`.githooks/pre-push`, with the Docker image
|
|
528
|
+
[source,sh,subs="attributes+"]
|
|
529
|
+
----
|
|
530
|
+
#!/bin/sh
|
|
531
|
+
# Refuses the push when a published target changed without a new version in its version file.
|
|
532
|
+
# check compares with origin's base branch and the release tags: bring them up to date.
|
|
533
|
+
git fetch --quiet --tags origin || echo "pre-push: could not fetch origin; checking against what is here" >&2
|
|
534
|
+
exec docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" \
|
|
535
|
+
arcetect/api-only-publisher:{api-only-publisher-version} check
|
|
536
|
+
----
|
|
537
|
+
+
|
|
538
|
+
The hook fetches every branch of `origin`, so it never needs to know which one `versioning.baseBranch` names, and changing that setting needs no change to the hook.
|
|
539
|
+
It fetches on your machine, not in the container: the image has no ssh client and none of your credentials.
|
|
540
|
+
+
|
|
541
|
+
Git runs a hook from the top of the repository.
|
|
542
|
+
When `apionly.yaml` is in a subdirectory, add `cd <that directory> || exit 1` before the `npx` line, or `-C <that directory>` after `check` in the Docker variant, which needs the whole repository mounted to read its history.
|
|
543
|
+
|
|
544
|
+
. *Make it executable*, and commit it, so that everyone who clones the repository gets it:
|
|
545
|
+
+
|
|
546
|
+
[source,sh]
|
|
547
|
+
----
|
|
548
|
+
chmod +x .githooks/pre-push
|
|
549
|
+
git add .githooks/pre-push
|
|
550
|
+
git commit -m "chore: check API versions before every push"
|
|
551
|
+
----
|
|
552
|
+
+
|
|
553
|
+
On Windows, `chmod` has no effect, and neither does the executable bit `init` sets.
|
|
554
|
+
Use `git update-index --chmod=+x .githooks/pre-push` before the commit instead.
|
|
555
|
+
Git for Windows runs the hook with its own `sh`, so the script works there as it is.
|
|
556
|
+
|
|
557
|
+
. *Tell git to use the directory.*
|
|
558
|
+
Git only looks in `.git/hooks` by default, and that directory is never committed.
|
|
559
|
+
Every clone has to run this once; `init` runs it for the clone it scaffolds in:
|
|
560
|
+
+
|
|
561
|
+
[source,sh]
|
|
562
|
+
----
|
|
563
|
+
git config core.hooksPath .githooks
|
|
564
|
+
----
|
|
565
|
+
+
|
|
566
|
+
Put this line in the library's own README, or in its setup script, so nobody has to remember it.
|
|
567
|
+
A hook manager such as husky or lefthook does this step for you when its dependencies are installed.
|
|
568
|
+
If you already use one, call the same `check` command from its `pre-push` entry instead; `init` prints that command when it finds one.
|
|
569
|
+
|
|
570
|
+
. *Try it.*
|
|
571
|
+
Change a fragment of a published target and commit it without changing its version file.
|
|
572
|
+
`git push` should now stop, and the message names the target and its version file:
|
|
573
|
+
+
|
|
574
|
+
[source,console]
|
|
575
|
+
----
|
|
576
|
+
$ git push
|
|
577
|
+
orders 2.1.0 VIOLATION orders changed since the merge-base with main (3f2a91c0d4e1), but its version is still 2.1.0: raise it in specs/openapi/bundles/orders.bundle.properties
|
|
578
|
+
Error: 1 target(s) changed without a new version:
|
|
579
|
+
orders 2.1.0: orders changed since the merge-base with main (3f2a91c0d4e1), but its version is still 2.1.0: raise it in specs/openapi/bundles/orders.bundle.properties
|
|
580
|
+
A published version is never replaced: a changed specification needs a changed version.
|
|
581
|
+
error: failed to push some refs to 'origin'
|
|
582
|
+
----
|
|
583
|
+
+
|
|
584
|
+
Raise the version, commit, and push again.
|
|
585
|
+
|
|
586
|
+
Some things to know about the hook:
|
|
587
|
+
|
|
588
|
+
* It checks every published target, not only those the pushed commits touch: a target that did not change passes, and the whole check takes seconds.
|
|
589
|
+
* `check` looks at the working tree, and that includes changes you have not committed yet.
|
|
590
|
+
Commit or stash them before you push, so it checks what you are actually pushing.
|
|
591
|
+
* It needs neither credentials nor a network beyond the `git fetch`, and it builds nothing.
|
|
592
|
+
* `git push --no-verify` skips the hook.
|
|
593
|
+
The pipeline still runs the same check, so skipping it only means you find out later.
|
|
465
594
|
|
|
466
595
|
[#closure]
|
|
467
596
|
== Independent versioning, and the dependency closure
|
|
@@ -952,7 +1081,7 @@ It publishes each target to a GitLab project's Generic Package Registry, on gitl
|
|
|
952
1081
|
[source,yaml]
|
|
953
1082
|
----
|
|
954
1083
|
channels:
|
|
955
|
-
gitlab-package: # in a GitLab CI job, nothing below is needed
|
|
1084
|
+
gitlab-package: # in a GitLab CI job, nothing below is needed, unless it inherits a GITLAB_TOKEN
|
|
956
1085
|
# url: https://gitlab.example.com/api/v4 # default: $CI_API_V4_URL, else https://gitlab.com/api/v4
|
|
957
1086
|
# project: group/subgroup/api-library # an ID or a path; default: $CI_PROJECT_ID
|
|
958
1087
|
# tokenEnv: GITLAB_TOKEN # default; when unset, $CI_JOB_TOKEN is used
|
|
@@ -985,6 +1114,10 @@ link:pipelines/gitlab-ci.yml[The reference pipeline →] publishes this way, fro
|
|
|
985
1114
|
|===
|
|
986
1115
|
|
|
987
1116
|
Without either, nothing is sent to GitLab, and `publish` warns, naming both (<<channel-problems,When a channel cannot take a version §>>).
|
|
1117
|
+
|
|
1118
|
+
*A `GITLAB_TOKEN` the job inherits wins over `CI_JOB_TOKEN`*, whether it comes from the project, a parent group or the instance, and is sent as a personal or project access token.
|
|
1119
|
+
When it is expired or lacks the `api` scope, the read-back fails with `401 Unauthorized` or `403 insufficient_scope`, although the job token itself would have worked.
|
|
1120
|
+
Set `tokenEnv: CI_JOB_TOKEN` and `tokenType: job` on the channel to use the job token regardless (link:../docs/guides/publishing-to-gitlab/overview.adoc#token-shadowed[When the job fails with 401 or 403 →]).
|
|
988
1121
|
A deploy token is the least privilege for packages alone: it reaches one project's package registry and nothing else.
|
|
989
1122
|
|
|
990
1123
|
*The same version again.*
|
|
@@ -1135,7 +1268,7 @@ portfolio: # absent is fine; an aggregate builds on documented de
|
|
|
1135
1268
|
|
|
1136
1269
|
versioning: # absent is fine; see A changed target needs a new version §
|
|
1137
1270
|
tagFormat: "{target}-v{version}" # the release tags the pipeline creates; the default
|
|
1138
|
-
|
|
1271
|
+
baseBranch: main # compared with when a target has no release tag; the default
|
|
1139
1272
|
----
|
|
1140
1273
|
|
|
1141
1274
|
`targets` is the single source of truth for what gets built.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ const { loadFrom, ConfigError } = require("./config");
|
|
|
9
9
|
const { build, prepare, BuildError } = require("./pipeline");
|
|
10
10
|
const { init, scaffold, plan } = require("./init");
|
|
11
11
|
const { resolveValues } = require("./init-questions");
|
|
12
|
+
const { planHook, setHooksPath, HOOKS_DIR } = require("./init-hook");
|
|
12
13
|
const { resolvePortfolioValues, writePortfolioSection } = require("./config-command");
|
|
13
14
|
const { PlaceholderError } = require("./placeholders");
|
|
14
15
|
const { VersionError } = require("./version");
|
|
@@ -34,7 +35,7 @@ Usage:
|
|
|
34
35
|
[--title <text>] [--contract-version <version>]
|
|
35
36
|
[--contact-name <text>] [--contact-url <url>]
|
|
36
37
|
[--license <spdx-id>] [--license-url <url>]
|
|
37
|
-
[--server-url <url>] [--broker-host <host[:port]>]
|
|
38
|
+
[--server-url <url>] [--broker-host <host[:port]>] [--runner <npm|docker>]
|
|
38
39
|
api-only-publisher config [section] [--yes] [--portfolio-paths <target-prefix|none>]
|
|
39
40
|
[--portfolio-location <dir>]
|
|
40
41
|
api-only-publisher build [--target <name>]... [--pre-release <ids>] [--openapi|--asyncapi]
|
|
@@ -64,6 +65,9 @@ Options:
|
|
|
64
65
|
--title, --contract-version, --contact-name, --contact-url, --license,
|
|
65
66
|
--license-url, --server-url, --broker-host
|
|
66
67
|
init only: the value of that question; a flag always wins.
|
|
68
|
+
--runner <npm|docker>
|
|
69
|
+
init only: how the pre-push hook init writes runs check.
|
|
70
|
+
Default: npm.
|
|
67
71
|
--portfolio-paths <target-prefix|none>, --portfolio-location <dir>
|
|
68
72
|
config only: the value of that question; a flag always wins.
|
|
69
73
|
--since <ref> changed: the git ref to compare the working tree against.
|
|
@@ -94,7 +98,7 @@ function parseArgs(argv) {
|
|
|
94
98
|
const INIT_VALUES = {
|
|
95
99
|
"--title": "title", "--contract-version": "contractVersion", "--contact-name": "contactName",
|
|
96
100
|
"--contact-url": "contactUrl", "--license": "license", "--license-url": "licenseUrl",
|
|
97
|
-
"--server-url": "serverUrl", "--broker-host": "brokerHost",
|
|
101
|
+
"--server-url": "serverUrl", "--broker-host": "brokerHost", "--runner": "runner",
|
|
98
102
|
};
|
|
99
103
|
const CONFIG_VALUES = { "--portfolio-paths": "paths", "--portfolio-location": "location" };
|
|
100
104
|
const positional = [];
|
|
@@ -232,26 +236,52 @@ async function runInit(options, positional, io, log) {
|
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
log(`Scaffolding a specification library in ${dir}`);
|
|
239
|
+
const hook = planHook(dir, values.runner, require("../package.json").version);
|
|
240
|
+
const scaffolded = { ...scaffold(values), ...hook.files };
|
|
235
241
|
let force = options.force;
|
|
236
242
|
if (terminal && force) {
|
|
237
243
|
// Asked, --force means "after showing me": it overwrites what differs only
|
|
238
244
|
// once the list has been seen and agreed to.
|
|
239
|
-
const differing = plan(dir,
|
|
245
|
+
const differing = plan(dir, scaffolded, values, portfolio).filter((entry) => entry.status === "differs");
|
|
240
246
|
if (differing.length > 0) {
|
|
241
247
|
output.write(`These files differ from the scaffold:\n${differing.map((d) => ` ${d.rel}\n`).join("")}`);
|
|
242
248
|
const answer = await terminal.question(`Overwrite these ${differing.length} file(s)? [y/N] `);
|
|
243
249
|
force = /^y(es)?$/i.test(answer.trim());
|
|
244
250
|
}
|
|
245
251
|
}
|
|
246
|
-
init(dir, { values, force, log, portfolio });
|
|
252
|
+
init(dir, { values, force, log, portfolio, extra: hook.files, executable: hook.executable });
|
|
247
253
|
if (portfolioStatusThisRun === "present") log(" present portfolio (already configured; init never changes it)");
|
|
254
|
+
const next = reportHook(hook, log);
|
|
248
255
|
log(`\nNext: api-only-publisher build -C ${dir}`);
|
|
256
|
+
for (const line of next) log(line);
|
|
249
257
|
} finally {
|
|
250
258
|
if (terminal) terminal.close();
|
|
251
259
|
}
|
|
252
260
|
return 0;
|
|
253
261
|
}
|
|
254
262
|
|
|
263
|
+
/**
|
|
264
|
+
* Reports what init did about the pre-push hook beyond writing its file, and does
|
|
265
|
+
* the one thing left: pointing this clone's git at .githooks, when nothing else
|
|
266
|
+
* already decides where its hooks are. README.adoc#version-hook.
|
|
267
|
+
*
|
|
268
|
+
* @returns {string[]} the lines to print after "Next:", for what init could not do itself
|
|
269
|
+
*/
|
|
270
|
+
function reportHook(hook, log) {
|
|
271
|
+
if (hook.manager) {
|
|
272
|
+
log(` skipped ${HOOKS_DIR}/pre-push (${hook.manager} runs this repository's hooks)`);
|
|
273
|
+
return [`Add to its pre-push hook: ${hook.command}`];
|
|
274
|
+
}
|
|
275
|
+
if (!hook.repository) {
|
|
276
|
+
return [`Once this is a git repository, run the pre-push hook: git config core.hooksPath ${HOOKS_DIR}`];
|
|
277
|
+
}
|
|
278
|
+
if (hook.hooksPath === "unset") {
|
|
279
|
+
setHooksPath(hook.repository);
|
|
280
|
+
log(` configured core.hooksPath=${HOOKS_DIR} (this clone runs the pre-push hook)`);
|
|
281
|
+
}
|
|
282
|
+
return [`Every other clone runs the pre-push hook after: git config core.hooksPath ${HOOKS_DIR}`];
|
|
283
|
+
}
|
|
284
|
+
|
|
255
285
|
const CONFIGURABLE_SECTIONS = ["portfolio"];
|
|
256
286
|
|
|
257
287
|
/**
|
package/src/config.js
CHANGED
|
@@ -36,7 +36,7 @@ const TARGET_KEYS = ["openapi", "asyncapi", "publish", "versionFile", "channels"
|
|
|
36
36
|
const TARGET_KIND_KEYS = ["bundle", "aggregate", "info"];
|
|
37
37
|
const PORTFOLIO_KEYS = ["openapi", "security", "location"];
|
|
38
38
|
const PORTFOLIO_OPENAPI_KEYS = ["paths", "operationIds", "tags"];
|
|
39
|
-
const VERSIONING_KEYS = ["tagFormat", "
|
|
39
|
+
const VERSIONING_KEYS = ["tagFormat", "baseBranch"];
|
|
40
40
|
|
|
41
41
|
// What every portfolio setting is when apionly.yaml says nothing: safe for the
|
|
42
42
|
// ordinary case (a gateway routing /<target>/** to each service), and named rather
|
|
@@ -206,6 +206,9 @@ function load(configPath) {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
const versioning = parsed.versioning || {};
|
|
209
|
+
if (versioning && typeof versioning === "object" && "mainBranch" in versioning) {
|
|
210
|
+
throw new ConfigError("versioning.mainBranch was renamed to versioning.baseBranch: rename the key, its value stays the same");
|
|
211
|
+
}
|
|
209
212
|
checkKeys(versioning, VERSIONING_KEYS, "versioning");
|
|
210
213
|
if (versioning.tagFormat !== undefined) {
|
|
211
214
|
const format = requireString(versioning.tagFormat, "versioning.tagFormat");
|
|
@@ -213,7 +216,7 @@ function load(configPath) {
|
|
|
213
216
|
throw new ConfigError(`versioning.tagFormat must contain {target} and {version}, not ${JSON.stringify(format)}`);
|
|
214
217
|
}
|
|
215
218
|
}
|
|
216
|
-
if (versioning.
|
|
219
|
+
if (versioning.baseBranch !== undefined) requireString(versioning.baseBranch, "versioning.baseBranch");
|
|
217
220
|
|
|
218
221
|
const channels = parsed.channels || {};
|
|
219
222
|
const types = channelTypes(channels);
|
|
@@ -347,8 +350,8 @@ function load(configPath) {
|
|
|
347
350
|
tagFormat() {
|
|
348
351
|
return this.versioning.tagFormat || "{target}-v{version}";
|
|
349
352
|
},
|
|
350
|
-
|
|
351
|
-
return this.versioning.
|
|
353
|
+
baseBranch() {
|
|
354
|
+
return this.versioning.baseBranch || "main";
|
|
352
355
|
},
|
|
353
356
|
// Where pack and publish write archives, and split its parts, unless --out says
|
|
354
357
|
// otherwise; relative to this configuration, as every other path here is.
|
package/src/init-hook.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// The pre-push hook `init` offers: `check` on the developer's own machine, before a
|
|
4
|
+
// target that changed without a new version leaves it. README.adoc#version-hook.
|
|
5
|
+
//
|
|
6
|
+
// Unlike the rest of the scaffold, where the hook goes and whether it is written at
|
|
7
|
+
// all depends on the repository the library is in, so this is where git is read:
|
|
8
|
+
//
|
|
9
|
+
// - The hook lives in .githooks/ at the top of the repository, committed, because
|
|
10
|
+
// .git/hooks never is; core.hooksPath points git at it, once per clone.
|
|
11
|
+
// - A repository whose hooks a manager runs -- husky, lefthook, pre-commit, or a
|
|
12
|
+
// core.hooksPath that points elsewhere -- gets no hook file, which would compete
|
|
13
|
+
// with the manager, only the command to add to its pre-push entry.
|
|
14
|
+
// - The hook fetches on the host, never inside the Docker image: the image has no
|
|
15
|
+
// ssh client and no credentials, and origin is what check compares with.
|
|
16
|
+
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const path = require("path");
|
|
19
|
+
const { execFileSync } = require("child_process");
|
|
20
|
+
|
|
21
|
+
const HOOKS_DIR = ".githooks";
|
|
22
|
+
const IMAGE = "arcetect/api-only-publisher";
|
|
23
|
+
|
|
24
|
+
function tryGit(args, cwd) {
|
|
25
|
+
try {
|
|
26
|
+
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The closest directory at or above `dir` that exists: init may be scaffolding one that does not yet. */
|
|
33
|
+
function existingAncestor(dir) {
|
|
34
|
+
let current = dir;
|
|
35
|
+
while (!fs.existsSync(current)) current = path.dirname(current);
|
|
36
|
+
return current;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A path as a single-quoted shell word. */
|
|
40
|
+
function shellQuote(text) {
|
|
41
|
+
return /^[A-Za-z0-9_./-]+$/.test(text) ? text : `'${text.replace(/'/g, "'\\''")}'`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The image tag the Docker variant runs: this Publisher's own version, or latest for a build that was never released. */
|
|
45
|
+
function imageTag(version) {
|
|
46
|
+
return /^\d+\.\d+\.\d+$/.test(version) && version !== "0.0.0" ? version : "latest";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The command a pre-push hook runs, from the top of the repository, for a library
|
|
51
|
+
* `sub` below it ("" when the library is at the top).
|
|
52
|
+
*
|
|
53
|
+
* npx finds the Publisher in the library's own node_modules, so the npm variant
|
|
54
|
+
* changes into the library first; the Docker variant mounts the whole repository,
|
|
55
|
+
* .git included, and names the library with -C.
|
|
56
|
+
*/
|
|
57
|
+
function checkCommand(runner, sub, version) {
|
|
58
|
+
if (runner === "docker") {
|
|
59
|
+
return `docker run --rm --user "$(id -u):$(id -g)" -v "$PWD:/work" ${IMAGE}:${imageTag(version)} check` +
|
|
60
|
+
(sub ? ` -C ${shellQuote(sub)}` : "");
|
|
61
|
+
}
|
|
62
|
+
return `${sub ? `cd ${shellQuote(sub)} && ` : ""}npx --no-install api-only-publisher check`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const FETCH = `git fetch --quiet --tags origin || echo "pre-push: could not fetch origin; checking against what is here" >&2`;
|
|
66
|
+
|
|
67
|
+
/** The hook script itself. */
|
|
68
|
+
function hookScript(runner, sub, version) {
|
|
69
|
+
const lines = [
|
|
70
|
+
"#!/bin/sh",
|
|
71
|
+
"# Written by api-only-publisher init: refuses the push when a published target",
|
|
72
|
+
"# changed without a new version in its version file. `git push --no-verify` skips it.",
|
|
73
|
+
"# check compares with origin's base branch and the release tags: bring them up to date.",
|
|
74
|
+
FETCH,
|
|
75
|
+
];
|
|
76
|
+
// A failed cd must stop the push, not run check somewhere else.
|
|
77
|
+
if (runner === "npm" && sub) lines.push(`cd ${shellQuote(sub)} || exit 1`, `exec ${checkCommand(runner, "", version)}`);
|
|
78
|
+
else lines.push(`exec ${checkCommand(runner, sub, version)}`);
|
|
79
|
+
return lines.join("\n") + "\n";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The hook manager this repository already uses, by name, or null. */
|
|
83
|
+
function hookManager(top, hooksPath) {
|
|
84
|
+
if (fs.existsSync(path.join(top, ".husky"))) return "husky";
|
|
85
|
+
for (const file of ["lefthook.yml", "lefthook.yaml", ".lefthook.yml", ".lefthook.yaml"]) {
|
|
86
|
+
if (fs.existsSync(path.join(top, file))) return "lefthook";
|
|
87
|
+
}
|
|
88
|
+
if (fs.existsSync(path.join(top, ".pre-commit-config.yaml"))) return "pre-commit";
|
|
89
|
+
if (hooksPath && path.normalize(hooksPath).replace(/[\\/]+$/, "") !== HOOKS_DIR) return `core.hooksPath (${hooksPath})`;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What init does about the hook for a library in `dir`, run with `runner`.
|
|
95
|
+
*
|
|
96
|
+
* @returns {{
|
|
97
|
+
* files: Object<string, string>, the hook file, keyed by its path relative to dir; empty under a manager
|
|
98
|
+
* executable: string[], which of those files to make executable
|
|
99
|
+
* manager: string|null, the hook manager found, if any
|
|
100
|
+
* command: string, what a manager's pre-push entry should run
|
|
101
|
+
* repository: string|null, the top of the git repository, or null outside one
|
|
102
|
+
* hooksPath: "unset"|"set"|null, whether init should set core.hooksPath; null when it has nothing to do
|
|
103
|
+
* }}
|
|
104
|
+
*/
|
|
105
|
+
function planHook(dir, runner, version) {
|
|
106
|
+
const top = tryGit(["rev-parse", "--show-toplevel"], existingAncestor(dir));
|
|
107
|
+
const repository = top === null ? null : fs.realpathSync(top);
|
|
108
|
+
// Real paths, as git answers with: a directory reached through a symlink -- /var on macOS -- would never match.
|
|
109
|
+
const ancestor = existingAncestor(dir);
|
|
110
|
+
const libraryDir = path.join(fs.realpathSync(ancestor), path.relative(ancestor, dir));
|
|
111
|
+
// Outside a repository, the library is taken to become the top of one.
|
|
112
|
+
const root = repository || libraryDir;
|
|
113
|
+
const sub = path.relative(root, libraryDir).split(path.sep).join("/");
|
|
114
|
+
const hooksPath = repository ? tryGit(["config", "--get", "core.hooksPath"], repository) : null;
|
|
115
|
+
const manager = repository ? hookManager(repository, hooksPath) : null;
|
|
116
|
+
const command = checkCommand(runner, sub, version);
|
|
117
|
+
if (manager) return { files: {}, executable: [], manager, command, repository, hooksPath: null };
|
|
118
|
+
|
|
119
|
+
const rel = path.relative(libraryDir, path.join(root, HOOKS_DIR, "pre-push")).split(path.sep).join("/");
|
|
120
|
+
return {
|
|
121
|
+
files: { [rel]: hookScript(runner, sub, version) },
|
|
122
|
+
executable: [rel],
|
|
123
|
+
manager: null,
|
|
124
|
+
command,
|
|
125
|
+
repository,
|
|
126
|
+
hooksPath: hooksPath ? "set" : "unset",
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Points git at .githooks in this clone. */
|
|
131
|
+
function setHooksPath(repository) {
|
|
132
|
+
execFileSync("git", ["config", "core.hooksPath", HOOKS_DIR], { cwd: repository, stdio: "ignore" });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { planHook, setHooksPath, imageTag, HOOKS_DIR };
|
package/src/init-questions.js
CHANGED
|
@@ -100,6 +100,14 @@ const QUESTIONS = [
|
|
|
100
100
|
},
|
|
101
101
|
{ key: "serverUrl", flag: "--server-url", text: "Production server URL", check: checkUrl, when: hasOpenapi },
|
|
102
102
|
{ key: "brokerHost", flag: "--broker-host", text: "Broker host, with its port", check: checkHost, when: hasAsyncapi, name: "a broker host" },
|
|
103
|
+
{
|
|
104
|
+
// How the pre-push hook runs check: through the library's own npm install, or the image.
|
|
105
|
+
key: "runner", flag: "--runner", text: "Run the Publisher with: npm or docker",
|
|
106
|
+
read: (text) => {
|
|
107
|
+
const runner = text.trim().toLowerCase();
|
|
108
|
+
return ["npm", "docker"].includes(runner) ? { value: runner } : { problem: `'${text}' is not npm or docker` };
|
|
109
|
+
},
|
|
110
|
+
},
|
|
103
111
|
];
|
|
104
112
|
|
|
105
113
|
/** Reads an answer to a question: the value, or what is wrong with it. */
|
package/src/init.js
CHANGED
|
@@ -29,6 +29,7 @@ const DEFAULTS = Object.freeze({
|
|
|
29
29
|
licenseUrl: "https://www.apache.org/licenses/LICENSE-2.0.html",
|
|
30
30
|
serverUrl: "https://api.example.invalid",
|
|
31
31
|
brokerHost: "kafka:9092",
|
|
32
|
+
runner: "npm",
|
|
32
33
|
});
|
|
33
34
|
|
|
34
35
|
/** A value as a YAML scalar: plain where YAML reads it back unchanged, quoted where it would not. */
|
|
@@ -362,9 +363,15 @@ function plan(targetDir, files, values, portfolio) {
|
|
|
362
363
|
* `portfolio`, given, is added the same way, as one more slot: whether the
|
|
363
364
|
* library is about to have more than one target -- and so whether this is worth
|
|
364
365
|
* asking about at all -- is decided before init is called, not here.
|
|
366
|
+
*
|
|
367
|
+
* `extra` are files beyond the scaffold, keyed as it is -- the pre-push hook, whose
|
|
368
|
+
* place depends on the repository, which init-hook.js reads -- and written by the
|
|
369
|
+
* same rules; those `executable` names are made executable whenever they are written.
|
|
365
370
|
*/
|
|
366
|
-
function init(targetDir, {
|
|
367
|
-
|
|
371
|
+
function init(targetDir, {
|
|
372
|
+
values = DEFAULTS, force = false, log = () => {}, portfolio = null, extra = {}, executable = [],
|
|
373
|
+
} = {}) {
|
|
374
|
+
const files = { ...scaffold(values), ...extra };
|
|
368
375
|
const report = { created: [], overwritten: [], identical: [], differing: [], updated: [] };
|
|
369
376
|
const updates = [];
|
|
370
377
|
|
|
@@ -386,6 +393,7 @@ function init(targetDir, { values = DEFAULTS, force = false, log = () => {}, por
|
|
|
386
393
|
const file = path.join(targetDir, rel);
|
|
387
394
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
388
395
|
fs.writeFileSync(file, files[rel]);
|
|
396
|
+
if (executable.includes(rel)) fs.chmodSync(file, 0o755);
|
|
389
397
|
(status === "missing" ? report.created : report.overwritten).push(rel);
|
|
390
398
|
}
|
|
391
399
|
|
package/src/version-guard.js
CHANGED
|
@@ -62,13 +62,13 @@ function fallbackBaseline(config, repoRoot, since) {
|
|
|
62
62
|
if (!commit) throw new GuardError(`--since ${since} is not a commit in this repository`);
|
|
63
63
|
return { ref: commit, label: `${since} (${commit.slice(0, 12)})` };
|
|
64
64
|
}
|
|
65
|
-
const branch = config.
|
|
65
|
+
const branch = config.baseBranch();
|
|
66
66
|
for (const candidate of [`origin/${branch}`, branch]) {
|
|
67
67
|
if (!tryGit(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], repoRoot)) continue;
|
|
68
68
|
const base = tryGit(["merge-base", "HEAD", candidate], repoRoot);
|
|
69
69
|
if (base) return { ref: base, label: `the merge-base with ${branch} (${base.slice(0, 12)})` };
|
|
70
70
|
}
|
|
71
|
-
return { missing: `no merge-base with ${branch}, the branch versioning.
|
|
71
|
+
return { missing: `no merge-base with ${branch}, the branch versioning.baseBranch names, to compare a target without a release tag with. ` +
|
|
72
72
|
"In CI, fetch the full history and the branch (actions/checkout with fetch-depth: 0, or GIT_DEPTH: 0 on GitLab), " +
|
|
73
73
|
"or name the ref to compare with: --since <ref>." };
|
|
74
74
|
}
|