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.
- package/dist/advance-ci-template.d.ts.map +1 -1
- package/dist/advance-ci-template.js +10 -4
- package/dist/advance-ci-template.js.map +1 -1
- package/dist/advance-lifecycle-template.d.ts.map +1 -1
- package/dist/advance-lifecycle-template.js +78 -25
- package/dist/advance-lifecycle-template.js.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/close-job-template.d.ts +1 -1
- package/dist/close-job-template.d.ts.map +1 -1
- package/dist/close-job-template.js +17 -3
- package/dist/close-job-template.js.map +1 -1
- package/dist/install-ci-action-pins.d.ts +98 -0
- package/dist/install-ci-action-pins.d.ts.map +1 -0
- package/dist/install-ci-action-pins.js +165 -0
- package/dist/install-ci-action-pins.js.map +1 -0
- package/dist/install-ci-core.d.ts +84 -0
- package/dist/install-ci-core.d.ts.map +1 -1
- package/dist/install-ci-core.js +120 -14
- package/dist/install-ci-core.js.map +1 -1
- package/dist/install-ci-github.d.ts +15 -1
- package/dist/install-ci-github.d.ts.map +1 -1
- package/dist/install-ci-github.js +32 -0
- package/dist/install-ci-github.js.map +1 -1
- package/dist/install-ci.d.ts.map +1 -1
- package/dist/install-ci.js +21 -0
- package/dist/install-ci.js.map +1 -1
- package/dist/intake-trigger-template.d.ts.map +1 -1
- package/dist/intake-trigger-template.js +24 -7
- package/dist/intake-trigger-template.js.map +1 -1
- package/dist/intake.d.ts.map +1 -1
- package/dist/intake.js +10 -5
- package/dist/intake.js.map +1 -1
- package/dist/mint-adr.d.ts.map +1 -1
- package/dist/mint-adr.js +4 -1
- package/dist/mint-adr.js.map +1 -1
- package/dist/slug-namespace.d.ts +4 -3
- package/dist/slug-namespace.d.ts.map +1 -1
- package/dist/slug-namespace.js +28 -3
- package/dist/slug-namespace.js.map +1 -1
- package/dist/slug-safety.d.ts +55 -0
- package/dist/slug-safety.d.ts.map +1 -0
- package/dist/slug-safety.js +76 -0
- package/dist/slug-safety.js.map +1 -0
- package/dist/triage-persist.d.ts.map +1 -1
- package/dist/triage-persist.js +12 -3
- package/dist/triage-persist.js.map +1 -1
- package/dist/verify-workflow-template.d.ts +1 -1
- package/dist/verify-workflow-template.d.ts.map +1 -1
- package/dist/verify-workflow-template.js +16 -3
- package/dist/verify-workflow-template.js.map +1 -1
- package/package.json +3 -2
- package/src/advance-ci-template.ts +12 -4
- package/src/advance-lifecycle-template.ts +80 -25
- package/src/cli.ts +2 -2
- package/src/close-job-template.ts +17 -3
- package/src/install-ci-action-pins.ts +184 -0
- package/src/install-ci-core.ts +166 -14
- package/src/install-ci-github.ts +40 -1
- package/src/install-ci.ts +25 -0
- package/src/intake-trigger-template.ts +28 -7
- package/src/intake.ts +10 -5
- package/src/mint-adr.ts +4 -1
- package/src/slug-namespace.ts +33 -5
- package/src/slug-safety.ts +83 -0
- package/src/triage-persist.ts +13 -7
- 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:
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
[ -n "\${
|
|
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:
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
[ -n "\${
|
|
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 "\${
|
|
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:
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
[ -n "\${
|
|
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 "\${
|
|
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:
|
|
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 "
|
|
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
|
-
|
|
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 "
|
|
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
|
|
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
|
-
|
|
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
|
|
1112
|
-
*
|
|
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(
|
|
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:
|
|
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
|
+
}
|