@codyswann/lisa 4.67.0 → 4.68.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.
Files changed (62) hide show
  1. package/all/copy-overwrite/scripts/lisa-self-update.mjs +472 -61
  2. package/all/create-only/.github/workflows/lisa-update.yml +12 -6
  3. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  4. package/dist/core/lisa-owned-hash-ledger.js +6 -0
  5. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +6 -3
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/package.json +5 -5
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo-agy/plugin.json +1 -1
  24. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  44. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails-agy/plugin.json +1 -1
  49. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  54. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  59. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  61. package/scripts/lisa-self-update.mjs +14 -0
  62. package/typescript/copy-overwrite/audit.ignore.config.json +6 -0
@@ -27,7 +27,15 @@
27
27
  * auto-merge with a merge method the repository allows.
28
28
  *
29
29
  * The pull request goes through every gate a human change does. Nothing here
30
- * merges, approves, or skips a check.
30
+ * merges, approves, or skips a check. A project that tracks work in GitHub
31
+ * Issues gets a fresh work item per update (trailer, PR line and backlink), so
32
+ * a traceability gate is satisfied rather than bypassed; elsewhere,
33
+ * `LISA_UPDATE_WORK_ITEM` names one.
34
+ *
35
+ * In Lisa's own repository (self mode, CodySwannGT/lisa#4331) the same flow
36
+ * moves Lisa's dependency on its published self after each release: no template
37
+ * apply, a caret floor, and a loop guard so a self-bump never chases the release
38
+ * its own merge cuts.
31
39
  * @module scripts/lisa-self-update
32
40
  */
33
41
  import { execFile } from "node:child_process";
@@ -41,6 +49,9 @@ export const LISA_PACKAGE = "@codyswann/lisa";
41
49
  /** Branch prefix every update pull request uses; supersession keys on it. */
42
50
  export const BRANCH_PREFIX = "lisa/update-";
43
51
 
52
+ /** The manifest every package-manager decision reads. */
53
+ const MANIFEST = "package.json";
54
+
44
55
  /** Flags this script accepts. Anything else is refused, never ignored. */
45
56
  const KNOWN_FLAGS = new Set(["--dry-run", "--help"]);
46
57
 
@@ -89,6 +100,63 @@ export function declaresLisa(manifest) {
89
100
  );
90
101
  }
91
102
 
