@moku-labs/ci 1.1.3 → 1.2.2
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.md +20 -2
- package/dist/release.mjs +425 -165
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ everything else lives here, once. Not a build tool and not a framework — it ca
|
|
|
22
22
|
[Install](#install) ·
|
|
23
23
|
[How it works](#how-it-works) ·
|
|
24
24
|
[Workflows](#workflows) ·
|
|
25
|
+
[PR previews](#pr-previews) ·
|
|
25
26
|
[CLI](#cli) ·
|
|
26
27
|
[The contract](#the-contract) ·
|
|
27
28
|
[Versioning](#versioning) ·
|
|
@@ -93,7 +94,7 @@ flowchart LR
|
|
|
93
94
|
|
|
94
95
|
| Workflow | Called from | Jobs | Inputs (all optional) |
|
|
95
96
|
|---|---|---|---|
|
|
96
|
-
| [`package-ci.yml`](.github/workflows/package-ci.yml) | [`examples/package/ci.yml`](examples/package/ci.yml) | `lint` · `types` · `test` · `build` | `runs_on`, `bun_version`, `validate` |
|
|
97
|
+
| [`package-ci.yml`](.github/workflows/package-ci.yml) | [`examples/package/ci.yml`](examples/package/ci.yml) | `lint` · `types` · `test` · `build` · `preview` | `runs_on`, `bun_version`, `validate`, `preview` |
|
|
97
98
|
| [`package-release.yml`](.github/workflows/package-release.yml) | [`examples/package/publish.yml`](examples/package/publish.yml) | `check` → `release` → `package` → `publish` | `release_type`, `publish`, `runs_on`, `bun_version`, `node_version`, `artifact_name`, `validate` |
|
|
98
99
|
| [`app-deploy.yml`](.github/workflows/app-deploy.yml) | [`examples/app/ci.yml`](examples/app/ci.yml) | `validate` → `deploy` to Cloudflare | script names (`lint_script`, `build_script`, `deploy_script`, …) and two required secrets |
|
|
99
100
|
| [`self-test.yml`](.github/workflows/self-test.yml) | this repo only | `actionlint` over workflows and examples | — |
|
|
@@ -109,12 +110,29 @@ flowchart LR
|
|
|
109
110
|
> A Layer-3 app copies `examples/app/ci.yml` by hand and needs a `deploy` script. The CLI
|
|
110
111
|
> sets up packages only.
|
|
111
112
|
|
|
113
|
+
## PR previews
|
|
114
|
+
|
|
115
|
+
Every pull request commit is published to [pkg.pr.new](https://pkg.pr.new) by the `preview`
|
|
116
|
+
job. Nothing reaches npm and no token is in scope. The bot comments the install command on
|
|
117
|
+
the PR.
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
bun add https://pkg.pr.new/@moku-labs/core@42 # 42 = PR number, a commit sha works too
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Rule | Where |
|
|
124
|
+
|---|---|
|
|
125
|
+
| The [pkg.pr.new GitHub App](https://github.com/apps/pkg-pr-new) must be installed on the repo. | once per org |
|
|
126
|
+
| The package repo must be public. The consuming project may be private. | pkg.pr.new |
|
|
127
|
+
| A preview URL never reaches `main`: the `lint` job and `doctor` both refuse it. | `package-ci.yml`, `preview-deps` |
|
|
128
|
+
| `preview` is not a required check. Turn it off with `with: { preview: false }`. | caller `ci.yml` |
|
|
129
|
+
|
|
112
130
|
## CLI
|
|
113
131
|
|
|
114
132
|
| Command | When | What it does |
|
|
115
133
|
|---|---|---|
|
|
116
134
|
| `bun run release:setup` | once per project | Idempotent wizard: workflows, script contract, first publish, first tag, trusted publisher, branch ruleset, then `doctor`. `--dry-run` prints every action and changes nothing. |
|
|
117
|
-
| `bun run release:doctor` | any time | Changes nothing in the project; it only runs `git fetch --tags` first.
|
|
135
|
+
| `bun run release:doctor` | any time | Changes nothing in the project; it only runs `git fetch --tags` first. Twelve checks, one line each, and the exact `fix:` command for every red line. `--json` for machines. |
|
|
118
136
|
| `bun run release <patch\|minor\|major\|prerelease>` | each release | Refuses unless the tree is clean and `HEAD == origin/main`. Dispatches `publish.yml`, watches the run, verifies the version and dist-tag on npm. |
|
|
119
137
|
|
|
120
138
|
The scripts are plain aliases of the `moku-release` bin. Internals and the list of checks:
|
package/dist/release.mjs
CHANGED
|
@@ -644,6 +644,61 @@ function parseJsonArray(stdout) {
|
|
|
644
644
|
function hasMainBranchRuleset(stdout) {
|
|
645
645
|
return parseJsonArray(stdout).some((ruleset) => ruleset.target === BRANCH_TARGET && ruleset.enforcement !== "disabled");
|
|
646
646
|
}
|
|
647
|
+
/** Rule type GitHub uses for the required status checks of a ruleset. */
|
|
648
|
+
const REQUIRED_CHECKS_RULE = "required_status_checks";
|
|
649
|
+
/**
|
|
650
|
+
* Parse the `rules` of one full ruleset, tolerating error text and non-object payloads.
|
|
651
|
+
*
|
|
652
|
+
* @param stdout - Output of `gh api repos/{owner}/{repo}/rulesets/{id}`, or a ruleset template.
|
|
653
|
+
* @returns The rules, or an empty array when the payload has none.
|
|
654
|
+
* @example
|
|
655
|
+
* parseRules('{"rules":[{"type":"deletion"}]}');
|
|
656
|
+
*/
|
|
657
|
+
function parseRules(stdout) {
|
|
658
|
+
try {
|
|
659
|
+
const parsed = JSON.parse(stdout);
|
|
660
|
+
return Array.isArray(parsed?.rules) ? parsed.rules : [];
|
|
661
|
+
} catch {
|
|
662
|
+
return [];
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* The ids of every active BRANCH ruleset, in listing order.
|
|
667
|
+
*
|
|
668
|
+
* @param stdout - Output of `gh api repos/{owner}/{repo}/rulesets`.
|
|
669
|
+
* @returns The ruleset ids; a ruleset without an id is left out.
|
|
670
|
+
* @example
|
|
671
|
+
* activeBranchRulesetIds('[{"id":7,"target":"branch","enforcement":"active"}]'); // [7]
|
|
672
|
+
*/
|
|
673
|
+
function activeBranchRulesetIds(stdout) {
|
|
674
|
+
return parseJsonArray(stdout).filter((ruleset) => ruleset.target === BRANCH_TARGET && ruleset.enforcement !== "disabled").flatMap((ruleset) => ruleset.id === void 0 ? [] : [ruleset.id]);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* The status check contexts one full ruleset requires.
|
|
678
|
+
*
|
|
679
|
+
* @param stdout - One full ruleset as JSON.
|
|
680
|
+
* @returns The required contexts, e.g. `["ci / lint"]`.
|
|
681
|
+
* @example
|
|
682
|
+
* requiredCheckContexts(renderMainRuleset()); // ["ci / lint", "ci / types", …]
|
|
683
|
+
*/
|
|
684
|
+
function requiredCheckContexts(stdout) {
|
|
685
|
+
return parseRules(stdout).filter((rule) => rule.type === REQUIRED_CHECKS_RULE).flatMap((rule) => rule.parameters?.required_status_checks ?? []).flatMap((check) => check.context === void 0 ? [] : [check.context]);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* The body that moves an existing ruleset onto the central required checks. Every other
|
|
689
|
+
* rule of the existing ruleset is kept as it is.
|
|
690
|
+
*
|
|
691
|
+
* @param existing - The full existing ruleset as JSON.
|
|
692
|
+
* @param template - The central ruleset template as JSON.
|
|
693
|
+
* @returns The `PUT` body: the existing rules with the template's required checks.
|
|
694
|
+
* @example
|
|
695
|
+
* const body = withCentralRequiredChecks(existingJson, renderMainRuleset());
|
|
696
|
+
*/
|
|
697
|
+
function withCentralRequiredChecks(existing, template) {
|
|
698
|
+
const kept = parseRules(existing).filter((rule) => rule.type !== REQUIRED_CHECKS_RULE);
|
|
699
|
+
const central = parseRules(template).filter((rule) => rule.type === REQUIRED_CHECKS_RULE);
|
|
700
|
+
return JSON.stringify({ rules: [...kept, ...central] });
|
|
701
|
+
}
|
|
647
702
|
/**
|
|
648
703
|
* The id of the newest workflow run from `gh run list --json databaseId`.
|
|
649
704
|
*
|
|
@@ -719,16 +774,34 @@ function parseManifest(text) {
|
|
|
719
774
|
async function readManifest(files) {
|
|
720
775
|
return parseManifest(await files.read(MANIFEST_PATH));
|
|
721
776
|
}
|
|
777
|
+
/** Any character outside printable ASCII and the JSON whitespace. */
|
|
778
|
+
const NON_ASCII = /[^\t\n\r -~]/;
|
|
779
|
+
/**
|
|
780
|
+
* Whether a text holds only ASCII characters, i.e. every other character is `\uXXXX`-escaped.
|
|
781
|
+
*
|
|
782
|
+
* @param text - The file contents to test.
|
|
783
|
+
* @returns `true` when no raw non-ASCII character is present.
|
|
784
|
+
* @example
|
|
785
|
+
* isAsciiOnly('{"description":"a \\u2014 b"}'); // true
|
|
786
|
+
*/
|
|
787
|
+
function isAsciiOnly(text) {
|
|
788
|
+
return !NON_ASCII.test(text);
|
|
789
|
+
}
|
|
722
790
|
/**
|
|
723
791
|
* Render a manifest the way npm itself writes one: two-space JSON with a trailing newline.
|
|
792
|
+
* A source file that was ASCII-only stays ASCII-only, so an escaped dash the package chose
|
|
793
|
+
* does not show up as a changed line in the diff.
|
|
724
794
|
*
|
|
725
795
|
* @param manifest - The manifest to serialize.
|
|
796
|
+
* @param source - The file contents the manifest was read from, when there were any.
|
|
726
797
|
* @returns The file contents to write.
|
|
727
798
|
* @example
|
|
728
|
-
* await files.write("package.json", formatManifest(manifest));
|
|
799
|
+
* await files.write("package.json", formatManifest(manifest, source));
|
|
729
800
|
*/
|
|
730
|
-
function formatManifest(manifest) {
|
|
731
|
-
|
|
801
|
+
function formatManifest(manifest, source) {
|
|
802
|
+
const text = `${JSON.stringify(manifest, void 0, 2)}\n`;
|
|
803
|
+
if (source === void 0 || !isAsciiOnly(source)) return text;
|
|
804
|
+
return text.replaceAll(new RegExp(NON_ASCII, "g"), (character) => String.raw`\u${character.codePointAt(0)?.toString(16).padStart(4, "0")}`);
|
|
732
805
|
}
|
|
733
806
|
/**
|
|
734
807
|
* The repository URL a manifest declares, in either the object or shorthand string form.
|
|
@@ -894,6 +967,143 @@ function skip(detail) {
|
|
|
894
967
|
};
|
|
895
968
|
}
|
|
896
969
|
//#endregion
|
|
970
|
+
//#region src/templates/central.ts
|
|
971
|
+
/**
|
|
972
|
+
* @file `moku-release` — read a central definition from this package's own files.
|
|
973
|
+
*
|
|
974
|
+
* The CLI ships in the same package as `examples/` and `rulesets/`, so a template is read
|
|
975
|
+
* from disk instead of being copied into a string. One file, one version, nothing to drift.
|
|
976
|
+
*/
|
|
977
|
+
/** A file that only exists at the package root, used to find it from `src/` and from `dist/`. */
|
|
978
|
+
const ROOT_MARKER = path.join("rulesets", "main.json");
|
|
979
|
+
/**
|
|
980
|
+
* The package root: the closest parent directory that holds `rulesets/main.json`.
|
|
981
|
+
*
|
|
982
|
+
* @returns The absolute path of the package root.
|
|
983
|
+
* @throws {Error} When no parent directory holds the marker, which means a broken install.
|
|
984
|
+
* @example
|
|
985
|
+
* path.join(packageRoot(), "examples");
|
|
986
|
+
*/
|
|
987
|
+
function packageRoot() {
|
|
988
|
+
let directory = path.dirname(fileURLToPath(import.meta.url));
|
|
989
|
+
while (!existsSync(path.join(directory, ROOT_MARKER))) {
|
|
990
|
+
const parent = path.dirname(directory);
|
|
991
|
+
if (parent === directory) throw new Error(`moku-release: ${ROOT_MARKER} not found above ${import.meta.url}`);
|
|
992
|
+
directory = parent;
|
|
993
|
+
}
|
|
994
|
+
return directory;
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Read one central definition, for example `examples/package/ci.yml`.
|
|
998
|
+
*
|
|
999
|
+
* @param relativePath - The path inside the package.
|
|
1000
|
+
* @returns The file contents.
|
|
1001
|
+
* @example
|
|
1002
|
+
* readCentral("rulesets/main.json");
|
|
1003
|
+
*/
|
|
1004
|
+
function readCentral(relativePath) {
|
|
1005
|
+
return readFileSync(path.join(packageRoot(), relativePath), "utf8");
|
|
1006
|
+
}
|
|
1007
|
+
//#endregion
|
|
1008
|
+
//#region src/templates/ci.ts
|
|
1009
|
+
/**
|
|
1010
|
+
* @file `moku-release` — the `.github/workflows/ci.yml` template.
|
|
1011
|
+
*
|
|
1012
|
+
* Read from this package's own `examples/package/ci.yml`. It is a thin caller: the
|
|
1013
|
+
* whole check matrix lives once in the central reusable workflow, so a pipeline change is
|
|
1014
|
+
* one PR there instead of one per repository. Two details are load-bearing and must not be
|
|
1015
|
+
* "tidied": the caller job id is `ci` (GitHub prefixes the reused jobs with it, so the
|
|
1016
|
+
* required checks are `ci / lint`, `ci / types`, `ci / test`, `ci / build`), and there is
|
|
1017
|
+
* deliberately NO concurrency block — the called workflow already groups by
|
|
1018
|
+
* `github.workflow`, and a caller group with the same value deadlocks against its own child.
|
|
1019
|
+
*/
|
|
1020
|
+
/** Repo-relative path this template is written to. */
|
|
1021
|
+
const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
|
|
1022
|
+
/** The reusable workflow ref `doctor` recognizes a migrated `ci.yml` by. */
|
|
1023
|
+
const CI_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-ci.yml@v1";
|
|
1024
|
+
/** The `ci.yml` body, read from `examples/package/ci.yml`. */
|
|
1025
|
+
const CI_WORKFLOW = readCentral("examples/package/ci.yml");
|
|
1026
|
+
//#endregion
|
|
1027
|
+
//#region src/templates/publish.ts
|
|
1028
|
+
/**
|
|
1029
|
+
* @file `moku-release` — the `.github/workflows/publish.yml` template.
|
|
1030
|
+
*
|
|
1031
|
+
* Read from this package's own `examples/package/publish.yml`. The FILE NAME is the
|
|
1032
|
+
* contract: npm Trusted Publishing validates the CALLING workflow's filename, so this file
|
|
1033
|
+
* must stay `publish.yml` even though the publish itself happens inside the central
|
|
1034
|
+
* reusable workflow. There is no `NPM_TOKEN` anywhere — `id-token: write` is the whole
|
|
1035
|
+
* credential.
|
|
1036
|
+
*/
|
|
1037
|
+
/** Repo-relative path this template is written to. Registered with npm — never rename it. */
|
|
1038
|
+
const PUBLISH_WORKFLOW_PATH = ".github/workflows/publish.yml";
|
|
1039
|
+
/**
|
|
1040
|
+
* The reusable workflow ref `doctor` recognizes a migrated `publish.yml` by. The
|
|
1041
|
+
* `publish.local-publish.yml` fallback variant calls the same ref, so it is recognized too.
|
|
1042
|
+
*/
|
|
1043
|
+
const PUBLISH_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-release.yml@v1";
|
|
1044
|
+
/** The `publish.yml` body, read from `examples/package/publish.yml`. */
|
|
1045
|
+
const PUBLISH_WORKFLOW = readCentral("examples/package/publish.yml");
|
|
1046
|
+
//#endregion
|
|
1047
|
+
//#region src/templates/ruleset.ts
|
|
1048
|
+
/**
|
|
1049
|
+
* @file `moku-release` — the branch ruleset `setup` applies to `main`.
|
|
1050
|
+
*
|
|
1051
|
+
* Read from this package's own `rulesets/main.json`: PR-only main, no deletion, no
|
|
1052
|
+
* force-push, and the four required checks the thin `ci.yml` produces (`ci / lint`,
|
|
1053
|
+
* `ci / types`, `ci / test`, `ci / build`). Tags are deliberately NOT restricted — the
|
|
1054
|
+
* release workflow pushes `v*` tags, and a tag ruleset would block every release.
|
|
1055
|
+
*/
|
|
1056
|
+
/** The ruleset payload for `gh api repos/{owner}/{repo}/rulesets --input -`, read from `rulesets/main.json`. */
|
|
1057
|
+
const MAIN_RULESET_JSON = readCentral("rulesets/main.json");
|
|
1058
|
+
//#endregion
|
|
1059
|
+
//#region src/lib/templates.ts
|
|
1060
|
+
/**
|
|
1061
|
+
* @file `moku-release` — the workflow template surface: what to write, where, and how to
|
|
1062
|
+
* tell a migrated workflow from a hand-written legacy one.
|
|
1063
|
+
*
|
|
1064
|
+
* `setup` writes what {@link workflowTemplates} lists; `doctor` reads the same list back
|
|
1065
|
+
* and asks {@link isThinWorkflow} whether the file on disk still calls the pinned central
|
|
1066
|
+
* workflow. One list, both directions — a template can never drift from its check.
|
|
1067
|
+
*
|
|
1068
|
+
* The bodies are read from this package's own `examples/package/*.yml` and
|
|
1069
|
+
* `rulesets/main.json`: this CLI distributes the central definitions, it does not copy them.
|
|
1070
|
+
*/
|
|
1071
|
+
/** Both generated workflows, in the order `setup` writes and `doctor` reports them. */
|
|
1072
|
+
const workflowTemplates = [{
|
|
1073
|
+
path: CI_WORKFLOW_PATH,
|
|
1074
|
+
content: CI_WORKFLOW,
|
|
1075
|
+
ref: CI_WORKFLOW_REF
|
|
1076
|
+
}, {
|
|
1077
|
+
path: PUBLISH_WORKFLOW_PATH,
|
|
1078
|
+
content: PUBLISH_WORKFLOW,
|
|
1079
|
+
ref: PUBLISH_WORKFLOW_REF
|
|
1080
|
+
}];
|
|
1081
|
+
/**
|
|
1082
|
+
* Whether a workflow file on disk is the thin caller — i.e. it delegates to the pinned
|
|
1083
|
+
* central reusable workflow. A file that does not is a legacy hand-written pipeline.
|
|
1084
|
+
* Matching on the `@v1` ref rather than on the whole body is deliberate: the
|
|
1085
|
+
* `publish.local-publish.yml` fallback variant calls the same ref and must also pass.
|
|
1086
|
+
*
|
|
1087
|
+
* @param content - The file contents read from disk.
|
|
1088
|
+
* @param template - The template the file is expected to match.
|
|
1089
|
+
* @returns `true` when the file calls the pinned reusable workflow.
|
|
1090
|
+
* @example
|
|
1091
|
+
* isThinWorkflow(onDisk, workflowTemplates[0]);
|
|
1092
|
+
*/
|
|
1093
|
+
function isThinWorkflow(content, template) {
|
|
1094
|
+
return content.includes(template.ref);
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* The branch-ruleset payload `gh api … --input -` reads — the central definition verbatim.
|
|
1098
|
+
*
|
|
1099
|
+
* @returns The ruleset JSON.
|
|
1100
|
+
* @example
|
|
1101
|
+
* const body = renderMainRuleset();
|
|
1102
|
+
*/
|
|
1103
|
+
function renderMainRuleset() {
|
|
1104
|
+
return MAIN_RULESET_JSON;
|
|
1105
|
+
}
|
|
1106
|
+
//#endregion
|
|
897
1107
|
//#region src/checks/branch-ruleset.ts
|
|
898
1108
|
/**
|
|
899
1109
|
* @file `moku-release` — check: a branch ruleset protects the default branch.
|
|
@@ -901,15 +1111,40 @@ function skip(detail) {
|
|
|
901
1111
|
* Advisory. The release pipeline works without it; what it buys is that main can only
|
|
902
1112
|
* move through a PR, so a release always describes a reviewed state.
|
|
903
1113
|
*/
|
|
904
|
-
/**
|
|
1114
|
+
/**
|
|
1115
|
+
* Find the first active branch ruleset whose required checks are not the central ones. A
|
|
1116
|
+
* legacy ruleset requires `lint`; the thin caller reports `ci / lint`, so no PR can merge.
|
|
1117
|
+
*
|
|
1118
|
+
* @param ctx - The injected ports.
|
|
1119
|
+
* @param ownerRepo - The `owner/repo` slug.
|
|
1120
|
+
* @param listing - Output of `gh api repos/{owner}/{repo}/rulesets`.
|
|
1121
|
+
* @returns The stale ruleset, or `undefined` when every ruleset is on the central checks.
|
|
1122
|
+
* @example
|
|
1123
|
+
* const stale = await findStaleRuleset(ctx, "moku-labs/system", listing.stdout);
|
|
1124
|
+
*/
|
|
1125
|
+
async function findStaleRuleset(ctx, ownerRepo, listing) {
|
|
1126
|
+
const central = new Set(requiredCheckContexts(renderMainRuleset()));
|
|
1127
|
+
for (const id of activeBranchRulesetIds(listing)) {
|
|
1128
|
+
const detail = await ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets/${id}`]);
|
|
1129
|
+
if (detail.code !== 0) continue;
|
|
1130
|
+
const legacy = requiredCheckContexts(detail.stdout).filter((context) => !central.has(context));
|
|
1131
|
+
if (legacy.length > 0) return {
|
|
1132
|
+
id,
|
|
1133
|
+
body: detail.stdout,
|
|
1134
|
+
legacy
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
/** Verifies `gh api repos/{owner}/{repo}/rulesets` lists an active, up-to-date branch ruleset. */
|
|
905
1139
|
const branchRulesetCheck = {
|
|
906
1140
|
id: "branch-ruleset",
|
|
907
1141
|
title: "branch ruleset on main",
|
|
908
1142
|
/**
|
|
909
|
-
* List the repository's rulesets
|
|
1143
|
+
* List the repository's rulesets, look for an active branch-targeting one, and make sure
|
|
1144
|
+
* it does not require legacy check names.
|
|
910
1145
|
*
|
|
911
1146
|
* @param ctx - The injected ports.
|
|
912
|
-
* @returns Pass when one exists, warn
|
|
1147
|
+
* @returns Pass when one exists on the central checks, warn otherwise.
|
|
913
1148
|
* @example
|
|
914
1149
|
* await branchRulesetCheck.run(ctx);
|
|
915
1150
|
*/
|
|
@@ -921,6 +1156,8 @@ const branchRulesetCheck = {
|
|
|
921
1156
|
const rulesets = await ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets`]);
|
|
922
1157
|
if (rulesets.code !== 0) return skip("`gh api` could not read the rulesets");
|
|
923
1158
|
if (!hasMainBranchRuleset(rulesets.stdout)) return warn("main has no branch ruleset", "moku-release setup");
|
|
1159
|
+
const stale = await findStaleRuleset(ctx, ownerRepo, rulesets.stdout);
|
|
1160
|
+
if (stale !== void 0) return warn(`ruleset requires legacy checks: ${stale.legacy.join(", ")}`, "moku-release setup");
|
|
924
1161
|
return pass("active branch ruleset present");
|
|
925
1162
|
}
|
|
926
1163
|
};
|
|
@@ -1216,6 +1453,59 @@ const packageContractCheck = {
|
|
|
1216
1453
|
}
|
|
1217
1454
|
};
|
|
1218
1455
|
//#endregion
|
|
1456
|
+
//#region src/checks/preview-deps.ts
|
|
1457
|
+
/**
|
|
1458
|
+
* @file `moku-release` — check: no dependency points at a pkg.pr.new preview build.
|
|
1459
|
+
*
|
|
1460
|
+
* A preview URL is for testing an unreleased upstream change. It has no provenance and dies
|
|
1461
|
+
* with its pull request, so a release must never be cut with one in the manifest. The `lint`
|
|
1462
|
+
* job of package-ci.yml refuses the same thing on every PR; this is the local view of it.
|
|
1463
|
+
*/
|
|
1464
|
+
/** Host every pkg.pr.new install URL contains. */
|
|
1465
|
+
const PREVIEW_HOST = "pkg.pr.new";
|
|
1466
|
+
/** The manifest tables a preview URL can sit in. */
|
|
1467
|
+
const DEPENDENCY_FIELDS = [
|
|
1468
|
+
"dependencies",
|
|
1469
|
+
"devDependencies",
|
|
1470
|
+
"peerDependencies",
|
|
1471
|
+
"optionalDependencies"
|
|
1472
|
+
];
|
|
1473
|
+
/**
|
|
1474
|
+
* Names of every dependency whose range is a pkg.pr.new URL.
|
|
1475
|
+
*
|
|
1476
|
+
* @param manifest - The manifest to scan.
|
|
1477
|
+
* @returns The dependency names, in manifest order.
|
|
1478
|
+
* @example
|
|
1479
|
+
* previewDependencies({ dependencies: { "@moku-labs/core": "https://pkg.pr.new/@moku-labs/core@42" } });
|
|
1480
|
+
*/
|
|
1481
|
+
function previewDependencies(manifest) {
|
|
1482
|
+
return DEPENDENCY_FIELDS.flatMap((field) => {
|
|
1483
|
+
const table = manifest[field];
|
|
1484
|
+
if (typeof table !== "object" || table === null) return [];
|
|
1485
|
+
return Object.entries(table).filter(([, range]) => typeof range === "string" && range.includes(PREVIEW_HOST)).map(([name]) => name);
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
/** Verifies no dependency is installed from a pkg.pr.new preview. */
|
|
1489
|
+
const previewDepsCheck = {
|
|
1490
|
+
id: "preview-deps",
|
|
1491
|
+
title: "no pkg.pr.new preview dependencies",
|
|
1492
|
+
/**
|
|
1493
|
+
* Scan the dependency tables for preview URLs.
|
|
1494
|
+
*
|
|
1495
|
+
* @param ctx - The injected ports.
|
|
1496
|
+
* @returns Pass when none is found, otherwise every preview dependency by name.
|
|
1497
|
+
* @example
|
|
1498
|
+
* await previewDepsCheck.run(ctx);
|
|
1499
|
+
*/
|
|
1500
|
+
async run(ctx) {
|
|
1501
|
+
const manifest = await readManifest(ctx.files);
|
|
1502
|
+
if (!manifest) return skip("package.json is missing or malformed");
|
|
1503
|
+
const names = previewDependencies(manifest);
|
|
1504
|
+
if (names.length > 0) return fail(`preview build of ${names.join(", ")}`, `release upstream, then: bun add ${names.join(" ")}`);
|
|
1505
|
+
return pass("every dependency comes from the registry");
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
//#endregion
|
|
1219
1509
|
//#region src/checks/repository-url.ts
|
|
1220
1510
|
/**
|
|
1221
1511
|
* @file `moku-release` — check: `repository.url` points at the real origin.
|
|
@@ -1296,64 +1586,6 @@ const tagSyncCheck = {
|
|
|
1296
1586
|
}
|
|
1297
1587
|
};
|
|
1298
1588
|
//#endregion
|
|
1299
|
-
//#region src/templates/central.ts
|
|
1300
|
-
/**
|
|
1301
|
-
* @file `moku-release` — read a central definition from this package's own files.
|
|
1302
|
-
*
|
|
1303
|
-
* The CLI ships in the same package as `examples/` and `rulesets/`, so a template is read
|
|
1304
|
-
* from disk instead of being copied into a string. One file, one version, nothing to drift.
|
|
1305
|
-
*/
|
|
1306
|
-
/** A file that only exists at the package root, used to find it from `src/` and from `dist/`. */
|
|
1307
|
-
const ROOT_MARKER = path.join("rulesets", "main.json");
|
|
1308
|
-
/**
|
|
1309
|
-
* The package root: the closest parent directory that holds `rulesets/main.json`.
|
|
1310
|
-
*
|
|
1311
|
-
* @returns The absolute path of the package root.
|
|
1312
|
-
* @throws {Error} When no parent directory holds the marker, which means a broken install.
|
|
1313
|
-
* @example
|
|
1314
|
-
* path.join(packageRoot(), "examples");
|
|
1315
|
-
*/
|
|
1316
|
-
function packageRoot() {
|
|
1317
|
-
let directory = path.dirname(fileURLToPath(import.meta.url));
|
|
1318
|
-
while (!existsSync(path.join(directory, ROOT_MARKER))) {
|
|
1319
|
-
const parent = path.dirname(directory);
|
|
1320
|
-
if (parent === directory) throw new Error(`moku-release: ${ROOT_MARKER} not found above ${import.meta.url}`);
|
|
1321
|
-
directory = parent;
|
|
1322
|
-
}
|
|
1323
|
-
return directory;
|
|
1324
|
-
}
|
|
1325
|
-
/**
|
|
1326
|
-
* Read one central definition, for example `examples/package/ci.yml`.
|
|
1327
|
-
*
|
|
1328
|
-
* @param relativePath - The path inside the package.
|
|
1329
|
-
* @returns The file contents.
|
|
1330
|
-
* @example
|
|
1331
|
-
* readCentral("rulesets/main.json");
|
|
1332
|
-
*/
|
|
1333
|
-
function readCentral(relativePath) {
|
|
1334
|
-
return readFileSync(path.join(packageRoot(), relativePath), "utf8");
|
|
1335
|
-
}
|
|
1336
|
-
//#endregion
|
|
1337
|
-
//#region src/templates/publish.ts
|
|
1338
|
-
/**
|
|
1339
|
-
* @file `moku-release` — the `.github/workflows/publish.yml` template.
|
|
1340
|
-
*
|
|
1341
|
-
* Read from this package's own `examples/package/publish.yml`. The FILE NAME is the
|
|
1342
|
-
* contract: npm Trusted Publishing validates the CALLING workflow's filename, so this file
|
|
1343
|
-
* must stay `publish.yml` even though the publish itself happens inside the central
|
|
1344
|
-
* reusable workflow. There is no `NPM_TOKEN` anywhere — `id-token: write` is the whole
|
|
1345
|
-
* credential.
|
|
1346
|
-
*/
|
|
1347
|
-
/** Repo-relative path this template is written to. Registered with npm — never rename it. */
|
|
1348
|
-
const PUBLISH_WORKFLOW_PATH = ".github/workflows/publish.yml";
|
|
1349
|
-
/**
|
|
1350
|
-
* The reusable workflow ref `doctor` recognizes a migrated `publish.yml` by. The
|
|
1351
|
-
* `publish.local-publish.yml` fallback variant calls the same ref, so it is recognized too.
|
|
1352
|
-
*/
|
|
1353
|
-
const PUBLISH_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-release.yml@v1";
|
|
1354
|
-
/** The `publish.yml` body, read from `examples/package/publish.yml`. */
|
|
1355
|
-
const PUBLISH_WORKFLOW = readCentral("examples/package/publish.yml");
|
|
1356
|
-
//#endregion
|
|
1357
1589
|
//#region src/checks/trusted-publisher.ts
|
|
1358
1590
|
/**
|
|
1359
1591
|
* @file `moku-release` — check: npm knows this repo's `publish.yml` as a trusted publisher.
|
|
@@ -1387,6 +1619,18 @@ function isUnauthorized(output) {
|
|
|
1387
1619
|
return /E401|ENEEDAUTH|401 Unauthorized/i.test(output);
|
|
1388
1620
|
}
|
|
1389
1621
|
/**
|
|
1622
|
+
* Whether npm refused the listing because the account needs a one-time password. Output is
|
|
1623
|
+
* captured here, so npm cannot prompt: the answer is unknown, not "missing".
|
|
1624
|
+
*
|
|
1625
|
+
* @param output - The combined stdout/stderr npm produced.
|
|
1626
|
+
* @returns `true` when npm asked for an OTP.
|
|
1627
|
+
* @example
|
|
1628
|
+
* isOtpRequired("npm error code EOTP");
|
|
1629
|
+
*/
|
|
1630
|
+
function isOtpRequired(output) {
|
|
1631
|
+
return /EOTP|one-time password/i.test(output);
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1390
1634
|
* The exact registration command for this package and repository.
|
|
1391
1635
|
*
|
|
1392
1636
|
* @param name - The package name.
|
|
@@ -1396,7 +1640,7 @@ function isUnauthorized(output) {
|
|
|
1396
1640
|
* trustCommand("@moku-labs/common", "moku-labs/common");
|
|
1397
1641
|
*/
|
|
1398
1642
|
function trustCommand(name, ownerRepo) {
|
|
1399
|
-
return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$1} --repo ${ownerRepo} --yes`;
|
|
1643
|
+
return `npm trust github ${name} --file ${PUBLISH_WORKFLOW_FILE$1} --repo ${ownerRepo} --allow-publish --yes`;
|
|
1400
1644
|
}
|
|
1401
1645
|
/** Verifies a trusted publisher is registered for the package. */
|
|
1402
1646
|
const trustedPublisherCheck = {
|
|
@@ -1423,90 +1667,12 @@ const trustedPublisherCheck = {
|
|
|
1423
1667
|
]);
|
|
1424
1668
|
if (isUnknownCommand(`${listing.stdout}${listing.stderr}`)) return warn("this npm has no `trust` command", "upgrade npm");
|
|
1425
1669
|
if (isUnauthorized(`${listing.stdout}${listing.stderr}`)) return skip("cannot list trusted publishers without `npm login`");
|
|
1670
|
+
if (isOtpRequired(`${listing.stdout}${listing.stderr}`)) return warn("npm asks for an OTP, cannot verify from here", `npm trust list ${manifest.name}`);
|
|
1426
1671
|
if (listing.code !== 0 || !listing.stdout.includes(PUBLISH_WORKFLOW_FILE$1)) return fail("no trusted publisher registered", trustCommand(manifest.name, ownerRepo));
|
|
1427
1672
|
return pass(`${ownerRepo} · ${PUBLISH_WORKFLOW_FILE$1}`);
|
|
1428
1673
|
}
|
|
1429
1674
|
};
|
|
1430
1675
|
//#endregion
|
|
1431
|
-
//#region src/templates/ci.ts
|
|
1432
|
-
/**
|
|
1433
|
-
* @file `moku-release` — the `.github/workflows/ci.yml` template.
|
|
1434
|
-
*
|
|
1435
|
-
* Read from this package's own `examples/package/ci.yml`. It is a thin caller: the
|
|
1436
|
-
* whole check matrix lives once in the central reusable workflow, so a pipeline change is
|
|
1437
|
-
* one PR there instead of one per repository. Two details are load-bearing and must not be
|
|
1438
|
-
* "tidied": the caller job id is `ci` (GitHub prefixes the reused jobs with it, so the
|
|
1439
|
-
* required checks are `ci / lint`, `ci / types`, `ci / test`, `ci / build`), and there is
|
|
1440
|
-
* deliberately NO concurrency block — the called workflow already groups by
|
|
1441
|
-
* `github.workflow`, and a caller group with the same value deadlocks against its own child.
|
|
1442
|
-
*/
|
|
1443
|
-
/** Repo-relative path this template is written to. */
|
|
1444
|
-
const CI_WORKFLOW_PATH = ".github/workflows/ci.yml";
|
|
1445
|
-
/** The reusable workflow ref `doctor` recognizes a migrated `ci.yml` by. */
|
|
1446
|
-
const CI_WORKFLOW_REF = "moku-labs/ci/.github/workflows/package-ci.yml@v1";
|
|
1447
|
-
/** The `ci.yml` body, read from `examples/package/ci.yml`. */
|
|
1448
|
-
const CI_WORKFLOW = readCentral("examples/package/ci.yml");
|
|
1449
|
-
//#endregion
|
|
1450
|
-
//#region src/templates/ruleset.ts
|
|
1451
|
-
/**
|
|
1452
|
-
* @file `moku-release` — the branch ruleset `setup` applies to `main`.
|
|
1453
|
-
*
|
|
1454
|
-
* Read from this package's own `rulesets/main.json`: PR-only main, no deletion, no
|
|
1455
|
-
* force-push, and the four required checks the thin `ci.yml` produces (`ci / lint`,
|
|
1456
|
-
* `ci / types`, `ci / test`, `ci / build`). Tags are deliberately NOT restricted — the
|
|
1457
|
-
* release workflow pushes `v*` tags, and a tag ruleset would block every release.
|
|
1458
|
-
*/
|
|
1459
|
-
/** The ruleset payload for `gh api repos/{owner}/{repo}/rulesets --input -`, read from `rulesets/main.json`. */
|
|
1460
|
-
const MAIN_RULESET_JSON = readCentral("rulesets/main.json");
|
|
1461
|
-
//#endregion
|
|
1462
|
-
//#region src/lib/templates.ts
|
|
1463
|
-
/**
|
|
1464
|
-
* @file `moku-release` — the workflow template surface: what to write, where, and how to
|
|
1465
|
-
* tell a migrated workflow from a hand-written legacy one.
|
|
1466
|
-
*
|
|
1467
|
-
* `setup` writes what {@link workflowTemplates} lists; `doctor` reads the same list back
|
|
1468
|
-
* and asks {@link isThinWorkflow} whether the file on disk still calls the pinned central
|
|
1469
|
-
* workflow. One list, both directions — a template can never drift from its check.
|
|
1470
|
-
*
|
|
1471
|
-
* The bodies are read from this package's own `examples/package/*.yml` and
|
|
1472
|
-
* `rulesets/main.json`: this CLI distributes the central definitions, it does not copy them.
|
|
1473
|
-
*/
|
|
1474
|
-
/** Both generated workflows, in the order `setup` writes and `doctor` reports them. */
|
|
1475
|
-
const workflowTemplates = [{
|
|
1476
|
-
path: CI_WORKFLOW_PATH,
|
|
1477
|
-
content: CI_WORKFLOW,
|
|
1478
|
-
ref: CI_WORKFLOW_REF
|
|
1479
|
-
}, {
|
|
1480
|
-
path: PUBLISH_WORKFLOW_PATH,
|
|
1481
|
-
content: PUBLISH_WORKFLOW,
|
|
1482
|
-
ref: PUBLISH_WORKFLOW_REF
|
|
1483
|
-
}];
|
|
1484
|
-
/**
|
|
1485
|
-
* Whether a workflow file on disk is the thin caller — i.e. it delegates to the pinned
|
|
1486
|
-
* central reusable workflow. A file that does not is a legacy hand-written pipeline.
|
|
1487
|
-
* Matching on the `@v1` ref rather than on the whole body is deliberate: the
|
|
1488
|
-
* `publish.local-publish.yml` fallback variant calls the same ref and must also pass.
|
|
1489
|
-
*
|
|
1490
|
-
* @param content - The file contents read from disk.
|
|
1491
|
-
* @param template - The template the file is expected to match.
|
|
1492
|
-
* @returns `true` when the file calls the pinned reusable workflow.
|
|
1493
|
-
* @example
|
|
1494
|
-
* isThinWorkflow(onDisk, workflowTemplates[0]);
|
|
1495
|
-
*/
|
|
1496
|
-
function isThinWorkflow(content, template) {
|
|
1497
|
-
return content.includes(template.ref);
|
|
1498
|
-
}
|
|
1499
|
-
/**
|
|
1500
|
-
* The branch-ruleset payload `gh api … --input -` reads — the central definition verbatim.
|
|
1501
|
-
*
|
|
1502
|
-
* @returns The ruleset JSON.
|
|
1503
|
-
* @example
|
|
1504
|
-
* const body = renderMainRuleset();
|
|
1505
|
-
*/
|
|
1506
|
-
function renderMainRuleset() {
|
|
1507
|
-
return MAIN_RULESET_JSON;
|
|
1508
|
-
}
|
|
1509
|
-
//#endregion
|
|
1510
1676
|
//#region src/checks/workflows.ts
|
|
1511
1677
|
/**
|
|
1512
1678
|
* @file `moku-release` — check: both workflows exist and are the thin callers.
|
|
@@ -1576,6 +1742,7 @@ const allChecks = [
|
|
|
1576
1742
|
npmAuthCheck,
|
|
1577
1743
|
npmVersionCheck,
|
|
1578
1744
|
packageContractCheck,
|
|
1745
|
+
previewDepsCheck,
|
|
1579
1746
|
repositoryUrlCheck,
|
|
1580
1747
|
workflowsCheck,
|
|
1581
1748
|
npmPackageCheck,
|
|
@@ -1948,9 +2115,55 @@ async function ensurePrerequisites(setup) {
|
|
|
1948
2115
|
return true;
|
|
1949
2116
|
}
|
|
1950
2117
|
/**
|
|
2118
|
+
* Whether git already holds this exact file: tracked, and with no uncommitted change.
|
|
2119
|
+
*
|
|
2120
|
+
* @param setup - The wizard state.
|
|
2121
|
+
* @param path - Repo-relative path of the file.
|
|
2122
|
+
* @returns `true` when `git checkout -- <path>` would bring the current content back.
|
|
2123
|
+
* @example
|
|
2124
|
+
* await isCommittedUnchanged(setup, ".github/workflows/ci.yml");
|
|
2125
|
+
*/
|
|
2126
|
+
async function isCommittedUnchanged(setup, path) {
|
|
2127
|
+
if ((await setup.ctx.exec.capture("git", [
|
|
2128
|
+
"ls-files",
|
|
2129
|
+
"--error-unmatch",
|
|
2130
|
+
path
|
|
2131
|
+
])).code !== 0) return false;
|
|
2132
|
+
const status = await setup.ctx.exec.capture("git", [
|
|
2133
|
+
"status",
|
|
2134
|
+
"--porcelain",
|
|
2135
|
+
"--",
|
|
2136
|
+
path
|
|
2137
|
+
]);
|
|
2138
|
+
return status.code === 0 && status.stdout.trim() === "";
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Ask before replacing an existing workflow, and keep the original: in git when it is
|
|
2142
|
+
* committed and unmodified, in a `.bak` copy otherwise.
|
|
2143
|
+
*
|
|
2144
|
+
* @param setup - The wizard state.
|
|
2145
|
+
* @param template - The central workflow about to be written.
|
|
2146
|
+
* @param existing - The current contents of the file.
|
|
2147
|
+
* @returns `true` when the caller may write the template now.
|
|
2148
|
+
* @example
|
|
2149
|
+
* if (!(await clearExistingWorkflow(setup, template, existing))) continue;
|
|
2150
|
+
*/
|
|
2151
|
+
async function clearExistingWorkflow(setup, template, existing) {
|
|
2152
|
+
const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
|
|
2153
|
+
const committed = await isCommittedUnchanged(setup, template.path);
|
|
2154
|
+
const safety = committed ? "git keeps the original" : "a .bak copy is kept";
|
|
2155
|
+
if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (${safety})?`)) {
|
|
2156
|
+
setup.ui.check(false, `${template.path} left unchanged`);
|
|
2157
|
+
return false;
|
|
2158
|
+
}
|
|
2159
|
+
if (deferred(setup, `${committed ? "replace" : "back up and replace"} ${template.path}`)) return false;
|
|
2160
|
+
if (!committed) await setup.ctx.files.backup(template.path);
|
|
2161
|
+
return true;
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
1951
2164
|
* Write the two thin workflows. A file that already calls the pinned central workflow is
|
|
1952
2165
|
* left alone; a differing file is only replaced after an explicit confirm, and a `.bak`
|
|
1953
|
-
* copy is kept.
|
|
2166
|
+
* copy is kept unless git already holds the original.
|
|
1954
2167
|
*
|
|
1955
2168
|
* @param setup - The wizard state.
|
|
1956
2169
|
* @returns Nothing.
|
|
@@ -1965,15 +2178,7 @@ async function writeWorkflows(setup) {
|
|
|
1965
2178
|
setup.ui.check(true, `${template.path} up to date`);
|
|
1966
2179
|
continue;
|
|
1967
2180
|
}
|
|
1968
|
-
if (existing
|
|
1969
|
-
const kind = isThinWorkflow(existing, template) ? "differs" : "is a legacy workflow";
|
|
1970
|
-
if (!await setup.prompts.confirm(`${template.path} ${kind}. Replace it (a .bak copy is kept)?`)) {
|
|
1971
|
-
setup.ui.check(false, `${template.path} left unchanged`);
|
|
1972
|
-
continue;
|
|
1973
|
-
}
|
|
1974
|
-
if (deferred(setup, `back up and replace ${template.path}`)) continue;
|
|
1975
|
-
await setup.ctx.files.backup(template.path);
|
|
1976
|
-
} else if (deferred(setup, `write ${template.path}`)) continue;
|
|
2181
|
+
if (!(existing === void 0 ? !deferred(setup, `write ${template.path}`) : await clearExistingWorkflow(setup, template, existing))) continue;
|
|
1977
2182
|
await setup.ctx.files.write(template.path, template.content);
|
|
1978
2183
|
setup.ui.check(true, `${template.path} written`);
|
|
1979
2184
|
}
|
|
@@ -2006,7 +2211,8 @@ async function normalizeContract(setup, manifest) {
|
|
|
2006
2211
|
return;
|
|
2007
2212
|
}
|
|
2008
2213
|
if (deferred(setup, `write package.json`)) return;
|
|
2009
|
-
await setup.ctx.files.
|
|
2214
|
+
const source = await setup.ctx.files.read(MANIFEST_PATH);
|
|
2215
|
+
await setup.ctx.files.write(MANIFEST_PATH, formatManifest(next, source));
|
|
2010
2216
|
setup.ui.check(true, `${MANIFEST_PATH} normalized`);
|
|
2011
2217
|
}
|
|
2012
2218
|
/**
|
|
@@ -2060,6 +2266,11 @@ async function firstPublish(setup, name, version) {
|
|
|
2060
2266
|
*/
|
|
2061
2267
|
async function pushVersionTag(setup, version) {
|
|
2062
2268
|
setup.ui.heading("Tag");
|
|
2269
|
+
const latest = latestVersionTag((await setup.ctx.exec.capture("git", [...LATEST_TAG_ARGS])).stdout);
|
|
2270
|
+
if (latest !== void 0) {
|
|
2271
|
+
setup.ui.check(true, `release tags exist, latest is ${latest}`);
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2063
2274
|
const tag = `v${version}`;
|
|
2064
2275
|
if ((await setup.ctx.exec.capture("git", [
|
|
2065
2276
|
"tag",
|
|
@@ -2122,6 +2333,21 @@ async function applyBranchRuleset(setup, ownerRepo) {
|
|
|
2122
2333
|
setup.ui.check(true, result.detail);
|
|
2123
2334
|
return;
|
|
2124
2335
|
}
|
|
2336
|
+
const listing = await setup.ctx.exec.capture("gh", ["api", `repos/${ownerRepo}/rulesets`]);
|
|
2337
|
+
const stale = await findStaleRuleset(setup.ctx, ownerRepo, listing.stdout);
|
|
2338
|
+
if (stale !== void 0) {
|
|
2339
|
+
if (deferred(setup, `move ruleset ${stale.id} on ${ownerRepo} to the ci / … checks`)) return;
|
|
2340
|
+
const updated = await setup.ctx.exec.capture("gh", [
|
|
2341
|
+
"api",
|
|
2342
|
+
`repos/${ownerRepo}/rulesets/${stale.id}`,
|
|
2343
|
+
"--method",
|
|
2344
|
+
"PUT",
|
|
2345
|
+
"--input",
|
|
2346
|
+
"-"
|
|
2347
|
+
], { input: withCentralRequiredChecks(stale.body, renderMainRuleset()) });
|
|
2348
|
+
setup.ui.check(updated.code === 0, "branch ruleset updated", updated.stderr.trim() || void 0);
|
|
2349
|
+
return;
|
|
2350
|
+
}
|
|
2125
2351
|
if (deferred(setup, `create the PR-only branch ruleset on ${ownerRepo}`)) return;
|
|
2126
2352
|
const created = await setup.ctx.exec.capture("gh", [
|
|
2127
2353
|
"api",
|
|
@@ -2181,7 +2407,7 @@ async function runSetup(options) {
|
|
|
2181
2407
|
* dispatcher.
|
|
2182
2408
|
*
|
|
2183
2409
|
* The grammar is deliberately tiny: one positional (a command name, or a semver bump that
|
|
2184
|
-
* implies the `release` command) plus
|
|
2410
|
+
* implies the `release` command) plus four flags. Anything unrecognized resolves to
|
|
2185
2411
|
* `help` with an `error` set — the CLI never guesses what an operator meant.
|
|
2186
2412
|
*/
|
|
2187
2413
|
/** The semver bumps `moku-release <type>` accepts, in menu order. */
|
|
@@ -2191,6 +2417,13 @@ const RELEASE_TYPES = [
|
|
|
2191
2417
|
"major",
|
|
2192
2418
|
"prerelease"
|
|
2193
2419
|
];
|
|
2420
|
+
/** Every flag the CLI accepts, besides `--help`. */
|
|
2421
|
+
const KNOWN_FLAGS = /* @__PURE__ */ new Set([
|
|
2422
|
+
"--json",
|
|
2423
|
+
"--dry-run",
|
|
2424
|
+
"--yes",
|
|
2425
|
+
"-y"
|
|
2426
|
+
]);
|
|
2194
2427
|
/**
|
|
2195
2428
|
* Whether a positional argument is one of the semver bumps.
|
|
2196
2429
|
*
|
|
@@ -2215,6 +2448,7 @@ function rejected(error) {
|
|
|
2215
2448
|
command: "help",
|
|
2216
2449
|
json: false,
|
|
2217
2450
|
dryRun: false,
|
|
2451
|
+
yes: false,
|
|
2218
2452
|
error
|
|
2219
2453
|
};
|
|
2220
2454
|
}
|
|
@@ -2225,7 +2459,7 @@ function rejected(error) {
|
|
|
2225
2459
|
* @returns What to run, and with which flags.
|
|
2226
2460
|
* @example
|
|
2227
2461
|
* parseArgv(["patch", "--dry-run"]);
|
|
2228
|
-
* // { command: "release", releaseType: "patch", json: false, dryRun: true }
|
|
2462
|
+
* // { command: "release", releaseType: "patch", json: false, dryRun: true, yes: false }
|
|
2229
2463
|
*/
|
|
2230
2464
|
function parseArgv(argv) {
|
|
2231
2465
|
const flags = argv.filter((argument) => argument.startsWith("-"));
|
|
@@ -2233,34 +2467,40 @@ function parseArgv(argv) {
|
|
|
2233
2467
|
if (flags.includes("--help") || flags.includes("-h")) return {
|
|
2234
2468
|
command: "help",
|
|
2235
2469
|
json: false,
|
|
2236
|
-
dryRun: false
|
|
2470
|
+
dryRun: false,
|
|
2471
|
+
yes: false
|
|
2237
2472
|
};
|
|
2238
|
-
const unknownFlag = flags.find((flag) => flag
|
|
2473
|
+
const unknownFlag = flags.find((flag) => !KNOWN_FLAGS.has(flag));
|
|
2239
2474
|
if (unknownFlag) return rejected(`unknown flag \`${unknownFlag}\``);
|
|
2240
2475
|
if (positionals.length > 1) return rejected(`unexpected argument \`${positionals[1]}\``);
|
|
2241
2476
|
const json = flags.includes("--json");
|
|
2242
2477
|
const dryRun = flags.includes("--dry-run");
|
|
2478
|
+
const yes = flags.includes("--yes") || flags.includes("-y");
|
|
2243
2479
|
const [first] = positionals;
|
|
2244
2480
|
if (first === void 0) return {
|
|
2245
2481
|
command: "help",
|
|
2246
2482
|
json,
|
|
2247
|
-
dryRun
|
|
2483
|
+
dryRun,
|
|
2484
|
+
yes
|
|
2248
2485
|
};
|
|
2249
2486
|
if (first === "doctor" || first === "setup") return {
|
|
2250
2487
|
command: first,
|
|
2251
2488
|
json,
|
|
2252
|
-
dryRun
|
|
2489
|
+
dryRun,
|
|
2490
|
+
yes
|
|
2253
2491
|
};
|
|
2254
2492
|
if (first === "help") return {
|
|
2255
2493
|
command: "help",
|
|
2256
2494
|
json,
|
|
2257
|
-
dryRun
|
|
2495
|
+
dryRun,
|
|
2496
|
+
yes
|
|
2258
2497
|
};
|
|
2259
2498
|
if (isReleaseType(first)) return {
|
|
2260
2499
|
command: "release",
|
|
2261
2500
|
releaseType: first,
|
|
2262
2501
|
json,
|
|
2263
|
-
dryRun
|
|
2502
|
+
dryRun,
|
|
2503
|
+
yes
|
|
2264
2504
|
};
|
|
2265
2505
|
return rejected(`unknown command \`${first}\``);
|
|
2266
2506
|
}
|
|
@@ -2445,12 +2685,32 @@ const USAGE = [
|
|
|
2445
2685
|
" moku-release doctor [--json] read-only diagnosis of the release setup",
|
|
2446
2686
|
` moku-release <${RELEASE_TYPES.join("|")}>`,
|
|
2447
2687
|
"",
|
|
2448
|
-
" --dry-run print every action, mutate nothing",
|
|
2688
|
+
" --dry-run print every action, mutate nothing, ask nothing",
|
|
2689
|
+
" --yes, -y answer every setup confirmation with yes",
|
|
2449
2690
|
"",
|
|
2450
2691
|
" Two steps are yours alone — this CLI never handles a credential:",
|
|
2451
2692
|
" gh auth login",
|
|
2452
2693
|
" npm login"
|
|
2453
2694
|
].join("\n");
|
|
2695
|
+
/** Prompts that never read stdin: every confirmation is a yes, every menu its first entry. */
|
|
2696
|
+
const ASSENTING_PROMPTS = {
|
|
2697
|
+
/**
|
|
2698
|
+
* Answer a confirmation with yes.
|
|
2699
|
+
*
|
|
2700
|
+
* @returns Always `true`.
|
|
2701
|
+
* @example
|
|
2702
|
+
* await ASSENTING_PROMPTS.confirm("Replace ci.yml?"); // true
|
|
2703
|
+
*/
|
|
2704
|
+
confirm: async () => true,
|
|
2705
|
+
/**
|
|
2706
|
+
* Pick the first entry of a menu.
|
|
2707
|
+
*
|
|
2708
|
+
* @returns Always `0`.
|
|
2709
|
+
* @example
|
|
2710
|
+
* await ASSENTING_PROMPTS.select("Bump?", ["patch", "minor"]); // 0
|
|
2711
|
+
*/
|
|
2712
|
+
select: async () => 0
|
|
2713
|
+
};
|
|
2454
2714
|
/**
|
|
2455
2715
|
* Dispatch a parsed invocation to its command.
|
|
2456
2716
|
*
|
|
@@ -2476,7 +2736,7 @@ async function dispatch(parsed, ctx, ui) {
|
|
|
2476
2736
|
if (parsed.command === "setup") return runSetup({
|
|
2477
2737
|
ctx,
|
|
2478
2738
|
ui,
|
|
2479
|
-
prompts: createBrandPrompts(),
|
|
2739
|
+
prompts: parsed.yes || parsed.dryRun ? ASSENTING_PROMPTS : createBrandPrompts(),
|
|
2480
2740
|
dryRun: parsed.dryRun
|
|
2481
2741
|
});
|
|
2482
2742
|
if (parsed.command === "release" && parsed.releaseType !== void 0) return runRelease({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moku-labs/ci",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Central CI and release for the moku family: reusable workflows, caller examples and the moku-release CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -56,8 +56,8 @@
|
|
|
56
56
|
"lint:fix": "biome check --write . && eslint --fix .",
|
|
57
57
|
"format": "biome format --write .",
|
|
58
58
|
"test": "vitest run",
|
|
59
|
-
"release:setup": "
|
|
60
|
-
"release:doctor": "
|
|
61
|
-
"release": "
|
|
59
|
+
"release:setup": "bun run build && node dist/release.mjs setup",
|
|
60
|
+
"release:doctor": "bun run build && node dist/release.mjs doctor",
|
|
61
|
+
"release": "bun run build && node dist/release.mjs"
|
|
62
62
|
}
|
|
63
63
|
}
|