dorfl 0.14.1 → 0.14.3

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 (66) hide show
  1. package/dist/advance-ci-template.d.ts.map +1 -1
  2. package/dist/advance-ci-template.js +10 -4
  3. package/dist/advance-ci-template.js.map +1 -1
  4. package/dist/advance-lifecycle-template.d.ts.map +1 -1
  5. package/dist/advance-lifecycle-template.js +78 -25
  6. package/dist/advance-lifecycle-template.js.map +1 -1
  7. package/dist/cli.js +2 -2
  8. package/dist/close-job-template.d.ts +1 -1
  9. package/dist/close-job-template.d.ts.map +1 -1
  10. package/dist/close-job-template.js +17 -3
  11. package/dist/close-job-template.js.map +1 -1
  12. package/dist/install-ci-action-pins.d.ts +98 -0
  13. package/dist/install-ci-action-pins.d.ts.map +1 -0
  14. package/dist/install-ci-action-pins.js +165 -0
  15. package/dist/install-ci-action-pins.js.map +1 -0
  16. package/dist/install-ci-core.d.ts +84 -0
  17. package/dist/install-ci-core.d.ts.map +1 -1
  18. package/dist/install-ci-core.js +120 -14
  19. package/dist/install-ci-core.js.map +1 -1
  20. package/dist/install-ci-github.d.ts +15 -1
  21. package/dist/install-ci-github.d.ts.map +1 -1
  22. package/dist/install-ci-github.js +32 -0
  23. package/dist/install-ci-github.js.map +1 -1
  24. package/dist/install-ci.d.ts.map +1 -1
  25. package/dist/install-ci.js +21 -0
  26. package/dist/install-ci.js.map +1 -1
  27. package/dist/intake-trigger-template.d.ts.map +1 -1
  28. package/dist/intake-trigger-template.js +24 -7
  29. package/dist/intake-trigger-template.js.map +1 -1
  30. package/dist/intake.d.ts.map +1 -1
  31. package/dist/intake.js +10 -5
  32. package/dist/intake.js.map +1 -1
  33. package/dist/mint-adr.d.ts.map +1 -1
  34. package/dist/mint-adr.js +4 -1
  35. package/dist/mint-adr.js.map +1 -1
  36. package/dist/slug-namespace.d.ts +4 -3
  37. package/dist/slug-namespace.d.ts.map +1 -1
  38. package/dist/slug-namespace.js +28 -3
  39. package/dist/slug-namespace.js.map +1 -1
  40. package/dist/slug-safety.d.ts +55 -0
  41. package/dist/slug-safety.d.ts.map +1 -0
  42. package/dist/slug-safety.js +76 -0
  43. package/dist/slug-safety.js.map +1 -0
  44. package/dist/triage-persist.d.ts.map +1 -1
  45. package/dist/triage-persist.js +12 -3
  46. package/dist/triage-persist.js.map +1 -1
  47. package/dist/verify-workflow-template.d.ts +1 -1
  48. package/dist/verify-workflow-template.d.ts.map +1 -1
  49. package/dist/verify-workflow-template.js +16 -3
  50. package/dist/verify-workflow-template.js.map +1 -1
  51. package/package.json +3 -2
  52. package/src/advance-ci-template.ts +12 -4
  53. package/src/advance-lifecycle-template.ts +80 -25
  54. package/src/cli.ts +2 -2
  55. package/src/close-job-template.ts +17 -3
  56. package/src/install-ci-action-pins.ts +184 -0
  57. package/src/install-ci-core.ts +166 -14
  58. package/src/install-ci-github.ts +40 -1
  59. package/src/install-ci.ts +25 -0
  60. package/src/intake-trigger-template.ts +28 -7
  61. package/src/intake.ts +10 -5
  62. package/src/mint-adr.ts +4 -1
  63. package/src/slug-namespace.ts +33 -5
  64. package/src/slug-safety.ts +83 -0
  65. package/src/triage-persist.ts +13 -7
  66. package/src/verify-workflow-template.ts +16 -3
@@ -60,6 +60,7 @@
60
60
  * the test generates this artifact under `--fake` and asserts every invariant.
61
61
  */
62
62
 
63
+ import {ACTION_PINS, pinnedUses} from './install-ci-action-pins.js';
63
64
  import type {ResolvedCIConfig} from './install-ci-core.js';
64
65
  import {providerSecretsWithBlock} from './install-ci-core.js';
65
66
 
@@ -296,7 +297,7 @@ jobs:
296
297
  # re-run, no baked-in YAML number.
297
298
  githubTimeout: \${{ steps.timeout.outputs.githubTimeout }}
