@lernapps/tooling 0.3.0 → 0.4.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.
@@ -32,6 +32,11 @@ const base: PlaywrightTestConfig = {
32
32
  // another number of workers: the tooling's own tests set 1, because they run many apps' checks at the same time.
33
33
  fullyParallel: true,
34
34
  workers: Number(process.env["LERNAPPS_PLAYWRIGHT_WORKERS"]) || "75%",
35
+ // On CI (a small shared runner) a single interaction can now and then wait past the timeout under load
36
+ // (lernapps/app-templates#125): a failed test runs up to twice more there. One that passes on a retry counts as
37
+ // "flaky" in the output, not as passed, so it stays visible; a test that fails every time still fails the check.
38
+ // Locally no retry, so that a test that only sometimes passes shows up while the app is written.
39
+ retries: process.env["CI"] ? 2 : 0,
35
40
  reporter: [["line"]],
36
41
  // LERNAPPS_CHROMIUM: an installed Chromium that lernapps check starts where Playwright's own is missing
37
42
  // (check/visit.ts)
package/dist/cli.mjs CHANGED
@@ -5,14 +5,219 @@ import { a as countFailedChecks, c as planFrontMatter, i as PLAN_NAME, l as plan
5
5
  import { createRequire } from "node:module";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { appendFileSync, closeSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
8
- import { homedir, tmpdir } from "node:os";
8
+ import { homedir, hostname, tmpdir } from "node:os";
9
9
  import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
10
10
  import { parseArgs } from "node:util";
11
11
  import { parse, stringify } from "yaml";
12
+ import { createHash } from "node:crypto";
12
13
  import { fileURLToPath, pathToFileURL } from "node:url";
13
14
  import { createServer } from "node:http";
14
15
  import { chromium } from "playwright";
15
- import { createHash } from "node:crypto";
16
+ /** Every package the lock file installs below node_modules, by its path: the version it asks for and whether it is optional. */
17
+ function lockedPackages(dir) {
18
+ const file = join(dir, "package-lock.json");
19
+ if (!existsSync(file)) return void 0;
20
+ const lock = JSON.parse(readFileSync(file, "utf8"));
21
+ return new Map(Object.entries(lock.packages ?? {}).filter(([path, entry]) => path.startsWith("node_modules/") && entry.link !== true));
22
+ }
23
+ /** The version of the package installed at `path` (below `dir`), or undefined when none is installed there. */
24
+ function installedVersion(dir, path) {
25
+ try {
26
+ const manifest = JSON.parse(readFileSync(join(dir, path, "package.json"), "utf8"));
27
+ return typeof manifest.version === "string" ? manifest.version : "";
28
+ } catch {
29
+ return;
30
+ }
31
+ }
32
+ /**
33
+ * The installed version of every package the lock file names, as one line per package ("path version", "path -" when
34
+ * none is installed), sorted: what node_modules holds, for the key of a cached result. Undefined without a lock file.
35
+ */
36
+ function installedPackages(dir) {
37
+ const locked = lockedPackages(dir);
38
+ if (locked === void 0) return void 0;
39
+ return [...locked.keys()].sort().map((path) => `${path} ${installedVersion(dir, path) ?? "-"}`).join("\n");
40
+ }
41
+ /** How many mismatches a finding names before it says how many more there are. */
42
+ const SHOWN = 5;
43
+ /**
44
+ * Whether node_modules holds what package-lock.json asks for: every package at its version. An optional package that
45
+ * is not installed (one for another platform) is no mismatch. Empty without a lock file or when everything matches;
46
+ * else one problem that names the first mismatches.
47
+ */
48
+ function installedProblems(dir) {
49
+ const locked = lockedPackages(dir);
50
+ if (locked === void 0 || locked.size === 0) return [];
51
+ const mismatches = [];
52
+ for (const [path, entry] of [...locked].sort(([a], [b]) => a.localeCompare(b))) {
53
+ const installed = installedVersion(dir, path);
54
+ if (installed === void 0 && entry.optional === true) continue;
55
+ if (installed !== void 0 && (entry.version === void 0 || installed === entry.version)) continue;
56
+ const name = path.slice(path.lastIndexOf("node_modules/") + 13);
57
+ mismatches.push(installed === void 0 ? `${name} is not installed (the lock file asks for ${entry.version ?? "it"})` : `${name} ${installed} is installed, the lock file asks for ${entry.version}`);
58
+ }
59
+ if (mismatches.length === 0) return [];
60
+ const more = mismatches.length > SHOWN ? ` and ${mismatches.length - SHOWN} more` : "";
61
+ return [{
62
+ where: "node_modules",
63
+ found: `node_modules does not hold what package-lock.json asks for: ${mismatches.slice(0, SHOWN).join("; ")}${more}. CI installs with npm ci, so the check would test other packages than CI`,
64
+ fix: "Run npm ci, which installs exactly what package-lock.json asks for, then check again"
65
+ }];
66
+ }
67
+ /** A warning when this Node has another major version than CI, or undefined. */
68
+ function nodeMajorProblem(version = process.versions.node) {
69
+ if (Number(version.split(".")[0]) === 26) return void 0;
70
+ return {
71
+ where: "node",
72
+ found: `Node ${version} runs this check, CI runs Node 26: what passes here may fail in CI`,
73
+ fix: `Use Node 26 (for example nvm install 26); the check does not fail for this`,
74
+ severity: "warning"
75
+ };
76
+ }
77
+ const git$2 = (cwd, ...args) => {
78
+ const result = spawnSync("git", args, {
79
+ cwd,
80
+ encoding: "utf8"
81
+ });
82
+ return result.status === 0 ? result.stdout : void 0;
83
+ };
84
+ /** The folder of the cache and the lock: <git common dir>/lernapps, or undefined outside a repository. */
85
+ function stateDir(cwd = ".") {
86
+ const common = git$2(cwd, "rev-parse", "--git-common-dir")?.trim();
87
+ return common === void 0 || common === "" ? void 0 : join(resolve(cwd, common), "lernapps");
88
+ }
89
+ /**
90
+ * The key of the state in `cwd` for a check of `parts`, or the reason it has none: not a repository, no commit, a
91
+ * working tree with changes git sees (untracked files included), or a run in CI.
92
+ */
93
+ function cacheKey(cwd, parts, toolingVersion, env = process.env) {
94
+ if (env["CI"] !== void 0 && env["CI"] !== "" && env["CI"] !== "false") return { none: "CI checks every time" };
95
+ const top = git$2(cwd, "rev-parse", "--show-toplevel")?.trim();
96
+ if (top === void 0) return { none: "not a git repository" };
97
+ const tree = git$2(cwd, "rev-parse", "--verify", "--quiet", "HEAD^{tree}")?.trim();
98
+ if (tree === void 0 || tree === "") return { none: "no commit yet" };
99
+ const status = git$2(top, "status", "--porcelain", "--untracked-files=all");
100
+ if (status === void 0) return { none: "git status failed" };
101
+ if (status.trim() !== "") return { none: "the working tree has changes that are not committed" };
102
+ const variables = Object.keys(env).filter((name) => name.startsWith("LERNAPPS_")).sort().map((name) => `${name}=${env[name]}`);
103
+ const hash = createHash("sha256");
104
+ for (const part of [
105
+ `tree ${tree}`,
106
+ `folder ${relative(top, resolve(cwd)).split("\\").join("/")}`,
107
+ `parts ${[...parts].sort().join(",")}`,
108
+ `tooling ${toolingVersion}`,
109
+ `node ${process.versions.node}`,
110
+ ...variables,
111
+ `installed\n${installedPackages(cwd) ?? ""}`
112
+ ]) {
113
+ hash.update(part);
114
+ hash.update("\0");
115
+ }
116
+ return { key: hash.digest("hex") };
117
+ }
118
+ const checkedFile = (dir, key) => join(dir, "checked", `${key}.yaml`);
119
+ /** The report of a passing check of this state, and when it was made; undefined when there is none. */
120
+ function cachedReport(dir, key) {
121
+ const file = checkedFile(dir, key);
122
+ try {
123
+ return {
124
+ report: readFileSync(file, "utf8"),
125
+ at: statSync(file).mtime
126
+ };
127
+ } catch {
128
+ return;
129
+ }
130
+ }
131
+ /** Keeps the report of a passing check of this state; the cache keeps the newest KEEP states. */
132
+ function storeReport(dir, key, report) {
133
+ const folder = join(dir, "checked");
134
+ mkdirSync(folder, { recursive: true });
135
+ writeFileSync(checkedFile(dir, key), report);
136
+ const files = readdirSync(folder).filter((name) => name.endsWith(".yaml")).map((name) => ({
137
+ file: join(folder, name),
138
+ at: statSync(join(folder, name)).mtimeMs
139
+ })).sort((a, b) => b.at - a.at);
140
+ for (const { file } of files.slice(20)) rmSync(file, { force: true });
141
+ }
142
+ /** Whether a process with this id runs on this machine. */
143
+ function alive(pid) {
144
+ try {
145
+ process.kill(pid, 0);
146
+ return true;
147
+ } catch (error) {
148
+ return error.code === "EPERM";
149
+ }
150
+ }
151
+ function readOwner(lock) {
152
+ try {
153
+ return JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"));
154
+ } catch {
155
+ return;
156
+ }
157
+ }
158
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
159
+ /**
160
+ * Takes the lock of checks in this clone (a folder, made atomically with mkdir), waiting while another check holds
161
+ * it; `waiting` is called once, with its owner, when it has to wait. A lock whose process no longer runs on this
162
+ * machine is taken over. Returns the function that releases it; the lock is also released when the process exits or
163
+ * is interrupted.
164
+ */
165
+ async function acquireLock(dir, waiting, poll = 1e3) {
166
+ mkdirSync(dir, { recursive: true });
167
+ const lock = join(dir, "check.lock");
168
+ let told = false;
169
+ for (;;) {
170
+ try {
171
+ mkdirSync(lock);
172
+ break;
173
+ } catch (error) {
174
+ if (error.code !== "EEXIST") throw error;
175
+ }
176
+ const owner = readOwner(lock);
177
+ if (owner !== void 0 && owner.host === hostname() && !alive(owner.pid)) {
178
+ rmSync(lock, {
179
+ recursive: true,
180
+ force: true
181
+ });
182
+ continue;
183
+ }
184
+ const made = statSync(lock, { throwIfNoEntry: false })?.mtimeMs;
185
+ if (owner === void 0 && made !== void 0 && Date.now() - made > 10 * poll) {
186
+ rmSync(lock, {
187
+ recursive: true,
188
+ force: true
189
+ });
190
+ continue;
191
+ }
192
+ if (!told) {
193
+ waiting(owner);
194
+ told = true;
195
+ }
196
+ await sleep(poll);
197
+ }
198
+ writeFileSync(join(lock, "owner.json"), JSON.stringify({
199
+ pid: process.pid,
200
+ host: hostname(),
201
+ at: (/* @__PURE__ */ new Date()).toISOString()
202
+ }));
203
+ const release = () => {
204
+ process.off("exit", release);
205
+ process.off("SIGINT", interrupted);
206
+ process.off("SIGTERM", interrupted);
207
+ if (readOwner(lock)?.pid === process.pid) rmSync(lock, {
208
+ recursive: true,
209
+ force: true
210
+ });
211
+ };
212
+ const interrupted = (signal) => {
213
+ release();
214
+ process.exit(signal === "SIGINT" ? 130 : 143);
215
+ };
216
+ process.on("exit", release);
217
+ process.on("SIGINT", interrupted);
218
+ process.on("SIGTERM", interrupted);
219
+ return release;
220
+ }
16
221
  // check/run.ts
17
222
  const RULE_PAGE = "https://lernapps.net/tooling/rules/#";
18
223
  const finding = (rule, severity, problem) => ({
@@ -644,6 +849,10 @@ const CHECK_OPTIONS = {
644
849
  type: "boolean",
645
850
  default: false
646
851
  },
852
+ "no-cache": {
853
+ type: "boolean",
854
+ default: false
855
+ },
647
856
  entry: { type: "string" },
648
857
  report: { type: "string" },
649
858
  help: {
@@ -652,7 +861,7 @@ const CHECK_OPTIONS = {
652
861
  default: false
653
862
  }
654
863
  };
655
- const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [<dir|url>] [--entry <file>] [--report <file>]
864
+ const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [--no-cache] [<dir|url>] [--entry <file>] [--report <file>]
656
865
 
657
866
  Runs every check that has something to check. Passing, it prints one line; otherwise the validation report in
658
867
  YAML (schema: https://lernapps.net/tooling/schemas/validation-report.v1.schema.json). Every finding names its
@@ -681,8 +890,20 @@ In a repo (no <dir|url>):
681
890
  checks the site in a repo with one (npm run build, then npm run check:site in the tooling, npm run check in a
682
891
  site). Then it prints one line and writes no report. Anything else, or changed files it cannot know, runs the
683
892
  part. CI decides the same in the app check action (lernapps changes --help).
893
+ Every run in a repo first compares node_modules with package-lock.json: a package installed at another
894
+ version, or missing, fails the run before anything else (run npm ci), since CI installs with npm ci.
895
+ The heavy part (--pre-push, or both) then:
896
+ - reuses a passing result of the same state: the tree of HEAD, the folder, the parts, the versions of
897
+ the tooling and of Node, the packages installed and every LERNAPPS_* variable, with a working tree
898
+ that has no changes git sees (untracked files included). It prints that line with "already checked".
899
+ Kept in <git common dir>/lernapps/, which the worktrees of a clone share; never in CI (CI set)
900
+ - waits for another check of the heavy part in this clone (and its worktrees), saying so in one line
901
+ on standard error; a lock whose process no longer runs is taken over
902
+ - warns in one line on standard error when Node has another major version than CI (no finding:
903
+ the report is the same for the same state wherever it is checked)
684
904
  Every run in a repo also writes the full report to node_modules/.cache/lernapps/validation-report.yaml,
685
- passing or not; the app check action keeps it. A failed run counts each rule with an error in failedChecks
905
+ passing or not; the app check action keeps it. A run that checked writes how long each step took to
906
+ node_modules/.cache/lernapps/timings.json; the passing line ends with the whole time. A failed run counts each rule with an error in failedChecks
686
907
  of the current plan's front matter, when it has the key.
687
908
 
688
909
  On a built app, without its repo:
@@ -712,6 +933,7 @@ Options:
712
933
  #<section> (or #<section>/<sub>) is an element id of its page as the server sends it, the
713
934
  fitness values match; without <dir|url> the app at the entry's url is checked
714
935
  --report <file> also write the full report to <file>, passing or not
936
+ --no-cache check again even when this state passed before (in a repo)
715
937
  -h, --help print this help
716
938
 
717
939
  `;
@@ -2514,6 +2736,34 @@ const REPORT = "node_modules/.cache/lernapps/validation-report.yaml";
2514
2736
  const CHECKS_PASS = "checks-pass";
2515
2737
  /** A built app or a URL that could not be checked at all: a platform rule, unlike the toolchain's checks-pass. */
2516
2738
  const APP_CHECKABLE = "app-checkable";
2739
+ /** The duration of every step of the last check in a repo that ran: not in the report, which is deterministic. */
2740
+ const TIMINGS = "node_modules/.cache/lernapps/timings.json";
2741
+ /** The steps of this run and how long each took, in seconds, for TIMINGS. */
2742
+ const timings = [];
2743
+ /** Runs `run` and records how long it took as the step `name`. */
2744
+ function timed(name, run) {
2745
+ const start = performance.now();
2746
+ try {
2747
+ return run();
2748
+ } finally {
2749
+ timings.push({
2750
+ step: name,
2751
+ seconds: Math.round(performance.now() - start) / 1e3
2752
+ });
2753
+ }
2754
+ }
2755
+ /** Like timed, for a step that returns a promise. */
2756
+ async function timedAsync(name, run) {
2757
+ const start = performance.now();
2758
+ try {
2759
+ return await run();
2760
+ } finally {
2761
+ timings.push({
2762
+ step: name,
2763
+ seconds: Math.round(performance.now() - start) / 1e3
2764
+ });
2765
+ }
2766
+ }
2517
2767
  /** The Playwright tests' results, as its JSON reporter writes them, to tell the runtime's tests from the app's. */
2518
2768
  const E2E_RESULTS = "node_modules/.cache/lernapps/playwright-results.json";
2519
2769
  const RUNTIME_ISSUES = "https://github.com/lernapps/app-templates/issues";
@@ -2852,6 +3102,9 @@ function architectureDiffFindings(where, stdout, stderr) {
2852
3102
  }
2853
3103
  /** Runs a step and returns its findings: a diff's as architectureDiffFindings judges them, else runStep's one. */
2854
3104
  function stepFindings(step) {
3105
+ return timed(step.name, () => untimedStepFindings(step));
3106
+ }
3107
+ function untimedStepFindings(step) {
2855
3108
  if (!step.architectureDiff) {
2856
3109
  const failure = runStep(step);
2857
3110
  return failure ? [failure] : [];
@@ -2937,12 +3190,12 @@ async function checkProject(fast, heavy, context) {
2937
3190
  if (heavy && built) {
2938
3191
  context.project = process.cwd();
2939
3192
  if (existsSync(join("dist", "index.html"))) try {
2940
- context.app = await visit({ bundle: "dist" });
3193
+ context.app = await timedAsync("visit of the built app", () => visit({ bundle: "dist" }));
2941
3194
  } catch (error) {
2942
3195
  if (!(error instanceof BrowserError)) throw error;
2943
3196
  findings.push(browserFinding(error));
2944
3197
  }
2945
- const checks = await runChecks(context);
3198
+ const checks = await timedAsync("checks of the built app", () => runChecks(context));
2946
3199
  rules.push(...checks.rules);
2947
3200
  findings.push(...checks.findings);
2948
3201
  if ([
@@ -2951,7 +3204,8 @@ async function checkProject(fast, heavy, context) {
2951
3204
  "mjs",
2952
3205
  "cjs"
2953
3206
  ].map((ext) => `playwright.config.${ext}`).find((file) => existsSync(file)) !== void 0) try {
2954
- const failure = runEndToEndTests(await browserForTests());
3207
+ const browser = await browserForTests();
3208
+ const failure = timed("end-to-end tests", () => runEndToEndTests(browser));
2955
3209
  if (failure) findings.push(failure);
2956
3210
  } catch (error) {
2957
3211
  if (!(error instanceof BrowserError)) throw error;
@@ -3014,6 +3268,62 @@ function checkSite(script) {
3014
3268
  suppressions: []
3015
3269
  };
3016
3270
  }
3271
+ /**
3272
+ * The check of a repo: first whether node_modules holds what package-lock.json asks for (else nothing else runs, its
3273
+ * result would not be CI's). The heavy part warns on standard error when Node has another major version than CI,
3274
+ * then reuses a passing report of the same state (check/cache.ts) or waits for another check in this clone.
3275
+ */
3276
+ async function checkRepo(fast, heavy, context, noCache) {
3277
+ const parts = [...fast ? ["pre-commit"] : [], ...heavy ? ["pre-push"] : []];
3278
+ const installed = timed("installed packages", () => installedProblems("."));
3279
+ if (installed.length > 0) return { report: {
3280
+ checked: commitOrDirectory(),
3281
+ parts,
3282
+ rules: [CHECKS_PASS],
3283
+ findings: installed.map((problem) => finding(CHECKS_PASS, "error", problem)),
3284
+ suppressions: []
3285
+ } };
3286
+ if (!heavy) return { report: await checkProject(fast, heavy, context) };
3287
+ const node = nodeMajorProblem();
3288
+ if (node !== void 0) process.stderr.write(`lernapps check: warning: ${node.found}. ${node.fix}\n`);
3289
+ const dir = stateDir();
3290
+ const keyOf = () => {
3291
+ if (noCache || dir === void 0) return void 0;
3292
+ const key = cacheKey(".", parts, version());
3293
+ return "key" in key ? key.key : void 0;
3294
+ };
3295
+ const cached = (key) => {
3296
+ if (key === void 0 || dir === void 0) return void 0;
3297
+ const hit = cachedReport(dir, key);
3298
+ if (hit === void 0) return void 0;
3299
+ return {
3300
+ report: {
3301
+ ...parse(hit.report),
3302
+ checked: commitOrDirectory()
3303
+ },
3304
+ cachedAt: hit.at
3305
+ };
3306
+ };
3307
+ const before = keyOf();
3308
+ const hit = cached(before);
3309
+ if (hit !== void 0) return hit;
3310
+ let waited = false;
3311
+ const release = dir === void 0 ? () => {} : await acquireLock(dir, (owner) => {
3312
+ waited = true;
3313
+ process.stderr.write(`lernapps check: waiting for another lernapps check in this clone${owner === void 0 ? "" : ` (process ${owner.pid}, since ${owner.at})`}\n`);
3314
+ });
3315
+ try {
3316
+ if (waited) {
3317
+ const after = cached(keyOf());
3318
+ if (after !== void 0) return after;
3319
+ }
3320
+ const report = await checkProject(fast, heavy, context);
3321
+ if (!failed(report) && before !== void 0 && dir !== void 0 && keyOf() === before) storeReport(dir, before, toYaml(report));
3322
+ return { report };
3323
+ } finally {
3324
+ release();
3325
+ }
3326
+ }
3017
3327
  /** The checks of a built app alone: a bundle directory or a URL. */
3018
3328
  async function checkApp(target, context) {
3019
3329
  const isUrl = /^https?:\/\//.test(target);
@@ -3054,6 +3364,9 @@ async function check(args) {
3054
3364
  if (values.entry !== void 0) context.entry = readEntry(values.entry);
3055
3365
  const target = positionals[0] ?? context.entry?.value.url;
3056
3366
  let report;
3367
+ /** When the report comes from the cache: when that state was checked. */
3368
+ let cachedAt;
3369
+ const start = performance.now();
3057
3370
  if (target !== void 0) {
3058
3371
  if (values["pre-commit"] || values["pre-push"]) throw new UsageError("--pre-commit and --pre-push check a repo; a built app is checked without them");
3059
3372
  report = await checkApp(target, context);
@@ -3073,21 +3386,32 @@ async function check(args) {
3073
3386
  process.stdout.write(`${scope.skip}\n`);
3074
3387
  return 0;
3075
3388
  } else {
3076
- report = scope !== void 0 ? checkSite(scope.site) : await checkProject(all || values["pre-commit"], all || values["pre-push"], context);
3389
+ if (scope !== void 0) report = checkSite(scope.site);
3390
+ else {
3391
+ const result = await checkRepo(all || values["pre-commit"], all || values["pre-push"], context, values["no-cache"]);
3392
+ report = result.report;
3393
+ cachedAt = result.cachedAt;
3394
+ }
3077
3395
  if (values["pre-push"] && failed(report)) countPrePushFailure();
3078
3396
  }
3079
3397
  }
3080
3398
  if (target === void 0 && failed(report)) countFailedChecks(report.findings.filter((found) => found.severity === "error").map((found) => found.rule));
3081
3399
  const yaml = toYaml(report);
3400
+ const seconds = Math.round((performance.now() - start) / 100) / 10;
3082
3401
  if (target === void 0) {
3083
3402
  mkdirSync(dirname(REPORT), { recursive: true });
3084
3403
  writeFileSync(REPORT, yaml);
3404
+ if (cachedAt === void 0 && timings.length > 0) writeFileSync(TIMINGS, `${JSON.stringify({
3405
+ seconds,
3406
+ steps: timings
3407
+ }, null, 2)}\n`);
3085
3408
  }
3086
3409
  if (values.report !== void 0) writeFileSync(values.report, yaml);
3087
3410
  if (report.findings.length > 0) process.stdout.write(yaml);
3088
3411
  else {
3089
3412
  const what = "commit" in report.checked ? `commit ${report.checked.commit.slice(0, 7)}` : "bundle" in report.checked ? relative(process.cwd(), resolve(report.checked.bundle)) || "." : "url" in report.checked ? report.checked.url : report.checked.directory;
3090
- process.stdout.write(`lernapps check: ok, ${report.rules.length} rule${report.rules.length === 1 ? "" : "s"}, no findings (${what})\n`);
3413
+ const how = cachedAt !== void 0 ? `, already checked at ${cachedAt.toTimeString().slice(0, 5)} (--no-cache checks again)` : ` in ${seconds} s`;
3414
+ process.stdout.write(`lernapps check: ok, ${report.rules.length} rule${report.rules.length === 1 ? "" : "s"}, no findings (${what})${how}\n`);
3091
3415
  }
3092
3416
  return failed(report) ? 1 : 0;
3093
3417
  }
@@ -18,6 +18,7 @@ const base = {
18
18
  forbidOnly: true,
19
19
  fullyParallel: true,
20
20
  workers: Number(process.env["LERNAPPS_PLAYWRIGHT_WORKERS"]) || "75%",
21
+ retries: process.env["CI"] ? 2 : 0,
21
22
  reporter: [["line"]],
22
23
  use: {
23
24
  baseURL: URL,
@@ -15,7 +15,9 @@ name: Pages
15
15
  # paths filter: a check skipped by a paths filter stays "Pending" and blocks a pull request that requires it. GitHub
16
16
  # may start no run for a push that creates main from commits it already has (the first publish): then start it by
17
17
  # hand, Actions → Pages → Run workflow on main (gh workflow run Pages --ref main), as the plan says; a run started by
18
- # hand checks and publishes everything.
18
+ # hand checks and publishes everything. A push to main whose commit has the same tree as the pull request that the job
19
+ # `app` checked already (the pull request was up to date with main when it was merged) takes that run's bundle and
20
+ # report instead of checking again.
19
21
  on:
20
22
  push:
21
23
  branches: [main]
@@ -34,6 +36,9 @@ jobs:
34
36
  app:
35
37
  if: github.event.action != 'closed'
36
38
  runs-on: ubuntu-latest
39
+ permissions:
40
+ contents: read
41
+ actions: read # on main: find the run of the pull request that checked the same tree, and take its result
37
42
  steps:
38
43
  - uses: lernapps/tooling/actions/app-check@{{tooling}}
39
44
  deploy:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lernapps/tooling",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared tooling of the lernapps.net apps and sites: the CLI lernapps, site actions, Renovate preset, guidance for building apps; documentation at https://lernapps.net/tooling/",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -115,7 +115,7 @@
115
115
  },
116
116
  "devDependencies": {
117
117
  "@doctc/arc42": "0.38.0",
118
- "@lernapps/site": "github:lernapps/lernapps.github.io#8b6c98f76170c313519d04b38937a48747c05b09",
118
+ "@lernapps/site": "github:lernapps/lernapps.github.io#0f6257d3dffcbf69ffe962d0b80bd7298a62de02",
119
119
  "@types/node": "26.6.5",
120
120
  "ajv": "8.20.0",
121
121
  "vite-plus": "1.4.0"