103
+ /**
104
+ * Whether this manifest IS Lisa — the repository that publishes the package.
105
+ *
106
+ * Lisa depends on a published copy of itself, and that pin is the one no other
107
+ * automation corrects: `lisa apply` deliberately skips the self-pin phase on
108
+ * its own manifest (CodySwannGT/lisa#3768). Self mode updates it the same way
109
+ * a host is updated, minus the template apply Lisa never runs on itself.
110
+ * @param {Record<string, any> | null} manifest Parsed package.json.
111
+ * @returns {boolean} True for Lisa's own repository.
112
+ */
113
+ export function isSelfManifest(manifest) {
114
+ return manifest?.name === LISA_PACKAGE;
115
+ }
116
+
117
+ /** Subject of the commit a self-update makes; the loop guard keys on it. */
118
+ export function selfBumpSubject(version) {
119
+ return `chore(deps): update ${LISA_PACKAGE} self-dependency to ${version}`;
120
+ }
121
+
122
+ /** A release-bot commit, in either form Lisa's release workflow has emitted. */
123
+ const RELEASE_SUBJECT = /^chore\(release\): \S+ \[skip ci\](?: \[skip-cd\])?$/u;
124
+
125
+ /** A self-update commit made by {@link selfBumpSubject}. */
126
+ const SELF_BUMP_SUBJECT =
127
+ /^chore\(deps\): update @codyswann\/lisa self-dependency to \S+$/u;
128
+
129
+ /**
130
+ * The loop guard. Every merge to Lisa's `main` cuts a release, so merging a
131
+ * self-bump publishes a NEWER version than the one it pinned — and an updater
132
+ * that chased it would open a bump, merge it, release, and bump again forever.
133
+ * When everything a release added since the pinned one is release commits and
134
+ * self-bumps, the two differ only by the pin itself, and the pin is current.
135
+ * @param {readonly string[]} subjects Non-merge commit subjects between the tags.
136
+ * @returns {boolean} True when the newer release carries nothing else.
137
+ */
138
+ export function onlySelfBumps(subjects) {
139
+ return subjects.every(
140
+ subject => RELEASE_SUBJECT.test(subject) || SELF_BUMP_SUBJECT.test(subject)
141
+ );
142
+ }
143
+
144
+ /**
145
+ * The version Lisa should pin itself to. Its own `package.json` names the
146
+ * release `main` just cut; once npm serves that exact version it is the
147
+ * target, which beats waiting minutes for the cached `latest` pointer to move
148
+ * (CodySwannGT/lisa#3685). Until then, npm latest.
149
+ * @param {string | undefined} own Version in Lisa's own manifest.
150
+ * @param {boolean} ownPublished Whether npm serves that exact version.
151
+ * @param {string | null} npmLatest npm's `latest`.
152
+ * @returns {string | null} Target version.
153
+ */
154
+ export function chooseSelfTarget(own, ownPublished, npmLatest) {
155
+ if (!own || !ownPublished) return npmLatest;
156
+ if (!npmLatest) return own;
157
+ return isOlder(own, npmLatest) ? npmLatest : own;
158
+ }
159
+
92
160
  /**
93
161
  * Choose the package manager. `engines` is authoritative: a value of
94
162
  * `please-use-<pm>` names the only allowed manager, and any manager whose own
@@ -118,10 +186,12 @@ export function choosePackageManager(engines, lockfiles) {
118
186
  * The commands that install the current lockfile and bump Lisa.
119
187
  * @param {"bun" | "npm" | "pnpm" | "yarn"} manager Package manager.
120
188
  * @param {string} version Target version.
189
+ * @param {string} [range] Range operator to write, e.g. `^` for Lisa's own
190
+ * caret floor; a host gets the exact version its manager records.
121
191
  * @returns {{install: string[], bump: string[]}} argv arrays.
122
192
  */