298
299
  steps:
299
- - uses: actions/checkout@v5
300
+ - uses: ${pinnedUses(ACTION_PINS.checkout)}
300
301
  with:
301
302
  fetch-depth: 0
302
303
  - uses: ./.github/actions/dorfl-setup
@@ -306,13 +307,26 @@ jobs:
306
307
  # surfaceBlockers), so an override that does not reach this job produces an
307
308
  # empty matrix and is silently inert. \`if:\` + the inner \`[ -n ... ]\` guard
308
309
  # keep schedule/push (and a blank dispatch field) exporting NOTHING — an
309
- # empty DORFL_* would make env-config coercion throw.
310
+ # empty DORFL_* would make env-config coercion throw. The inputs reach the
311
+ # shell through \`env:\` as DATA, never as \`\${{ }}\` text spliced into the
312
+ # script (GitHub's script-injection guidance), and the \`case\` guard stops a
313
+ # value from appending its own lines to $GITHUB_ENV. Both only matter if an
314
+ # input ever stops being a fixed \`type: choice\`; for the choices below the
315
+ # step writes exactly what it always did.
310
316
  if: \${{ github.event_name == 'workflow_dispatch' }}
317
+ env:
318
+ DISPATCH_AUTO_BUILD: \${{ github.event.inputs.autoBuild }}
319
+ DISPATCH_AUTO_TASK: \${{ github.event.inputs.autoTask }}
320
+ DISPATCH_OBSERVATION_TRIAGE: \${{ github.event.inputs.observationTriage }}
321
+ DISPATCH_SURFACE_BLOCKERS: \${{ github.event.inputs.surfaceBlockers }}
311
322
  run: |
312
- [ -n "\${{ github.event.inputs.autoBuild }}" ] && echo "DORFL_AUTO_BUILD=\${{ github.event.inputs.autoBuild }}" >> "$GITHUB_ENV"
313
- [ -n "\${{ github.event.inputs.autoTask }}" ] && echo "DORFL_AUTO_TASK=\${{ github.event.inputs.autoTask }}" >> "$GITHUB_ENV"
314
- [ -n "\${{ github.event.inputs.observationTriage }}" ] && echo "DORFL_OBSERVATION_TRIAGE=\${{ github.event.inputs.observationTriage }}" >> "$GITHUB_ENV"
315
- [ -n "\${{ github.event.inputs.surfaceBlockers }}" ] && echo "DORFL_SURFACE_BLOCKERS=\${{ github.event.inputs.surfaceBlockers }}" >> "$GITHUB_ENV"
323
+ case "\${DISPATCH_AUTO_BUILD}\${DISPATCH_AUTO_TASK}\${DISPATCH_OBSERVATION_TRIAGE}\${DISPATCH_SURFACE_BLOCKERS}" in
324
+ *$'\\n'*|*$'\\r'*) echo "::error::a dispatch gate override contains a line break"; exit 1 ;;
325
+ esac
326
+ [ -n "\${DISPATCH_AUTO_BUILD}" ] && echo "DORFL_AUTO_BUILD=\${DISPATCH_AUTO_BUILD}" >> "$GITHUB_ENV"
327
+ [ -n "\${DISPATCH_AUTO_TASK}" ] && echo "DORFL_AUTO_TASK=\${DISPATCH_AUTO_TASK}" >> "$GITHUB_ENV"
328
+ [ -n "\${DISPATCH_OBSERVATION_TRIAGE}" ] && echo "DORFL_OBSERVATION_TRIAGE=\${DISPATCH_OBSERVATION_TRIAGE}" >> "$GITHUB_ENV"
329
+ [ -n "\${DISPATCH_SURFACE_BLOCKERS}" ] && echo "DORFL_SURFACE_BLOCKERS=\${DISPATCH_SURFACE_BLOCKERS}" >> "$GITHUB_ENV"
316
330
  true
317
331
  - id: scan
318
332
  # Enumerate eligible items as namespaced ids, one matrix leg per id. CI
@@ -393,7 +407,7 @@ jobs:
393
407
  matrix:
394
408
  item: \${{ fromJson(needs.enumerate.outputs.items) }}
395
409
  steps:
396
- - uses: actions/checkout@v5
410
+ - uses: ${pinnedUses(ACTION_PINS.checkout)}
397
411
  with:
398
412
  fetch-depth: 0
399
413
  - uses: ./.github/actions/dorfl-setup${setupWith}
@@ -402,11 +416,19 @@ jobs:
402
416
  # DORFL_* ONLY on a workflow_dispatch with a non-blank input, so the
403
417
  # one-shot override also reaches the \`advance\` leg that builds the item.
404
418
  if: \${{ github.event_name == 'workflow_dispatch' }}
419
+ env:
420
+ DISPATCH_AUTO_BUILD: \${{ github.event.inputs.autoBuild }}
421
+ DISPATCH_AUTO_TASK: \${{ github.event.inputs.autoTask }}
422
+ DISPATCH_OBSERVATION_TRIAGE: \${{ github.event.inputs.observationTriage }}
423
+ DISPATCH_SURFACE_BLOCKERS: \${{ github.event.inputs.surfaceBlockers }}
405
424
  run: |
406
- [ -n "\${{ github.event.inputs.autoBuild }}" ] && echo "DORFL_AUTO_BUILD=\${{ github.event.inputs.autoBuild }}" >> "$GITHUB_ENV"
407
- [ -n "\${{ github.event.inputs.autoTask }}" ] && echo "DORFL_AUTO_TASK=\${{ github.event.inputs.autoTask }}" >> "$GITHUB_ENV"
408
- [ -n "\${{ github.event.inputs.observationTriage }}" ] && echo "DORFL_OBSERVATION_TRIAGE=\${{ github.event.inputs.observationTriage }}" >> "$GITHUB_ENV"
409
- [ -n "\${{ github.event.inputs.surfaceBlockers }}" ] && echo "DORFL_SURFACE_BLOCKERS=\${{ github.event.inputs.surfaceBlockers }}" >> "$GITHUB_ENV"
425
+ case "\${DISPATCH_AUTO_BUILD}\${DISPATCH_AUTO_TASK}\${DISPATCH_OBSERVATION_TRIAGE}\${DISPATCH_SURFACE_BLOCKERS}" in
426
+ *$'\\n'*|*$'\\r'*) echo "::error::a dispatch gate override contains a line break"; exit 1 ;;
427
+ esac
428
+ [ -n "\${DISPATCH_AUTO_BUILD}" ] && echo "DORFL_AUTO_BUILD=\${DISPATCH_AUTO_BUILD}" >> "$GITHUB_ENV"
429
+ [ -n "\${DISPATCH_AUTO_TASK}" ] && echo "DORFL_AUTO_TASK=\${DISPATCH_AUTO_TASK}" >> "$GITHUB_ENV"
430
+ [ -n "\${DISPATCH_OBSERVATION_TRIAGE}" ] && echo "DORFL_OBSERVATION_TRIAGE=\${DISPATCH_OBSERVATION_TRIAGE}" >> "$GITHUB_ENV"
431
+ [ -n "\${DISPATCH_SURFACE_BLOCKERS}" ] && echo "DORFL_SURFACE_BLOCKERS=\${DISPATCH_SURFACE_BLOCKERS}" >> "$GITHUB_ENV"
410
432
  true
411
433
  - name: advance one item in-place (propose ⇒ opens a PR)
412
434
  # In-place in this checkout (no --isolated/--remote): the CI container IS
@@ -421,13 +443,19 @@ jobs:
421
443
  # works zero-config; the job's \`pull-requests: write\` scopes that token.
422
444
  env:
423
445
  GH_TOKEN: \${{ secrets.DORFL_GH_TOKEN || secrets.GITHUB_TOKEN }}
446
+ # The item id is a slug read from work/ (frontmatter \`slug:\` or the
447
+ # file name). dorfl sanitises the slugs it mints, but a hand-written
448
+ # one is only checked when \`advance\` resolves it, so it reaches the
449
+ # shell as DATA through env, never as \`\${{ matrix.item }}\` text
450
+ # spliced into the script.
451
+ WORK_ITEM: \${{ matrix.item }}
424
452
  # \`--watch\` streams the build agent's high-signal turns (assistant text,
425
453
  # tool calls, finish) into THIS job log live, so the run shows the agent
426
454
  # working instead of freezing after "Start work". A read-only observer (no
427
455
  # outcome/gate/git effect). It fits because each matrix leg names ONE item
428
456
  # (one pi session to tail) — the merge job is now a per-item matrix too
429
457
  # (see below) and streams the same way.
430
- run: dorfl advance "\${{ matrix.item }}" --propose --watch --arbiter origin
458
+ run: dorfl advance "\${WORK_ITEM}" --propose --watch --arbiter origin
431
459
 
432
460
  # ── MERGE: a MATRIX of independent jobs (parallel build/gate/review, serialised land) ──
433
461
  # SPEC \`land-time-reverify-and-parallel-merge-ceiling\` (stories 4 + 6): each item
@@ -458,7 +486,7 @@ jobs:
458
486
  matrix:
459
487
  item: \${{ fromJson(needs.enumerate.outputs.items) }}