123
- export function packageCommands(manager, version) {
124
- const spec = `${LISA_PACKAGE}@${version}`;
193
+ export function packageCommands(manager, version, range = "") {
194
+ const spec = `${LISA_PACKAGE}@${range}${version}`;
125
195
  const table = {
126
196
  bun: {
127
197
  install: ["bun", "install", "--frozen-lockfile"],
@@ -269,7 +339,7 @@ export function supersededPullRequests(open, branch) {
269
339
  * Commit message, with the optional standing work-item trailer.
270
340
  * @param {string} subject Conventional subject.
271
341
  * @param {string} body Paragraph explaining the change.
272
- * @param {string | undefined} workItem `LISA_UPDATE_WORK_ITEM`, if set.
342
+ * @param {string | undefined} workItem The update's work item, if any.
273
343
  * @returns {string} Full message.
274
344
  */
275
345
  export function commitMessage(subject, body, workItem) {
@@ -280,26 +350,88 @@ export function commitMessage(subject, body, workItem) {
280
350
  /**
281
351
  * Pull request body, written for a non-technical reader.
282
352
  * @param {{from: string, to: string}} plan The update.
283
- * @param {string | undefined} workItem Standing work item, if configured.
353
+ * @param {string | undefined} workItem The update's work item, if any.
354
+ * @param {boolean} [selfMode] Whether this is Lisa updating itself.
284
355
  * @returns {string} Markdown body.
285
356
  */
286
- export function pullRequestBody(plan, workItem) {
357
+ export function pullRequestBody(plan, workItem, selfMode = false) {
358
+ const what = selfMode
359
+ ? `This updates the copy of Lisa that Lisa itself is built and checked with, from **${plan.from}** to **${plan.to}**, so its own checks run the guardrails it ships.`
360
+ : `This updates Lisa, the project's engineering guardrails, from **${plan.from}** to **${plan.to}**.`;
361
+ const contents = selfMode
362
+ ? "It was opened automatically after a release. It contains one commit, the dependency bump; Lisa does not apply its own templates to itself."
363
+ : "It was opened automatically by the scheduled Lisa Update workflow. It contains two commits: the dependency bump, and the template changes Lisa applies for the new version.";
287
364
  const lines = [
288
- `This updates Lisa, the project's engineering guardrails, from **${plan.from}** to **${plan.to}**.`,
365
+ what,
289
366
  "",
290
- "It was opened automatically by the scheduled Lisa Update workflow. It contains two commits: the dependency bump, and the template changes Lisa applies for the new version. It goes through every check a normal change does and merges itself only when they all pass.",
367
+ `${contents} It goes through every check a normal change does and merges itself only when they all pass.`,
291
368
  "",
292
369
  `Release notes: https://github.com/CodySwannGT/lisa/releases/tag/v${plan.to}`,
293
370
  ];
294
- if (!workItem) {
371
+ if (workItem) {
372
+ lines.push("", `Work-Item: ${workItem}`);
373
+ } else {
295
374
  lines.push(
296
375
  "",
297
- 'If this project requires every change to reference a work item, set the repository variable `LISA_UPDATE_WORK_ITEM` to a standing work item (for example a "Keep Lisa current" ticket) so future update pull requests carry it.'
376
+ "If this project requires every change to reference a work item and does not track work in GitHub Issues, set the repository variable `LISA_UPDATE_WORK_ITEM` to an open work item for the next update. Projects that track work in GitHub Issues get a fresh work item for each update automatically."
298
377
  );
299
378
  }
300
379
  return `${lines.join("\n")}\n`;
301
380
  }
302
381
 
382
+ /**
383
+ * Labels for a per-update work item, from the project's own lifecycle config.
384
+ *
385
+ * A fresh item per update, never a standing one: merging into the production
386
+ * branch completes the item it names, so a standing item is closed by the first
387
+ * update and refuses every later one.
388
+ * @param {Record<string, any> | null} config Parsed `.lisa.config.json`.
389
+ * @returns {{ready: string, claimed: string, repo: string} | null} Labels, or
390
+ * null when the project does not track work in GitHub Issues.
391
+ */
392
+ export function workItemLabels(config) {
393
+ if (config?.tracker !== "github" || !config.github?.repo) return null;
394
+ const build = config.github.labels?.build ?? {};
395
+ return {
396
+ ready: build.ready ?? "status:ready",
397
+ claimed: build.claimed ?? "status:in-progress",
398
+ repo: `repo:${config.github.repo}`,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Body of a per-update work item, written for a non-technical reader.
404
+ * @param {{from: string, to: string}} plan The update.
405
+ * @param {boolean} selfMode Whether this is Lisa updating itself.
406
+ * @returns {string} Markdown body.
407
+ */
408
+ export function workItemBody(plan, selfMode) {
409
+ const subject = selfMode
410
+ ? "the copy of Lisa this repository builds with"
411
+ : "Lisa";
412
+ return [
413
+ "Filed by: lisa-self-update (automated).",
414
+ "",
415
+ "## Context / Business Value",
416
+ "",
417
+ `Lisa ${plan.to} is published and this repository uses ${plan.from}. Keeping ${subject} current means the newest guardrails and fixes apply here.`,
418
+ "",
419
+ "## Acceptance Criteria",
420
+ "",
421
+ "```gherkin",
422
+ "Scenario: the update lands",
423
+ ` Given ${subject} is ${plan.from}`,
424
+ " When the update pull request merges after every required check passes",
425
+ ` Then ${subject} is ${plan.to}`,
426
+ "```",
427
+ "",
428
+ "## Target Backend Environment",
429
+ "",
430
+ "None — no runtime behavior change: config-only",
431
+ "",
432
+ ].join("\n");
433
+ }
434
+
303
435
  /**
304
436
  * Proof the full apply ran for the target version.
305
437
  * @param {Record<string, any> | null} receipt Parsed `.lisa/apply-receipt.json`.
@@ -330,6 +462,51 @@ function readJson(file) {
330
462
  }
331
463
  }
332
464
 
465
+ /** Variables that can carry the GitHub credential; never inherited by default. */
466
+ const TOKEN_VARS = ["LISA_UPDATE_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
467
+
468
+ /**
469
+ * An environment with every GitHub credential removed.
470
+ * @param {NodeJS.ProcessEnv} env Source environment.
471
+ * @returns {NodeJS.ProcessEnv} A copy without credentials.
472
+ */
473
+ export function withoutToken(env) {
474
+ return Object.fromEntries(
475
+ Object.entries(env).filter(([key]) => !TOKEN_VARS.includes(key))
476
+ );
477
+ }
478
+
479
+ /**
480
+ * The environment one command runs with: the credential reaches only the
481
+ * commands that talk to GitHub. Installs, the bump and the apply run without
482
+ * it, because they execute dependency lifecycle scripts, and a compromised
483
+ * release must not be able to read a token that can push and merge. `gh` gets
484
+ * `GH_TOKEN`; git fetch and push get an auth header through git's environment
485
+ * config (never argv, which other processes can read); the work-item backlink
486
+ * shells out to `gh`.
487
+ * @param {readonly string[]} argv Command and arguments.
488
+ * @param {NodeJS.ProcessEnv | undefined} env Environment the caller supplied.
489
+ * @param {string | undefined} token GitHub credential, if any.
490
+ * @returns {NodeJS.ProcessEnv} The environment to spawn with.
491
+ */
492
+ export function commandEnv(argv, env, token) {
493
+ const base = withoutToken(env ?? process.env);
494
+ if (!token) return base;
495
+ const talksToGithub =
496
+ argv[0] === "gh" || (argv[0] === "node" && argv.includes("backlink"));
497
+ if (talksToGithub) return { ...base, GH_TOKEN: token };
498
+ if (argv[0] === "git" && (argv[1] === "fetch" || argv[1] === "push")) {
499
+ const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
500
+ return {
501
+ ...base,
502
+ GIT_CONFIG_COUNT: "1",
503
+ GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
504
+ GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basic}`,
505
+ };
506
+ }
507
+ return base;
508
+ }
509
+
333
510
  /**
334
511
  * Run a command, inheriting nothing but the environment given.
335
512
  * @param {string[]} argv Command and arguments.
@@ -343,7 +520,7 @@ export function runCommand(argv, options) {
343
520
  argv.slice(1),
344
521
  {
345
522
  cwd: options.cwd,
346
- env: options.env ?? process.env,
523
+ env: options.env ?? withoutToken(process.env),
347
524
  maxBuffer: 64 * 1024 * 1024,
348
525
  },
349
526
  (error, stdout, stderr) => {
@@ -367,18 +544,31 @@ export function runCommand(argv, options) {
367
544
  * @returns {Promise<object>} Facts.
368
545
  */
369
546
  async function gatherFacts(ctx) {
370
- const manifest = readJson(path.join(ctx.root, "package.json"));
371
- const declares = declaresLisa(manifest);
372
- if (!declares) return { declares, manifest };
547
+ const manifest = readJson(path.join(ctx.root, MANIFEST));
548
+ const selfMode = isSelfManifest(manifest);
549
+ const declares = selfMode || declaresLisa(manifest);
550
+ if (!declares) return { declares, manifest, selfMode };
373
551
  const manager = choosePackageManager(
374
552
  manifest.engines,
375
553
  LOCKFILES.map(([file]) => file).filter(file =>
376
554
  existsSync(path.join(ctx.root, file))
377
555
  )
378
556
  );
379
- const latest = await ctx
380
- .run(["npm", "view", LISA_PACKAGE, "version"], { cwd: ctx.root })
381
- .catch(() => null);
557
+ // probe-direction: neutral — an unanswered registry query yields no target,
558
+ // and planUpdate turns that into a reported skip; nothing is gated on it.
559
+ const view = spec =>
560
+ ctx
561
+ .run(["npm", "view", spec, "version"], { cwd: ctx.root })
562
+ .catch(() => null);
563
+ const npmLatest = await view(LISA_PACKAGE);
564
+ const latest = selfMode
565
+ ? chooseSelfTarget(
566
+ manifest.version,
567
+ (await view(`${LISA_PACKAGE}@${manifest.version}`)) ===
568
+ manifest.version,
569
+ npmLatest
570
+ )
571
+ : npmLatest;
382
572
  if (!ctx.dryRun) {
383
573
  ctx.log(`Installing the current lockfile with ${manager}…`);
384
574
  await ctx.run(packageCommands(manager, latest ?? "latest").install, {
@@ -387,9 +577,189 @@ async function gatherFacts(ctx) {
387
577
  }
388
578
  const installed =
389
579
  readJson(
390
- path.join(ctx.root, "node_modules", "@codyswann", "lisa", "package.json")
580
+ path.join(ctx.root, "node_modules", "@codyswann", "lisa", MANIFEST)
391
581
  )?.version ?? null;
392
- return { declares, manifest, manager, latest, installed };
582
+ return { declares, manifest, manager, latest, installed, selfMode };
583
+ }
584
+
585
+ /**
586
+ * Apply the loop guard to a self-update plan (see {@link onlySelfBumps}).
587
+ *
588
+ * A missing tag is an error, never a pass: an unreadable range cannot show the
589
+ * newer release is only a self-bump, and treating it as one would stop Lisa
590
+ * updating itself with nothing saying so.
591
+ * @param {object} ctx Context.
592
+ * @param {{from: string, to: string}} plan The planned update.
593
+ * @returns {Promise<boolean>} True when the pin is effectively current.
594
+ */
595
+ async function pinIsEffectivelyCurrent(ctx, plan) {
596
+ await ctx.run(["git", "fetch", "--tags", "--quiet", "origin"], {
597
+ cwd: ctx.root,
598
+ });
599
+ const log = await ctx.run(
600
+ ["git", "log", "--no-merges", "--format=%s", `v${plan.from}..v${plan.to}`],
601
+ { cwd: ctx.root }
602
+ );
603
+ return onlySelfBumps(log.split("\n").filter(Boolean));
604
+ }
605
+
606
+ /**
607
+ * Create a fresh work item for this update when the project tracks work in
608
+ * GitHub Issues and none was supplied. Filed into the ready role, then claimed,
609
+ * as every work item is. Jira and Linear projects use `LISA_UPDATE_WORK_ITEM`.
610
+ * @param {object} ctx Context.
611
+ * @param {{from: string, to: string}} plan The update.
612
+ * @param {boolean} selfMode Whether this is Lisa updating itself.
613
+ * @returns {Promise<string | undefined>} `owner/repo#N`, or undefined.
614
+ */
615
+ async function resolveWorkItem(ctx, plan, selfMode) {
616
+ if (ctx.workItem) return ctx.workItem;
617
+ const labels = workItemLabels(
618
+ readJson(path.join(ctx.root, ".lisa.config.json"))
619
+ );
620
+ if (!labels) return undefined;
621
+ const title = selfMode
622
+ ? `Update Lisa's own self-dependency to ${plan.to}`
623
+ : `Update Lisa to ${plan.to}`;
624
+ const url = await ctx.run(
625
+ [
626
+ "gh",
627
+ "issue",
628
+ "create",
629
+ "--repo",
630
+ ctx.repo,
631
+ "--title",
632
+ title,
633
+ "--body",
634
+ workItemBody(plan, selfMode),
635
+ "--label",
636
+ "type:Task",
637
+ "--label",
638
+ labels.repo,
639
+ "--label",
640
+ labels.ready,
641
+ ],
642
+ { cwd: ctx.root }
643
+ );
644
+ const number = /\/issues\/(\d+)$/u.exec(url)?.[1];
645
+ if (!number)
646
+ throw new Error(
647
+ `lisa-self-update: could not read the work item created at ${url}.`
648
+ );
649
+ await ctx.run(
650
+ [
651
+ "gh",
652
+ "issue",
653
+ "edit",
654
+ number,
655
+ "--repo",
656
+ ctx.repo,
657
+ "--remove-label",
658
+ labels.ready,
659
+ "--add-label",
660
+ labels.claimed,
661
+ ],
662
+ { cwd: ctx.root }
663
+ );
664
+ ctx.log(`Filed work item ${ctx.repo}#${number} for this update.`);
665
+ return `${ctx.repo}#${number}`;
666
+ }
667
+
668
+ /**
669
+ * Post the managed `[lisa-pr-link]` backlink from the work item to the PR,
670
+ * through the project's own work-item tool so its format never drifts.
671
+ * @param {object} ctx Context.
672
+ * @param {string} url Pull request URL.
673
+ * @returns {Promise<void>} Resolves once the backlink is posted.
674
+ */
675
+ async function backlink(ctx, url) {
676
+ const tool = [
677
+ "scripts/lisa-work-item.mjs",
678
+ "all/copy-overwrite/scripts/lisa-work-item.mjs",
679
+ ].find(candidate => existsSync(path.join(ctx.root, candidate)));
680
+ if (!tool) {
681
+ ctx.log(
682
+ "No lisa-work-item.mjs here, so the work item was not backlinked to the pull request."
683
+ );
684
+ return;
685
+ }
686
+ await ctx.run(
687
+ ["node", tool, "backlink", "--ref", ctx.workItem, "--pr-url", url],
688
+ { cwd: ctx.root }
689
+ );
690
+ }
691
+
692
+ /**
693
+ * The open pull request already carrying this update, if any. Checked before
694
+ * anything is filed or built, so a re-run reuses it instead of duplicating it.
695
+ * @param {object} ctx Context.
696
+ * @param {string} branch Update branch.
697
+ * @returns {Promise<string>} Its URL, or "".
698
+ */
699
+ function openUpdatePullRequest(ctx, branch) {
700
+ return ctx.run(
701
+ [
702
+ "gh",
703
+ "pr",
704
+ "list",
705
+ "--repo",
706
+ ctx.repo,
707
+ "--head",
708
+ branch,
709
+ "--state",
710
+ "open",
711
+ "--json",
712
+ "url",
713
+ "--jq",
714
+ '.[0].url // ""',
715
+ ],
716
+ { cwd: ctx.root }
717
+ );
718
+ }
719
+
720
+ /**
721
+ * Self mode's single commit: bump the caret floor, prove the install, commit.
722
+ * Lisa never applies its own templates to itself, so there is no apply step.
723
+ * @param {object} ctx Context.
724
+ * @param {{from: string, to: string}} plan The update.
725
+ * @param {string} manager Package manager.
726
+ * @returns {Promise<void>} Resolves when the commit exists.
727
+ */
728
+ async function buildSelfBump(ctx, plan, manager) {
729
+ const { root, run, workItem } = ctx;
730
+ await run(packageCommands(manager, plan.to, "^").bump, { cwd: root });
731
+ const installed = readJson(
732
+ path.join(root, "node_modules", "@codyswann", "lisa", MANIFEST)
733
+ )?.version;
734
+ if (installed !== plan.to) {
735
+ throw new Error(
736
+ `lisa-self-update: the bump installed ${installed ?? "nothing"}, not ${plan.to}.`
737
+ );
738
+ }
739
+ await run(
740
+ [
741
+ "git",
742
+ "add",
743
+ MANIFEST,
744
+ ...LOCKFILES.map(([file]) => file).filter(file =>
745
+ existsSync(path.join(root, file))
746
+ ),
747
+ ],
748
+ { cwd: root }
749
+ );
750
+ await run(
751
+ [
752
+ "git",
753
+ "commit",
754
+ "-m",
755
+ commitMessage(
756
+ selfBumpSubject(plan.to),
757
+ `Moves the copy of Lisa this repository builds and checks with from ${plan.from} to ${plan.to}.`,
758
+ workItem
759
+ ),
760
+ ],
761
+ { cwd: root }
762
+ );
393
763
  }
394
764
 
395
765
  /**
@@ -397,11 +767,16 @@ async function gatherFacts(ctx) {
397
767
  * @param {object} ctx Context.
398
768
  * @param {{from: string, to: string, branch: string}} plan The update.
399
769
  * @param {string} manager Package manager.
400
- * @returns {Promise<void>} Resolves when both commits exist.
770
+ * @param {boolean} [selfMode] Whether this is Lisa updating itself.
771
+ * @returns {Promise<void>} Resolves when the commits exist.
401
772
  */
402
- async function buildBranch(ctx, plan, manager) {
773
+ async function buildBranch(ctx, plan, manager, selfMode = false) {
403
774
  const { root, run, workItem } = ctx;
404
775
  await run(["git", "checkout", "-B", plan.branch], { cwd: root });
776
+ if (selfMode) {
777
+ await buildSelfBump(ctx, plan, manager);
778
+ return;
779
+ }
405
780
  await run(packageCommands(manager, plan.to).bump, { cwd: root });
406
781
  await run(["git", "add", "-A"], { cwd: root });
407
782
  await run(
@@ -454,49 +829,51 @@ async function buildBranch(ctx, plan, manager) {
454
829
  * @param {string} base Base branch.
455
830
  * @returns {Promise<string>} The pull request URL.
456
831
  */
457
- async function publish(ctx, plan, base) {
458
- const { root, run, repo, log, workItem } = ctx;
832
+ async function publish(ctx, plan, base, selfMode = false) {
833
+ const { root, run, repo, workItem } = ctx;
459
834
  await run(["git", "push", "--force", "-u", "origin", plan.branch], {
460
835
  cwd: root,
461
836
  });
462
- const existing = await run(
837
+ const title = selfMode
838
+ ? selfBumpSubject(plan.to)
839
+ : `chore(deps): update Lisa to ${plan.to}`;
840
+ const url = await run(
463
841
  [
464
842
  "gh",
465
843
  "pr",
466
- "list",
844
+ "create",
467
845
  "--repo",
468
846
  repo,
847
+ "--base",
848
+ base,
469
849
  "--head",
470
850
  plan.branch,
471
- "--state",
472
- "open",
473
- "--json",
474
- "url",
475
- "--jq",
476
- '.[0].url // ""',
851
+ "--title",
852
+ title,
853
+ "--body",
854
+ pullRequestBody(plan, workItem, selfMode),
477
855
  ],
478
856
  { cwd: root }
479
857
  );
480
- const url =
481
- existing ||
482
- (await run(
483
- [
484
- "gh",
485
- "pr",
486
- "create",
487
- "--repo",
488
- repo,
489
- "--base",
490
- base,
491
- "--head",
492
- plan.branch,
493
- "--title",
494
- `chore(deps): update Lisa to ${plan.to}`,
495
- "--body",
496
- pullRequestBody(plan, workItem),
497
- ],
498
- { cwd: root }
499
- ));
858
+ if (workItem) await backlink(ctx, url);
859
+ await closeSupersededPullRequests(ctx, plan, url);
860
+ await armAutoMerge(ctx, url);
861
+ return url;
862
+ }
863
+
864
+ /**
865
+ * Close every other open update pull request as superseded by `url`.
866
+ *
867
+ * Runs for a newly opened AND a reused pull request: a run that stopped after
868
+ * `gh pr create` would otherwise leave older update PRs open forever, because
869
+ * every later run takes the reuse path.
870
+ * @param {object} ctx Context.
871
+ * @param {{to: string, branch: string}} plan The update.
872
+ * @param {string} url The pull request carrying this update.
873
+ * @returns {Promise<void>} Resolves once every superseded PR is closed.
874
+ */
875
+ async function closeSupersededPullRequests(ctx, plan, url) {
876
+ const { root, run, repo, log } = ctx;
500
877
  const open = JSON.parse(
501
878
  await run(
502
879
  [
@@ -532,21 +909,31 @@ async function publish(ctx, plan, base) {
532
909
  );
533
910
  log(`Closed superseded pull request #${number}.`);
534
911
  }
912
+ }
913
+
914
+ /**
915
+ * Arm auto-merge with the first method the repository allows, or say why not.
916
+ * @param {object} ctx Context.
917
+ * @param {string} url Pull request URL.
918
+ * @returns {Promise<void>} Resolves once armed or reported.
919
+ */
920
+ async function armAutoMerge(ctx, url) {
535
921
  const settings = JSON.parse(
536
- await run(["gh", "api", `repos/${repo}`], { cwd: root })
922
+ await ctx.run(["gh", "api", `repos/${ctx.repo}`], { cwd: ctx.root })
537
923
  );
538
924
  const flag = chooseMergeFlag(settings);
539
925
  if (flag) {
540
- await run(["gh", "pr", "merge", url, "--auto", flag], { cwd: root });
541
- log(
926
+ await ctx.run(["gh", "pr", "merge", url, "--auto", flag], {
927
+ cwd: ctx.root,
928
+ });
929
+ ctx.log(
542
930
  `Auto-merge armed (${flag}); the pull request merges when its checks pass.`
543
931
  );
544
932
  } else {
545
- log(
933
+ ctx.log(
546
934
  "Auto-merge is not enabled for this repository, so the pull request stays open for someone to merge once its checks pass."
547
935
  );
548
936
  }
549
- return url;
550
937
  }
551
938
 
552
939
  /**
@@ -564,12 +951,16 @@ async function checkoutBase(ctx, base) {
564
951
 
565
952
  /**
566
953
  * Run the whole flow.
567
- * @param {{root: string, repo: string, dryRun: boolean, workItem?: string, run?: typeof runCommand, log?: (line: string) => void, defaultBranch?: string}} options Options.
954
+ * @param {{root: string, repo: string, dryRun: boolean, workItem?: string, token?: string, run?: typeof runCommand, log?: (line: string) => void, defaultBranch?: string}} options Options.
568
955
  * @returns {Promise<number>} Exit code.
569
956
  */
570
957
  export async function main(options) {
571
958
  const ctx = {
572
- run: runCommand,
959
+ run: (argv, opts) =>
960
+ runCommand(argv, {
961
+ ...opts,
962
+ env: commandEnv(argv, opts?.env, options.token),
963
+ }),
573
964
  log: line => console.log(line),
574
965
  ...options,
575
966
  };
@@ -584,17 +975,34 @@ export async function main(options) {
584
975
  ctx.log(`lisa-self-update: nothing to do — ${plan.reason}`);
585
976
  return 0;
586
977
  }
978
+ if (facts.selfMode && (await pinIsEffectivelyCurrent(ctx, plan))) {
979
+ ctx.log(
980
+ `lisa-self-update: nothing to do — the self-dependency is effectively current: ${plan.to} differs from ${plan.from} only by release and self-bump commits.`
981
+ );
982
+ return 0;
983
+ }
587
984
  ctx.log(
588
985
  `lisa-self-update: Lisa ${plan.from} → ${plan.to}; branch ${plan.branch} into ${base}.`
589
986
  );
590
987
  if (ctx.dryRun) {
988
+ const steps = facts.selfMode
989
+ ? "bump the self-dependency (no template apply)"
990
+ : "run the full apply";
591
991
  ctx.log(
592
- `Dry run: would bump with ${facts.manager}, run the full apply, commit, push ${plan.branch}, open a pull request into ${base}, close older ${BRANCH_PREFIX}* pull requests and arm auto-merge.`
992
+ `Dry run: would bump with ${facts.manager}, ${steps}, commit, push ${plan.branch}, open a pull request into ${base}, close older ${BRANCH_PREFIX}* pull requests and arm auto-merge.`
593
993
  );
594
994
  return 0;
595
995
  }
596
- await buildBranch(ctx, plan, facts.manager);
597
- const url = await publish(ctx, plan, base);
996
+ const existing = await openUpdatePullRequest(ctx, plan.branch);
997
+ if (existing) {
998
+ ctx.log(`lisa-self-update: ${existing} already carries this update.`);
999
+ await closeSupersededPullRequests(ctx, plan, existing);
1000
+ await armAutoMerge(ctx, existing);
1001
+ return 0;
1002
+ }
1003
+ ctx.workItem = await resolveWorkItem(ctx, plan, facts.selfMode);
1004
+ await buildBranch(ctx, plan, facts.manager, facts.selfMode);
1005
+ const url = await publish(ctx, plan, base, facts.selfMode);
598
1006
  ctx.log(`lisa-self-update: update pull request ${url}`);
599
1007
  return 0;
600
1008
  }
@@ -620,6 +1028,9 @@ export async function runCli() {
620
1028
  repo,
621
1029
  dryRun: flags.dryRun,
622
1030
  workItem: process.env.LISA_UPDATE_WORK_ITEM || undefined,
1031
+ // The workflow passes LISA_UPDATE_TOKEN; a hand run may rely on GH_TOKEN,
1032
+ // or on neither, in which case gh and git use their own stored login.
1033
+ token: process.env.LISA_UPDATE_TOKEN || process.env.GH_TOKEN || undefined,
623
1034
  defaultBranch: process.env.LISA_DEFAULT_BRANCH || undefined,
624
1035
  });
625
1036
  } catch (error) {