460
488
  steps:
461
- - uses: actions/checkout@v5
489
+ - uses: ${pinnedUses(ACTION_PINS.checkout)}
462
490
  with:
463
491
  fetch-depth: 0
464
492
  - uses: ./.github/actions/dorfl-setup${setupWith}
@@ -468,11 +496,19 @@ jobs:
468
496
  # merge job re-scans the pool inside \`advance -n\`, so it needs the override
469
497
  # too for the lifecycle/task pools to reflect it.
470
498
  if: \${{ github.event_name == 'workflow_dispatch' }}
499
+ env:
500
+ DISPATCH_AUTO_BUILD: \${{ github.event.inputs.autoBuild }}
501
+ DISPATCH_AUTO_TASK: \${{ github.event.inputs.autoTask }}
502
+ DISPATCH_OBSERVATION_TRIAGE: \${{ github.event.inputs.observationTriage }}
503
+ DISPATCH_SURFACE_BLOCKERS: \${{ github.event.inputs.surfaceBlockers }}
471
504
  run: |
472
- [ -n "\${{ github.event.inputs.autoBuild }}" ] && echo "DORFL_AUTO_BUILD=\${{ github.event.inputs.autoBuild }}" >> "$GITHUB_ENV"
473
- [ -n "\${{ github.event.inputs.autoTask }}" ] && echo "DORFL_AUTO_TASK=\${{ github.event.inputs.autoTask }}" >> "$GITHUB_ENV"
474
- [ -n "\${{ github.event.inputs.observationTriage }}" ] && echo "DORFL_OBSERVATION_TRIAGE=\${{ github.event.inputs.observationTriage }}" >> "$GITHUB_ENV"
475
- [ -n "\${{ github.event.inputs.surfaceBlockers }}" ] && echo "DORFL_SURFACE_BLOCKERS=\${{ github.event.inputs.surfaceBlockers }}" >> "$GITHUB_ENV"
505
+ case "\${DISPATCH_AUTO_BUILD}\${DISPATCH_AUTO_TASK}\${DISPATCH_OBSERVATION_TRIAGE}\${DISPATCH_SURFACE_BLOCKERS}" in
506
+ *$'\\n'*|*$'\\r'*) echo "::error::a dispatch gate override contains a line break"; exit 1 ;;
507
+ esac
508
+ [ -n "\${DISPATCH_AUTO_BUILD}" ] && echo "DORFL_AUTO_BUILD=\${DISPATCH_AUTO_BUILD}" >> "$GITHUB_ENV"
509
+ [ -n "\${DISPATCH_AUTO_TASK}" ] && echo "DORFL_AUTO_TASK=\${DISPATCH_AUTO_TASK}" >> "$GITHUB_ENV"
510
+ [ -n "\${DISPATCH_OBSERVATION_TRIAGE}" ] && echo "DORFL_OBSERVATION_TRIAGE=\${DISPATCH_OBSERVATION_TRIAGE}" >> "$GITHUB_ENV"
511
+ [ -n "\${DISPATCH_SURFACE_BLOCKERS}" ] && echo "DORFL_SURFACE_BLOCKERS=\${DISPATCH_SURFACE_BLOCKERS}" >> "$GITHUB_ENV"
476
512
  true
477
513
  - name: advance one item in-place (merge ⇒ rebase + CAS land on main)
478
514
  # In-place (no --isolated/--remote). \`--merge\` ties the integration mode
@@ -483,10 +519,16 @@ jobs:
483
519
  # \`GH_TOKEN\` from the env.
484
520
  env:
485
521
  GH_TOKEN: \${{ secrets.DORFL_GH_TOKEN || secrets.GITHUB_TOKEN }}
522
+ # The item id is a slug read from work/ (frontmatter \`slug:\` or the
523
+ # file name). dorfl sanitises the slugs it mints, but a hand-written
524
+ # one is only checked when \`advance\` resolves it, so it reaches the
525
+ # shell as DATA through env, never as \`\${{ matrix.item }}\` text
526
+ # spliced into the script.
527
+ WORK_ITEM: \${{ matrix.item }}
486
528
  # \`--watch\` streams the build agent's high-signal turns into THIS job
487
529
  # log live; each leg names ONE item, so it fits the same way it does on
488
530
  # the propose legs.
489
- run: dorfl advance "\${{ matrix.item }}" --merge --watch --arbiter origin
531
+ run: dorfl advance "\${WORK_ITEM}" --merge --watch --arbiter origin
490
532
 
491
533
  # ── REAP merged remote work/* branches (capability F, the hygiene sweep) ─────
492
534
  # PRESERVED from the seed (NOT a separate gc-sweep workflow): the provider-
@@ -508,7 +550,7 @@ jobs:
508
550
  if: \${{ (github.event.inputs.sweepMergedBranches || 'true') == 'true' }}
509
551
  runs-on: ubuntu-latest
510
552
  steps:
511
- - uses: actions/checkout@v5
553
+ - uses: ${pinnedUses(ACTION_PINS.checkout)}
512
554
  with:
513
555
  fetch-depth: 0
514
556
  - uses: ./.github/actions/dorfl-setup
@@ -607,11 +649,19 @@ export function validateAdvanceLifecycleWorkflow(
607
649
  text,
608
650
  ), 'the matrix items must be ENUMERATED via the eligible-pool scan ' +
609
651
  '(`dorfl scan --json`).');
610
- require('propose-one-advance-per-item', /dorfl advance "?\$\{\{\s*matrix\./.test(
652
+ require('propose-one-advance-per-item', /WORK_ITEM:\s*\$\{\{\s*matrix\.item\s*\}\}[\s\S]*?dorfl advance "\$\{WORK_ITEM\}"/.test(
611
653
  text,
612
654
  ), 'each matrix leg must run one `dorfl advance <matrix item>` ' +
613
- '(one PR per item).');
614
- require('propose-leg-carries-propose-flag', /advance-propose:[\s\S]*?dorfl advance "?\$\{\{\s*matrix\.[\s\S]*?--propose\b/.test(
655
+ '(one PR per item), reading the item from the step env (`WORK_ITEM`).');
656
+ // The item id is a slug that may be hand-written (only minted slugs are
657
+ // sanitised), so it must reach the shell as DATA through `env:`, never as `${{ }}` text
658
+ // spliced into the `run:` script (GitHub Actions script injection).
659
+ require('matrix-item-not-spliced-into-run', !/dorfl advance "?\$\{\{/.test(
660
+ operative,
661
+ ), 'the matrix item must NOT be interpolated into the `run:` script as ' +
662
+ '`${{ matrix.item }}` (script injection): pass it through the step `env:` ' +
663
+ 'as `WORK_ITEM` and quote it as `"${WORK_ITEM}"`.');
664
+ require('propose-leg-carries-propose-flag', /advance-propose:[\s\S]*?dorfl advance "\$\{WORK_ITEM\}"[^\n]*--propose\b/.test(
615
665
  text,
616
666
  ), 'each `propose` matrix leg must pass `--propose` so the integration mode is ' +
617
667
  'TIED to the matrix shape (a leg can never merge to main / desync from the ' +
@@ -626,7 +676,7 @@ export function validateAdvanceLifecycleWorkflow(
626
676
  ), 'the `merge` job must use a MATRIX (parallel build/gate/review per item; ' +
627
677
  "the land tail is serialised by the engine's `mergeRetries` CAS-retry " +
628
678
  "loop, not by the workflow's job shape).");
629
- require('merge-leg-carries-merge-flag', /advance-merge:[\s\S]*?dorfl advance "?\$\{\{\s*matrix\.[\s\S]*?--merge\b/.test(
679
+ require('merge-leg-carries-merge-flag', /advance-merge:[\s\S]*?dorfl advance "\$\{WORK_ITEM\}"[^\n]*--merge\b/.test(
630
680
  text,
631
681
  ), 'each `merge` matrix leg must pass `--merge` so the integration mode is ' +
632
682
  'TIED to the matrix shape (a leg can never propose-only / desync from the ' +
@@ -692,9 +742,14 @@ export function validateAdvanceLifecycleWorkflow(
692
742
  ['observationTriage', 'DORFL_OBSERVATION_TRIAGE'],
693
743
  ['surfaceBlockers', 'DORFL_SURFACE_BLOCKERS'],
694
744
  ] as const) {
695
- // The override is a guarded shell write: `[ -n <input> ] && echo <ENV>=<input> >> $GITHUB_ENV`.
745
+ // The override is a guarded shell write of a step-env copy of the input:
746
+ // `env: DISPATCH_<X>: ${{ inputs.<x> }}` then
747
+ // `[ -n "${DISPATCH_<X>}" ] && echo "<ENV>=${DISPATCH_<X>}" >> $GITHUB_ENV`.
748
+ // The input never appears as `${{ }}` text inside the `run:` script.
749
+ const dispatchVar = envVar.replace(/^DORFL_/, 'DISPATCH_');
696
750
  const guardedWrite = new RegExp(
697
- `\\[ -n "\\$\\{\\{ github\\.event\\.inputs\\.${input} \\}\\}" \\][\\s\\S]*?${envVar}=`,
751
+ `${dispatchVar}:\\s*\\$\\{\\{ github\\.event\\.inputs\\.${input} \\}\\}` +
752
+ `[\\s\\S]*?\\[ -n "\\$\\{${dispatchVar}\\}" \\] && echo "${envVar}=\\$\\{${dispatchVar}\\}"`,
698
753
  );
699
754
  require(`dispatch-${input}-guarded-write`, guardedWrite.test(
700
755
  text,
package/src/cli.ts CHANGED
@@ -1108,8 +1108,8 @@ interface CloseMergedIssuesFlags {
1108
1108
  * it prints the error to stderr and exits 1 (the task-only commands never act on
1109
1109
  * a spec). An OMITTED slug (`start`/`complete`/`prompt` infer it from the branch)
1110
1110
  * passes through untouched. (The legacy ''prd:'' prefix is a DEAD namespace token
1111
- * after the hard cutover — it is neither special-cased nor rejected here; a
1112
- * ''prd:<slug>'' arg resolves as a bare literal task slug.)
1111
+ * after the hard cutover: it is not special-cased here; a ''prd:<slug>'' arg
1112
+ * is refused by the shared safe-slug check, since `:` is not a slug character.)
1113
1113
  *
1114
1114
  * `do` is the ONE command that spans both namespaces; it consumes the full
1115
1115
  * `resolveSlug` (with the cross-namespace collision check) in the `do-in-place`
@@ -33,7 +33,9 @@
33
33
  * the test generates this artifact under `--fake` and asserts every invariant.
34
34
  */
35
35
 
36
+ import {ACTION_PINS, pinnedUses} from './install-ci-action-pins.js';
36
37
  import type {ResolvedCIConfig} from './install-ci-core.js';
38
+ import {shouldDropCheckoutCredentials} from './install-ci-core.js';
37
39
 
38
40
  /** The capability id (the registry key + the emitted workflow file stem). */
39
41
  export const CLOSE_JOB_CAPABILITY_ID = 'close-job';
@@ -52,7 +54,19 @@ export const CLOSE_JOB_WORKFLOW_PATH = 'workflows/close-job.yml';
52
54
  * and future per-config wiring, but the close-job shape itself is
53
55
  * config-independent.
54
56
  */
55
- export function generateCloseJobWorkflow(_config: ResolvedCIConfig): string {
57
+ export function generateCloseJobWorkflow(config: ResolvedCIConfig): string {
58
+ // `persist-credentials: false` ONLY on a repo KNOWN to be public (see
59
+ // {@link shouldDropCheckoutCredentials}): this job has `contents: read`, so
60
+ // the persisted token can only ever read, and on a public repo reads need
61
+ // no token. Unknown / private / internal ⇒ today's checkout, unchanged.
62
+ const checkoutCredentials = shouldDropCheckoutCredentials(config)
63
+ ? `
64
+ # Public repository: every git read works without a token, and this
65
+ # job cannot push, so do not leave the token in .git/config for later
66
+ # steps (the setup action, including any project-setup hook, and
67
+ # close-merged-issues) to read.
68
+ persist-credentials: false`
69
+ : '';
56
70
  return `\
57
71
  # dorfl — the ISSUE CLOSE-JOB in CI (capability E, spec runner-in-ci).
58
72
  # EMITTED by \`dorfl install-ci\`; the human commits it. DO NOT hand-edit a
@@ -110,9 +124,9 @@ jobs:
110
124
  close-merged-issues:
111
125
  runs-on: ubuntu-latest
112
126
  steps:
113
- - uses: actions/checkout@v5
127
+ - uses: ${pinnedUses(ACTION_PINS.checkout)}
114
128
  with:
115
- fetch-depth: 0
129
+ fetch-depth: 0${checkoutCredentials}
116
130
  - uses: ./.github/actions/dorfl-setup
117
131
  - name: close issues whose work has landed on main
118
132
  # In-place in this checkout (no --isolated/--remote): the CI container IS
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The ONE table of third-party GitHub Actions that `dorfl install-ci` emits,
3
+ * each pinned to a full 40-character commit SHA, plus the regeneration rule that
4
+ * keeps a consumer repository's own pins.
5
+ *
6
+ * WHY SHAS. A tag such as `@v5` can be moved by whoever controls the action's
7
+ * repository, so a compromised or careless release reaches every job that uses
8
+ * it, including dorfl's jobs that hold `contents: write` and a provider API key.
9
+ * A commit SHA cannot move. This is GitHub's own hardening guidance ("Using
10
+ * third-party actions", Security hardening for GitHub Actions).
11
+ *
12
+ * THE FORMAT. Every reference is emitted as `owner/repo@<40-hex-sha> # vX.Y.Z`,
13
+ * the exact shape Dependabot writes and rewrites, so a consumer that enables
14
+ * Dependabot's `github-actions` ecosystem (with `/.github/actions/*` in
15
+ * `directories` for the composite action) gets reviewable bump PRs.
16
+ *
17
+ * HOW EACH SHA WAS RESOLVED (2026-09-26). For each tag:
18
+ * 1. `git ls-remote --tags https://github.com/<owner>/<repo>.git`, reading the
19
+ * peeled `refs/tags/<tag>^{}` line when the tag is annotated (that line is
20
+ * the COMMIT; the unpeeled line is the tag OBJECT and must never be used),
21
+ * else the tag line itself for a lightweight tag;
22
+ * 2. cross-checked with `gh api repos/<owner>/<repo>/git/ref/tags/<tag>`
23
+ * (object type) and `gh api repos/<owner>/<repo>/commits/<sha>` (the SHA is
24
+ * a commit that exists in the repository).
25
+ *
26
+ * actions/checkout v7.0.1: lightweight tag, `git/ref` type `commit`,
27
+ * 3d3c42e5aac5ba805825da76410c181273ba90b1 ("prep v7.0.1 release (#2531)").
28
+ * The floating `v7` tag points at the same commit. v7 is the current major
29
+ * and adds the fork-PR checkout block for `pull_request_target` /
30
+ * `workflow_run` that v5 lacks; the inputs dorfl passes (`fetch-depth`,
31
+ * `persist-credentials`) are unchanged.
32
+ * actions/setup-node v7.0.0: lightweight tag, `git/ref` type `commit`,
33
+ * 820762786026740c76f36085b0efc47a31fe5020 ("Migrate to ESM and upgrade
34
+ * dependencies (#1574)"). The floating `v7` tag points at the same commit.
35
+ * `node-version` and `package-manager-cache` are still inputs (see the
36
+ * comment on the setup-node step in `generateSetupAction`).
37
+ * pnpm/action-setup v6.1.0: ANNOTATED tag. `git/ref` type `tag` with object
38
+ * d9184bf108216479bc5a137cc391f4d7b14c870b (the tag object, NOT usable);
39
+ * peeled `^{}` and `gh api .../git/tags/d9184bf...` both give the commit
40
+ * ea17c68df8912ef543352723c149a84f56e3d413 ("feat: support pnpm v12
41
+ * (#288)"). With no `version` input it still reads `packageManager` from
42
+ * package.json, which is how dorfl's workspace mode uses it.
43
+ *
44
+ * To bump a pin: resolve the new tag the same way, update the entry, and record
45
+ * the resolution above. `install-ci-actions-pinned.test.ts` fails on any
46
+ * emitted `uses:` that is not a full SHA.
47
+ */
48
+
49
+ /** One pinned third-party action. */
50
+ export interface ActionPin {
51
+ /** `owner/repo`, or `owner/repo/path` for an action in a subdirectory. */
52
+ readonly action: string;
53
+ /** The full 40-character lowercase commit SHA (never a tag object). */
54
+ readonly sha: string;
55
+ /** The release tag the SHA was resolved from, for the trailing comment. */
56
+ readonly version: string;
57
+ }
58
+
59
+ /** Every third-party action dorfl's generators emit. */
60
+ export const ACTION_PINS = {
61
+ checkout: {
62
+ action: 'actions/checkout',
63
+ sha: '3d3c42e5aac5ba805825da76410c181273ba90b1',
64
+ version: 'v7.0.1',
65
+ },
66
+ setupNode: {
67
+ action: 'actions/setup-node',
68
+ sha: '820762786026740c76f36085b0efc47a31fe5020',
69
+ version: 'v7.0.0',
70
+ },
71
+ pnpmSetup: {
72
+ action: 'pnpm/action-setup',
73
+ sha: 'ea17c68df8912ef543352723c149a84f56e3d413',
74
+ version: 'v6.1.0',
75
+ },
76
+ } as const satisfies Record<string, ActionPin>;
77
+
78
+ /**
79
+ * The value to put after `uses: ` for a pinned action:
80
+ * `owner/repo@<sha> # vX.Y.Z` (the Dependabot format).
81
+ */
82
+ export function pinnedUses(pin: ActionPin): string {
83
+ return `${pin.action}@${pin.sha} # ${pin.version}`;
84
+ }
85
+
86
+ // ─── preserving the consumer's pins on regeneration ─────────────────────────
87
+
88
+ /**
89
+ * A `uses:` step line: the prefix (indentation, an optional `- ` list marker,
90
+ * `uses:` and its spacing), then the VALUE (an optionally quoted
91
+ * `action@ref`), then an optional trailing comment. Matched per line, which is
92
+ * how both dorfl's templates and Dependabot write these lines. A flow-style or
93
+ * multi-line `uses:` value is not recognised (and so not preserved).
94
+ */
95
+ const USES_LINE =
96
+ /^(\s*(?:-\s+)?uses:[ \t]+)((['"]?)([^\s'"@#]+)@([^\s'"#]+)\3(?:[ \t]+#.*)?)[ \t]*$/;
97
+
98
+ /**
99
+ * A full commit SHA (what counts as "pinned"). Case-insensitive here, so a
100
+ * consumer's uppercase pin is still kept; dorfl itself emits lowercase.
101
+ */
102
+ const FULL_SHA = /^[0-9a-f]{40}$/i;
103
+
104
+ /** The lookup key for an action: GitHub treats `owner/repo` case-insensitively. */
105
+ function actionKey(action: string): string {
106
+ return action.toLowerCase();
107
+ }
108
+
109
+ /** A remote action key (`owner/repo[/path]`), i.e. not `./local` nor `docker://`. */
110
+ function isRemoteAction(action: string): boolean {
111
+ return !action.startsWith('.') && /^[^/\s]+\/[^/\s]+/.test(action);
112
+ }
113
+
114
+ /** Parse the version out of a trailing `# v1.2.3` comment, for ranking. */
115
+ function versionOf(tail: string): number[] | undefined {
116
+ const m = /#\s*v?(\d+(?:\.\d+)*)/.exec(tail);
117
+ return m ? m[1].split('.').map(Number) : undefined;
118
+ }
119
+
120
+ /** Compare two parsed versions; a missing version ranks lowest. */
121
+ function compareVersions(a?: number[], b?: number[]): number {
122
+ if (!a || !b) return (a ? 1 : 0) - (b ? 1 : 0);
123
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
124
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
125
+ if (d !== 0) return d;
126
+ }
127
+ return 0;
128
+ }
129
+
130
+ /**
131
+ * Collect the SHA pins an existing file already carries, keyed by action
132
+ * (`owner/repo[/path]`). The value is the whole `uses:` value with its trailing
133
+ * comment, exactly as written. When the file pins the same action to several
134
+ * different SHAs, the one whose `# vX.Y.Z` comment is highest wins (ties: the
135
+ * first in the file), so a partial manual bump is never undone.
136
+ */
137
+ export function existingPins(existing: string): Map<string, string> {
138
+ const pins = new Map<string, string>();
139
+ // `\r?\n`: a CRLF checkout (Windows, `core.autocrlf`) must not hide its pins,
140
+ // or dorfl's default would silently replace them.
141
+ for (const line of existing.split(/\r?\n/)) {
142
+ const m = USES_LINE.exec(line);
143
+ if (!m) continue;
144
+ const [, , value, , action, ref] = m;
145
+ if (!isRemoteAction(action) || !FULL_SHA.test(ref)) continue;
146
+ const key = actionKey(action);
147
+ const current = pins.get(key);
148
+ if (
149
+ current === undefined ||
150
+ compareVersions(versionOf(value), versionOf(current)) > 0
151
+ ) {
152
+ pins.set(key, value);
153
+ }
154
+ }
155
+ return pins;
156
+ }
157
+
158
+ /**
159
+ * Rewrite a freshly generated workflow / composite action so every `uses:` of an
160
+ * action that `existing` already pins to a full SHA keeps the consumer's
161
+ * reference and trailing comment. Matching is by action (`owner/repo`, plus the
162
+ * path for an action in a subdirectory), never by position. An existing TAG is
163
+ * not a pin: the generated reference (dorfl's SHA) stays. So the result never
164
+ * swaps a SHA for a tag. Only the value after `uses: ` changes; indentation and
165
+ * every other line are the generated ones.
166
+ */
167
+ export function preserveExistingPins(
168
+ generated: string,
169
+ existing: string | undefined,
170
+ ): string {
171
+ if (existing === undefined) return generated;
172
+ const pins = existingPins(existing);
173
+ if (pins.size === 0) return generated;
174
+ return generated
175
+ .split('\n')
176
+ .map((line) => {
177
+ const m = USES_LINE.exec(line);
178
+ if (!m) return line;
179
+ const [, prefix, , , action] = m;
180
+ const kept = pins.get(actionKey(action));
181
+ return kept === undefined ? line : `${prefix}${kept}`;
182
+ })
183
+ .join('\n');
184
+ }