dorfl 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +73 -21
- 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 +15 -2
- package/dist/close-job-template.js.map +1 -1
- package/dist/install-ci-core.d.ts +77 -0
- package/dist/install-ci-core.d.ts.map +1 -1
- package/dist/install-ci-core.js +94 -4
- 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 +22 -6
- 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/skills/one-shot/SKILL.md +22 -15
- 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 +14 -2
- 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 +75 -21
- package/src/cli.ts +2 -2
- package/src/close-job-template.ts +15 -2
- package/src/install-ci-core.ts +130 -4
- package/src/install-ci-github.ts +40 -1
- package/src/install-ci.ts +25 -0
- package/src/intake-trigger-template.ts +26 -6
- 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 +14 -2
package/src/install-ci-core.ts
CHANGED
|
@@ -141,6 +141,44 @@ export interface ResolvedCIConfig {
|
|
|
141
141
|
projectSetup?: Record<string, unknown>;
|
|
142
142
|
/** See {@link CIConfigFile.maxParallel}. Always resolved (default applied). */
|
|
143
143
|
maxParallel: number;
|
|
144
|
+
/**
|
|
145
|
+
* The repo's visibility, DETECTED at generation time through the provider
|
|
146
|
+
* seam ({@link CIProviderContext.getRepoVisibility}); never read from or
|
|
147
|
+
* written to a config file (it is a fact about the repo, not a choice).
|
|
148
|
+
* `undefined` ⇒ unknown, which keeps the conservative output. See
|
|
149
|
+
* {@link shouldDropCheckoutCredentials}.
|
|
150
|
+
*/
|
|
151
|
+
repoVisibility?: RepoVisibility;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A repository's visibility on its CI provider. Used ONLY to decide whether the
|
|
156
|
+
* read-only jobs (verify, close-job) may check out with
|
|
157
|
+
* `persist-credentials: false`: on a PUBLIC repo every git read works without a
|
|
158
|
+
* token, so dropping the persisted token cannot break a fetch; on a private or
|
|
159
|
+
* internal repo (or when visibility is unknown) a fetch may need it.
|
|
160
|
+
*/
|
|
161
|
+
export type RepoVisibility = 'public' | 'private' | 'internal';
|
|
162
|
+
|
|
163
|
+
/** Parse a provider's visibility answer (`PUBLIC`, `private`, ...); unknown ⇒ `undefined`. */
|
|
164
|
+
export function parseRepoVisibility(raw: string): RepoVisibility | undefined {
|
|
165
|
+
const v = raw.trim().toLowerCase();
|
|
166
|
+
return v === 'public' || v === 'private' || v === 'internal' ? v : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Whether the read-only jobs (verify, close-job) should check out with
|
|
171
|
+
* `persist-credentials: false`. True ONLY when the repo is KNOWN to be public:
|
|
172
|
+
* then the persisted token adds nothing a git read needs (those jobs have
|
|
173
|
+
* `contents: read`, so they cannot push either way), while leaving it in
|
|
174
|
+
* `.git/config` hands it to every later step, including the project-setup
|
|
175
|
+
* hook's dependency installs. Unknown or non-public ⇒ keep today's checkout, so
|
|
176
|
+
* a project gate that runs `git fetch` on a private repo keeps working.
|
|
177
|
+
*/
|
|
178
|
+
export function shouldDropCheckoutCredentials(
|
|
179
|
+
config: Pick<ResolvedCIConfig, 'repoVisibility'>,
|
|
180
|
+
): boolean {
|
|
181
|
+
return config.repoVisibility === 'public';
|
|
144
182
|
}
|
|
145
183
|
|
|
146
184
|
/** The default harness the composite setup action installs. */
|
|
@@ -222,6 +260,13 @@ export interface CIProviderContext {
|
|
|
222
260
|
* no default-branch concept.
|
|
223
261
|
*/
|
|
224
262
|
getDefaultBranch?(): Promise<string | undefined>;
|
|
263
|
+
/**
|
|
264
|
+
* OPTIONALLY report the repo's VISIBILITY (GitHub:
|
|
265
|
+
* `gh repo view --json visibility`). Read-only. Returns `undefined` when the
|
|
266
|
+
* lookup fails; absent on a provider with no such concept. Drives
|
|
267
|
+
* {@link shouldDropCheckoutCredentials}.
|
|
268
|
+
*/
|
|
269
|
+
getRepoVisibility?(): Promise<RepoVisibility | undefined>;
|
|
225
270
|
/**
|
|
226
271
|
* OPTIONALLY create/replace a branch RULESET (GitHub:
|
|
227
272
|
* `POST /repos/{owner}/{repo}/rulesets`) carrying the deadlock-guard
|
|
@@ -673,6 +718,75 @@ function indent(text: string, spaces: number): string {
|
|
|
673
718
|
.join('\n');
|
|
674
719
|
}
|
|
675
720
|
|
|
721
|
+
/**
|
|
722
|
+
* The npm package the composite setup action installs for the `pi` harness.
|
|
723
|
+
*
|
|
724
|
+
* Upstream moved the harness from `@mariozechner/pi-coding-agent` (deprecated,
|
|
725
|
+
* frozen at 0.73.1) to this name. The binary is still `pi` and it still reads
|
|
726
|
+
* `~/.pi/agent/` (models.json / auth.json), so nothing else in the generated
|
|
727
|
+
* action changes.
|
|
728
|
+
*/
|
|
729
|
+
export const PI_HARNESS_PACKAGE = '@earendil-works/pi-coding-agent';
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The EXACT `pi` harness version the composite setup action installs.
|
|
733
|
+
*
|
|
734
|
+
* Pinned because the install runs in jobs that hold `contents: write` and a
|
|
735
|
+
* provider API key: an unpinned `npm install -g` resolves whatever `latest` is
|
|
736
|
+
* at RUN time, so a compromised or broken release would reach those
|
|
737
|
+
* credentials with no review and no lockfile to catch it.
|
|
738
|
+
*
|
|
739
|
+
* It is a constant dorfl DECLARES (not an install-ci parameter) because the
|
|
740
|
+
* harness is coupled to dorfl, not to the consumer: dorfl drives `pi` through
|
|
741
|
+
* its CLI flags and reads its session files (`session-path.ts`,
|
|
742
|
+
* `watch-session.ts`), so the harness version that works is a property of the
|
|
743
|
+
* dorfl release. Bump it here, with a changeset, when a dorfl release needs a
|
|
744
|
+
* newer harness; every consumer picks it up on their next `install-ci` run.
|
|
745
|
+
*
|
|
746
|
+
* 0.80.6 is the version dorfl is exercised against day to day (the maintainer's
|
|
747
|
+
* installed `pi`), not the newest release: a harness upgrade should be a
|
|
748
|
+
* deliberate, tested bump. It requires Node >= 22.19.0, which the action's
|
|
749
|
+
* `node-version: '22'` satisfies (setup-node resolves the latest 22.x).
|
|
750
|
+
*/
|
|
751
|
+
export const PI_HARNESS_VERSION = '0.80.6';
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* The version of THIS dorfl package, read at runtime from its own
|
|
755
|
+
* `package.json` (the single source changesets bumps on release). The composite
|
|
756
|
+
* setup action installs `dorfl@<this version>` so a consumer's CI runs the SAME
|
|
757
|
+
* CLI that generated its workflows, instead of whatever `latest` is at run time.
|
|
758
|
+
*
|
|
759
|
+
* Resolved relative to this module (`dist/install-ci-core.js` or
|
|
760
|
+
* `src/install-ci-core.ts` → `../package.json`), so it works from the built CLI
|
|
761
|
+
* and under tsx. Throws when the version cannot be read: silently falling back
|
|
762
|
+
* to an unpinned install would reintroduce exactly the risk the pin removes.
|
|
763
|
+
*/
|
|
764
|
+
export function dorflPackageVersion(): string {
|
|
765
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
766
|
+
const pkgPath = join(here, '..', 'package.json');
|
|
767
|
+
let version: unknown;
|
|
768
|
+
try {
|
|
769
|
+
version = (JSON.parse(readFileSync(pkgPath, 'utf8')) as {version?: unknown})
|
|
770
|
+
.version;
|
|
771
|
+
} catch (err) {
|
|
772
|
+
throw new Error(
|
|
773
|
+
`install-ci: cannot read dorfl's own version from ${pkgPath} ` +
|
|
774
|
+
`(needed to pin \`npm install -g dorfl@<version>\`): ` +
|
|
775
|
+
(err instanceof Error ? err.message : String(err)),
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
if (
|
|
779
|
+
typeof version !== 'string' ||
|
|
780
|
+
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/.test(version)
|
|
781
|
+
) {
|
|
782
|
+
throw new Error(
|
|
783
|
+
`install-ci: dorfl's package.json at ${pkgPath} has no usable semver ` +
|
|
784
|
+
`"version" (got ${JSON.stringify(version)}); cannot pin the CI install.`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
return version;
|
|
788
|
+
}
|
|
789
|
+
|
|
676
790
|
/**
|
|
677
791
|
* The install step for the configured harness (the `pi` CLI; `''` ⇒ none).
|
|
678
792
|
* `registry` mode installs via `npm install -g`; `workspace` mode installs via
|
|
@@ -684,11 +798,15 @@ function harnessInstallStep(
|
|
|
684
798
|
installSource: InstallSource,
|
|
685
799
|
): string {
|
|
686
800
|
if (harness === 'pi') {
|
|
801
|
+
const spec = `${PI_HARNESS_PACKAGE}@${PI_HARNESS_VERSION}`;
|
|
687
802
|
const run =
|
|
688
803
|
installSource === 'workspace'
|
|
689
|
-
?
|
|
690
|
-
:
|
|
804
|
+
? `pnpm add -g ${spec}`
|
|
805
|
+
: `npm install -g ${spec}`;
|
|
691
806
|
return `
|
|
807
|
+
# Pinned to the exact harness version this dorfl release declares
|
|
808
|
+
# (PI_HARNESS_VERSION): the job holds write access and a provider key, so the
|
|
809
|
+
# version must not move on its own. Re-run \`dorfl install-ci\` to upgrade.
|
|
692
810
|
- name: Install agent harness (pi)
|
|
693
811
|
shell: bash
|
|
694
812
|
run: ${run}`;
|
|
@@ -815,17 +933,25 @@ ${indent(modelsJsonStr, 8)}
|
|
|
815
933
|
pnpm setup
|
|
816
934
|
echo "$HOME/.local/share/pnpm" >> "$GITHUB_PATH"
|
|
817
935
|
|
|
936
|
+
# \`--ignore-scripts\`: nothing here needs an install-time script. The build is
|
|
937
|
+
# explicit (\`pnpm -r build\` below; the root \`prepare\` would only repeat it),
|
|
938
|
+
# and verify runs on fork pull requests, where a lifecycle script would run
|
|
939
|
+
# the fork's code before any gate does.
|
|
818
940
|
- name: Install dependencies and build dorfl
|
|
819
941
|
shell: bash
|
|
820
942
|
run: |
|
|
821
|
-
pnpm install
|
|
943
|
+
pnpm install --ignore-scripts
|
|
822
944
|
pnpm -r build
|
|
823
945
|
cd packages/dorfl && pnpm link --global${installHarness}`;
|
|
824
946
|
} else {
|
|
825
947
|
installSteps = `\
|
|
948
|
+
# Pinned to the dorfl version that generated this action, so CI runs the
|
|
949
|
+
# same CLI that wrote these workflows (and never an unreviewed \`latest\` in a
|
|
950
|
+
# job holding write access and a provider key). Re-run \`dorfl install-ci\`
|
|
951
|
+
# after upgrading dorfl to move the pin.
|
|
826
952
|
- name: Install dorfl
|
|
827
953
|
shell: bash
|
|
828
|
-
run: npm install -g dorfl${installHarness}`;
|
|
954
|
+
run: npm install -g dorfl@${dorflPackageVersion()}${installHarness}`;
|
|
829
955
|
}
|
|
830
956
|
|
|
831
957
|
// NO bespoke CI resolver shim (task install-ci-shim-converges-on-dorfl-cmd,
|
package/src/install-ci-github.ts
CHANGED
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
import {run, type RunResult} from './git.js';
|
|
18
18
|
import {DEFAULT_GH_BIN} from './github.js';
|
|
19
|
-
import
|
|
19
|
+
import {
|
|
20
|
+
parseRepoVisibility,
|
|
21
|
+
type CIProviderContext,
|
|
22
|
+
type RepoVisibility,
|
|
23
|
+
} from './install-ci-core.js';
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* The provider id this adapter exposes through {@link CIProviderContext.providerId}.
|
|
@@ -265,6 +269,32 @@ export class GitHubCIContext implements CIProviderContext {
|
|
|
265
269
|
return name === '' ? undefined : name;
|
|
266
270
|
}
|
|
267
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Detect the repo's VISIBILITY via
|
|
274
|
+
* `gh repo view --json visibility --jq .visibility` (GitHub answers
|
|
275
|
+
* `PUBLIC` / `PRIVATE` / `INTERNAL`). Returns `undefined` when `gh` is
|
|
276
|
+
* unavailable / the repo is unknown / the call fails / the answer is not one
|
|
277
|
+
* of those three, so the caller keeps the conservative (credential-persisting)
|
|
278
|
+
* checkout. Read-only.
|
|
279
|
+
*/
|
|
280
|
+
async getRepoVisibility(): Promise<RepoVisibility | undefined> {
|
|
281
|
+
if (!this.repo) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
const result = this.runGh([
|
|
285
|
+
'repo',
|
|
286
|
+
'view',
|
|
287
|
+
'--json',
|
|
288
|
+
'visibility',
|
|
289
|
+
'--jq',
|
|
290
|
+
'.visibility',
|
|
291
|
+
]);
|
|
292
|
+
if (result === undefined || result.status !== 0) {
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
return parseRepoVisibility(result.stdout);
|
|
296
|
+
}
|
|
297
|
+
|
|
268
298
|
/**
|
|
269
299
|
* Create the deadlock-guard RULESET via
|
|
270
300
|
* `POST /repos/{owner}/{repo}/rulesets`. The `ruleset` body is JSON-piped on
|
|
@@ -388,6 +418,8 @@ export class MemoryCIProviderContext implements CIProviderContext {
|
|
|
388
418
|
private readonly adminScope: boolean | undefined;
|
|
389
419
|
/** What `getDefaultBranch()` should return (tests configure). */
|
|
390
420
|
private readonly defaultBranch: string | undefined;
|
|
421
|
+
/** What `getRepoVisibility()` should return (tests configure). */
|
|
422
|
+
private readonly visibility: RepoVisibility | undefined;
|
|
391
423
|
/** Whether `setBranchProtection` should throw (tests configure). */
|
|
392
424
|
private readonly branchProtectionError: string | undefined;
|
|
393
425
|
/** Whether `setBranchRuleset` should throw (tests configure). */
|
|
@@ -404,6 +436,8 @@ export class MemoryCIProviderContext implements CIProviderContext {
|
|
|
404
436
|
* exercises its `main` fallback (models a `gh` that could not resolve it).
|
|
405
437
|
*/
|
|
406
438
|
defaultBranch?: string;
|
|
439
|
+
/** Fixture repo visibility; `undefined` ⇒ unknown (lookup failed / no gh). */
|
|
440
|
+
visibility?: RepoVisibility;
|
|
407
441
|
/** When set, `setBranchProtection` throws with this message (failure path). */
|
|
408
442
|
branchProtectionError?: string;
|
|
409
443
|
/** When set, `setBranchRuleset` throws with this message (failure path). */
|
|
@@ -421,6 +455,7 @@ export class MemoryCIProviderContext implements CIProviderContext {
|
|
|
421
455
|
this.ghAvailable = options.ghAvailable ?? false;
|
|
422
456
|
this.adminScope = options.adminScope;
|
|
423
457
|
this.defaultBranch = options.defaultBranch;
|
|
458
|
+
this.visibility = options.visibility;
|
|
424
459
|
this.branchProtectionError = options.branchProtectionError;
|
|
425
460
|
this.branchRulesetError = options.branchRulesetError;
|
|
426
461
|
this.providerId = options.providerId ?? GITHUB_PROVIDER_ID;
|
|
@@ -446,6 +481,10 @@ export class MemoryCIProviderContext implements CIProviderContext {
|
|
|
446
481
|
return this.defaultBranch;
|
|
447
482
|
}
|
|
448
483
|
|
|
484
|
+
async getRepoVisibility(): Promise<RepoVisibility | undefined> {
|
|
485
|
+
return this.visibility;
|
|
486
|
+
}
|
|
487
|
+
|
|
449
488
|
async setBranchProtection(branch: string, spec: unknown): Promise<void> {
|
|
450
489
|
if (this.branchProtectionError !== undefined) {
|
|
451
490
|
throw new Error(this.branchProtectionError);
|
package/src/install-ci.ts
CHANGED
|
@@ -193,6 +193,31 @@ export async function installCI(
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// 2c. Detect the repo's VISIBILITY (read-only; also in --fake, like the repo
|
|
197
|
+
// slug detection). It decides whether the read-only jobs (verify,
|
|
198
|
+
// close-job) check out with `persist-credentials: false`, which is only
|
|
199
|
+
// provably safe on a PUBLIC repo (every git read works without a token).
|
|
200
|
+
// Detected AFTER the export early-return: it is a fact about the repo,
|
|
201
|
+
// never a config value, so it is not baked into an exported config.
|
|
202
|
+
if (options.ctx.getRepoVisibility) {
|
|
203
|
+
const visibility = await options.ctx
|
|
204
|
+
.getRepoVisibility()
|
|
205
|
+
.catch(() => undefined);
|
|
206
|
+
if (visibility !== undefined) {
|
|
207
|
+
config.repoVisibility = visibility;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (config.repoVisibility === 'public') {
|
|
211
|
+
log(
|
|
212
|
+
'🔒 Public repository: verify + close-job check out with persist-credentials: false (no token left in .git/config).',
|
|
213
|
+
);
|
|
214
|
+
} else {
|
|
215
|
+
log(
|
|
216
|
+
`🔒 Repository visibility ${config.repoVisibility ?? 'unknown'}: verify + close-job keep the checkout token ` +
|
|
217
|
+
'(a git fetch in your gate or project-setup hook may need it). Re-run install-ci if the repo becomes public.',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
196
221
|
// 3. Orchestrate secrets — SKIPPED in --fake mode (no real secret touched).
|
|
197
222
|
let secrets: SecretSetResult[] = [];
|
|
198
223
|
if (fake) {
|
|
@@ -463,13 +463,23 @@ jobs:
|
|
|
463
463
|
# lone-task review/edit loop and posts its findings as questions back into
|
|
464
464
|
# THIS issue thread (insertion point E) through the issue-comment seam —
|
|
465
465
|
# CI surfaces E by invoking intake; it adds no new review mechanism.
|
|
466
|
+
#
|
|
467
|
+
# Every value reaches the shell through \`env:\` as DATA and is read back as a
|
|
468
|
+
# quoted variable, never as \`\${{ }}\` text spliced into the script
|
|
469
|
+
# (GitHub's script-injection guidance). The issue number is an integer and
|
|
470
|
+
# the policy outputs are fixed flags today; routing them through env anyway
|
|
471
|
+
# keeps "no \`\${{ }}\` inside \`run:\`" a rule with no exceptions.
|
|
466
472
|
env:
|
|
467
473
|
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
474
|
+
ISSUE_NUMBER: \${{ github.event.issue.number }}
|
|
475
|
+
SPEC_FLAG: \${{ steps.policy.outputs.spec_flag }}
|
|
476
|
+
TASK_FLAG: \${{ steps.policy.outputs.task_flag }}
|
|
477
|
+
ORIGIN_TRUST_FLAG: \${{ steps.policy.outputs.origin_trust_flag }}
|
|
468
478
|
run: |
|
|
469
|
-
dorfl intake "\${
|
|
470
|
-
"\${
|
|
471
|
-
"\${
|
|
472
|
-
"\${
|
|
479
|
+
dorfl intake "\${ISSUE_NUMBER}" \\
|
|
480
|
+
"\${SPEC_FLAG}" \\
|
|
481
|
+
"\${TASK_FLAG}" \\
|
|
482
|
+
"\${ORIGIN_TRUST_FLAG}" \\
|
|
473
483
|
--arbiter origin
|
|
474
484
|
`;
|
|
475
485
|
}
|
|
@@ -521,10 +531,20 @@ export function validateIntakeWorkflow(text: string): IntakeTriggerValidation {
|
|
|
521
531
|
'schedules).');
|
|
522
532
|
// The issue number rides the explicit positional — never a bare slug, and the
|
|
523
533
|
// number comes from the event payload (the issue under intake).
|
|
524
|
-
require('intake-explicit-issue-number', /
|
|
534
|
+
require('intake-explicit-issue-number', /ISSUE_NUMBER:\s*\$\{\{\s*github\.event\.issue\.number\s*\}\}[\s\S]*?dorfl intake "\$\{ISSUE_NUMBER\}"/.test(
|
|
525
535
|
operative,
|
|
526
536
|
), 'the intake invocation must pass the explicit issue NUMBER ' +
|
|
527
|
-
'(`github.event.issue.number`), never a bare slug.');
|
|
537
|
+
'(`github.event.issue.number`, via the step env as `ISSUE_NUMBER`), never a bare slug.');
|
|
538
|
+
// No `${{ }}` spliced into the intake `run:` script (script injection): the
|
|
539
|
+
// number and the policy flags reach the shell through the step `env:`.
|
|
540
|
+
require('intake-args-not-spliced-into-run', !/dorfl intake "?\$\{\{/.test(
|
|
541
|
+
operative,
|
|
542
|
+
) &&
|
|
543
|
+
!/"\$\{\{\s*steps\.policy\.outputs\./.test(
|
|
544
|
+
operative,
|
|
545
|
+
), 'the intake invocation must NOT interpolate `${{ github.event.issue.number }}` ' +
|
|
546
|
+
'or `${{ steps.policy.outputs.* }}` into its `run:` script: pass them through ' +
|
|
547
|
+
'the step `env:` and quote the variables.');
|
|
528
548
|
// CI owns ONLY the trigger/policy/delivery — it must NOT invoke a build/task
|
|
529
549
|
// verb (that is the build/task tick), nor re-implement the transform.
|
|
530
550
|
require('no-build-verbs', !/dorfl (?:do|advance)\b/.test(
|
package/src/intake.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {dirname, join} from 'node:path';
|
|
|
3
3
|
import {runAsync, type RunResult} from './git.js';
|
|
4
4
|
import {workFolderRel, workFolderPrefix} from './work-layout.js';
|
|
5
5
|
import {paramCase} from './brand.js';
|
|
6
|
+
import {ensureSafeSlug} from './slug-safety.js';
|
|
6
7
|
import {
|
|
7
8
|
performIntegration,
|
|
8
9
|
type IntegrationCoreResult,
|
|
@@ -1714,28 +1715,32 @@ async function postCompletionComment(params: {
|
|
|
1714
1715
|
* Resolve a content-derived slug from the verdict — NEVER a counter (spec `issue-intake` US #8).
|
|
1715
1716
|
* Prefer the drafted `taskSlug`, else derive from the drafted title; both go
|
|
1716
1717
|
* through `paramCase` (the brand case-transform) so the result is a clean
|
|
1717
|
-
* lowercase-`-`-joined slug
|
|
1718
|
-
*
|
|
1718
|
+
* lowercase-`-`-joined slug, and then through {@link ensureSafeSlug}: the draft
|
|
1719
|
+
* comes from issue content, and `paramCase` alone keeps characters like `$(`,
|
|
1720
|
+
* `"`, `;` and `/` that must never reach a file name, a git ref or a CI command
|
|
1721
|
+
* line. An empty result (no slug AND no title) signals the caller to refuse (a
|
|
1722
|
+
* counter fallback is forbidden).
|
|
1719
1723
|
*/
|
|
1720
1724
|
function resolveSlug(verdict: IntakeVerdict): string {
|
|
1721
1725
|
const candidate =
|
|
1722
1726
|
verdict.taskSlug && verdict.taskSlug.trim() !== ''
|
|
1723
1727
|
? verdict.taskSlug
|
|
1724
1728
|
: (verdict.taskTitle ?? '');
|
|
1725
|
-
return paramCase(candidate);
|
|
1729
|
+
return ensureSafeSlug(paramCase(candidate));
|
|
1726
1730
|
}
|
|
1727
1731
|
|
|
1728
1732
|
/**
|
|
1729
1733
|
* Resolve a content-derived slug for the spec outcome — NEVER a counter (spec `issue-intake` US #8).
|
|
1730
1734
|
* Prefer the drafted `specSlug`, else derive from the drafted spec title; both go
|
|
1731
|
-
* through `paramCase
|
|
1735
|
+
* through `paramCase` and {@link ensureSafeSlug} (see {@link resolveSlug}). An
|
|
1736
|
+
* empty result signals the caller to refuse.
|
|
1732
1737
|
*/
|
|
1733
1738
|
function resolveSpecSlug(verdict: IntakeVerdict): string {
|
|
1734
1739
|
const candidate =
|
|
1735
1740
|
verdict.specSlug && verdict.specSlug.trim() !== ''
|
|
1736
1741
|
? verdict.specSlug
|
|
1737
1742
|
: (verdict.specTitle ?? '');
|
|
1738
|
-
return paramCase(candidate);
|
|
1743
|
+
return ensureSafeSlug(paramCase(candidate));
|
|
1739
1744
|
}
|
|
1740
1745
|
|
|
1741
1746
|
/**
|
package/src/mint-adr.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type CreateItemThroughCasResult,
|
|
8
8
|
} from './advancing-lock.js';
|
|
9
9
|
import {resolveSidecarIdentity, sidecarPathFor} from './sidecar.js';
|
|
10
|
+
import {ensureSafeSlug} from './slug-safety.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* The **ADR-MINT route** (spec
|
|
@@ -185,7 +186,9 @@ export async function mintAdr(options: MintAdrOptions): Promise<MintAdrResult> {
|
|
|
185
186
|
const note = options.note ?? (() => {});
|
|
186
187
|
|
|
187
188
|
const {slug: obsSlug} = resolveSidecarIdentity(item);
|
|
188
|
-
|
|
189
|
+
// The drafted slug comes from an agent verdict: an unsafe one (a `/`, `..`,
|
|
190
|
+
// shell metacharacters) is sanitised, a safe one is kept exactly as drafted.
|
|
191
|
+
const adrSlug = ensureSafeSlug(options.adrSlug ?? obsSlug);
|
|
189
192
|
if (adrSlug === '') {
|
|
190
193
|
return {
|
|
191
194
|
outcome: 'usage-error',
|
package/src/slug-namespace.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {ledgerRead, type LedgerReadStrategy} from './ledger-read.js';
|
|
2
|
+
import {isSafeSlug, MAX_SLUG_LENGTH} from './slug-safety.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The **§3a slug-namespace resolver** (`docs/adr/command-surface-and-
|
|
@@ -44,8 +45,8 @@ import {ledgerRead, type LedgerReadStrategy} from './ledger-read.js';
|
|
|
44
45
|
*
|
|
45
46
|
* HARD CUTOVER (spec `prd-to-spec-vocabulary-cutover-and-migration-command`,
|
|
46
47
|
* contract step): the legacy ''prd'' member is GONE — the parent-spec namespace
|
|
47
|
-
* is `'spec'` only. A ''prd:<slug>'' arg no longer parses to a namespace (
|
|
48
|
-
*
|
|
48
|
+
* is `'spec'` only. A ''prd:<slug>'' arg no longer parses to a namespace (the
|
|
49
|
+
* resolvers refuse it by the safe-slug check: `:` is not a slug character), and a `work/prd-<slug>` branch ref no longer
|
|
49
50
|
* parses. No back-compat alias (the clean-break stance).
|
|
50
51
|
*/
|
|
51
52
|
export type SlugNamespace = 'task' | 'spec' | 'observation';
|
|
@@ -155,8 +156,8 @@ const TASK_PREFIX = 'task:';
|
|
|
155
156
|
/**
|
|
156
157
|
* The parent-spec namespace prefix: `spec:<slug>` → `{explicit: 'spec'}`. HARD
|
|
157
158
|
* CUTOVER (spec `prd-to-spec-vocabulary-cutover-and-migration-command`): the
|
|
158
|
-
* legacy ''prd:'' prefix is GONE
|
|
159
|
-
*
|
|
159
|
+
* legacy ''prd:'' prefix is GONE: a ''prd:<slug>'' arg carries no namespace and
|
|
160
|
+
* is refused by the safe-slug check (`:` is not a slug character), the clean-break stance.
|
|
160
161
|
*/
|
|
161
162
|
const SPEC_PREFIX = 'spec:';
|
|
162
163
|
/**
|
|
@@ -185,7 +186,8 @@ export class SlugResolutionError extends Error {
|
|
|
185
186
|
* `spec:foo` → explicit spec, `foo` → bare (`explicit: undefined`). The prefix
|
|
186
187
|
* match is case-sensitive and exact (`task:`/`spec:`); a slug like `tasked` is
|
|
187
188
|
* NOT a prefix and stays bare. HARD CUTOVER: the legacy ''prd:'' prefix is not a
|
|
188
|
-
* namespace prefix anymore
|
|
189
|
+
* namespace prefix anymore: ''prd:foo'' parses as a bare slug, which the
|
|
190
|
+
* resolvers then refuse by the safe-slug check (`:` is not a slug character).
|
|
189
191
|
*/
|
|
190
192
|
export function parseSlugArg(arg: string): ParsedSlugArg {
|
|
191
193
|
if (arg.startsWith(TASK_PREFIX)) {
|
|
@@ -206,6 +208,29 @@ export function parseSlugArg(arg: string): ParsedSlugArg {
|
|
|
206
208
|
return {explicit: undefined, slug: arg};
|
|
207
209
|
}
|
|
208
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Refuse a slug outside the safe set ({@link isSafeSlug}) before any verb acts on
|
|
213
|
+
* it. Producers sanitise the slugs they mint, but a slug can also arrive from a
|
|
214
|
+
* hand-written frontmatter `slug:` or a file name, and every resolved slug goes
|
|
215
|
+
* on to name files, git refs and CI command lines. Loud, like every other
|
|
216
|
+
* resolution error: an unsafe item is never acted on, and the message says how
|
|
217
|
+
* to fix it.
|
|
218
|
+
*/
|
|
219
|
+
function assertSafeSlug(arg: string, slug: string): void {
|
|
220
|
+
if (!isSafeSlug(slug)) {
|
|
221
|
+
throw new SlugResolutionError(
|
|
222
|
+
`'${arg}' does not name a usable slug: a slug must be 1-${MAX_SLUG_LENGTH} ` +
|
|
223
|
+
`letters, digits, '.', '_' or '-', start and end with a letter or digit, ` +
|
|
224
|
+
`and not contain '..'. Rename the item's file (and its frontmatter ` +
|
|
225
|
+
`\`slug:\`, if set) to a safe slug.` +
|
|
226
|
+
(slug.includes(':')
|
|
227
|
+
? ` (The only namespace prefixes are \`task:\`, \`spec:\`, \`obs:\` ` +
|
|
228
|
+
`and \`observation:\`; a legacy prefix such as \`prd:\` is not one.)`
|
|
229
|
+
: ''),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
209
234
|
/** What the resolver needs to perform the existence checks. */
|
|
210
235
|
export interface ResolveSlugInput {
|
|
211
236
|
/** The raw CLI slug argument (bare, `task:<slug>`, or `spec:<slug>`). */
|
|
@@ -261,6 +286,7 @@ function specExists(
|
|
|
261
286
|
export function resolveSlug(input: ResolveSlugInput): ResolvedSlug {
|
|
262
287
|
const read = input.read ?? ledgerRead;
|
|
263
288
|
const parsed = parseSlugArg(input.arg);
|
|
289
|
+
assertSafeSlug(input.arg, parsed.slug);
|
|
264
290
|
|
|
265
291
|
if (parsed.explicit === 'observation') {
|
|
266
292
|
// `do` spans the task/spec namespaces ONLY (build a task OR task a spec).
|
|
@@ -313,6 +339,7 @@ export function resolveSlug(input: ResolveSlugInput): ResolvedSlug {
|
|
|
313
339
|
export function resolveAdvanceArg(input: ResolveSlugInput): ResolvedSlug {
|
|
314
340
|
const read = input.read ?? ledgerRead;
|
|
315
341
|
const parsed = parseSlugArg(input.arg);
|
|
342
|
+
assertSafeSlug(input.arg, parsed.slug);
|
|
316
343
|
|
|
317
344
|
if (parsed.explicit !== undefined) {
|
|
318
345
|
// Explicit prefix (task / spec / observation): unambiguous by construction.
|
|
@@ -348,6 +375,7 @@ export function resolveAdvanceArg(input: ResolveSlugInput): ResolvedSlug {
|
|
|
348
375
|
*/
|
|
349
376
|
export function resolveTaskOnlyArg(arg: string): string {
|
|
350
377
|
const parsed = parseSlugArg(arg);
|
|
378
|
+
assertSafeSlug(arg, parsed.slug);
|
|
351
379
|
if (parsed.explicit === 'spec') {
|
|
352
380
|
throw new SlugResolutionError(
|
|
353
381
|
`this command operates on tasks, not specs — '${arg}' names a spec. ` +
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SAFE-SLUG character set, and the one sanitiser every slug-producing path
|
|
3
|
+
* shares.
|
|
4
|
+
*
|
|
5
|
+
* A slug is not just a label: it becomes a file name (`work/<folder>/<slug>.md`),
|
|
6
|
+
* part of a git ref (`work/<slug>`, `work/intake-task-<slug>`), a command-line
|
|
7
|
+
* argument (`dorfl advance "task:<slug>"`, one per CI matrix leg) and text in
|
|
8
|
+
* commit messages. Several producers draft slugs from content an outsider can
|
|
9
|
+
* influence: `intake` from an issue's title and thread, the triage and apply
|
|
10
|
+
* rungs from an agent verdict. Before this module the only transform was
|
|
11
|
+
* `paramCase`, which keeps `$(`, backticks, quotes, `;`, `|` and `/`, and the
|
|
12
|
+
* triage/ADR paths only `trim()`med, so a drafted `../x` escaped its folder.
|
|
13
|
+
*
|
|
14
|
+
* Two functions, two jobs:
|
|
15
|
+
*
|
|
16
|
+
* - {@link toSafeSlug} is for PRODUCERS: it turns any drafted text into a slug
|
|
17
|
+
* made only of ASCII letters, digits and single hyphens. Its output always
|
|
18
|
+
* passes {@link isSafeSlug}.
|
|
19
|
+
* - {@link isSafeSlug} is for CONSUMERS: the slug resolvers refuse any slug
|
|
20
|
+
* outside the safe set, which covers slugs no producer here minted (a
|
|
21
|
+
* frontmatter `slug:` a human or agent wrote by hand). It is deliberately a
|
|
22
|
+
* little wider than what {@link toSafeSlug} emits (it also allows `.` and
|
|
23
|
+
* `_` and mixed case) so every slug already in use in a real `work/` tree
|
|
24
|
+
* keeps resolving: those characters are inert in a file name, a ref and a
|
|
25
|
+
* quoted shell word. `..` is still refused (path traversal), as is a leading
|
|
26
|
+
* `-` (it would read as a command-line option) or `.` (a hidden file), and so
|
|
27
|
+
* is a `.lock` suffix (git refuses it in a ref component).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The longest slug either function accepts. Real slugs in this repo top out
|
|
32
|
+
* around 105 characters; 120 leaves room without letting a drafted essay become
|
|
33
|
+
* a file name or ref component.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_SLUG_LENGTH = 120;
|
|
36
|
+
|
|
37
|
+
/** Letters, digits, `.`, `_`, `-`; starts and ends with a letter or digit. */
|
|
38
|
+
const SAFE_SLUG_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
39
|
+
|
|
40
|
+
/** Git refuses a ref component ending in `.lock` (its lock-file suffix). */
|
|
41
|
+
const GIT_LOCK_SUFFIX_RE = /\.lock$/i;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether `slug` is safe to use as a file name, a git ref component and a
|
|
45
|
+
* command-line argument (see the module doc for the exact set).
|
|
46
|
+
*/
|
|
47
|
+
export function isSafeSlug(slug: string): boolean {
|
|
48
|
+
return (
|
|
49
|
+
slug.length > 0 &&
|
|
50
|
+
slug.length <= MAX_SLUG_LENGTH &&
|
|
51
|
+
SAFE_SLUG_RE.test(slug) &&
|
|
52
|
+
!slug.includes('..') &&
|
|
53
|
+
!GIT_LOCK_SUFFIX_RE.test(slug)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Turn drafted text into a safe slug: accents are folded to their base letter,
|
|
59
|
+
* every run of anything other than an ASCII letter or digit becomes ONE hyphen,
|
|
60
|
+
* leading/trailing hyphens are dropped, and the result is capped at
|
|
61
|
+
* {@link MAX_SLUG_LENGTH}. Case is preserved (callers that want lower case, like
|
|
62
|
+
* `intake`, `paramCase` first). Returns `''` when nothing usable is left, so the
|
|
63
|
+
* caller's existing "no slug to derive" refusal fires instead of a junk name.
|
|
64
|
+
*/
|
|
65
|
+
export function toSafeSlug(input: string): string {
|
|
66
|
+
return input
|
|
67
|
+
.normalize('NFKD')
|
|
68
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
69
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
70
|
+
.replace(/^-+|-+$/g, '')
|
|
71
|
+
.slice(0, MAX_SLUG_LENGTH)
|
|
72
|
+
.replace(/-+$/, '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Keep a slug that is already safe EXACTLY as it is, and sanitise one that is
|
|
77
|
+
* not. The producers use this so every input that worked before still yields
|
|
78
|
+
* the same slug, and only an unsafe one changes.
|
|
79
|
+
*/
|
|
80
|
+
export function ensureSafeSlug(input: string): string {
|
|
81
|
+
const trimmed = input.trim();
|
|
82
|
+
return isSafeSlug(trimmed) ? trimmed : toSafeSlug(trimmed);
|
|
83
|
+
}
|
package/src/triage-persist.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {resolveSidecarIdentity, sidecarPathFor} from './sidecar.js';
|
|
|
11
11
|
import type {TriageAutoKind} from './triage-gate.js';
|
|
12
12
|
import {renderTaskBody, renderSpecBody} from './buildable-body.js';
|
|
13
13
|
import {setFrontmatterMarker} from './frontmatter.js';
|
|
14
|
+
import {ensureSafeSlug} from './slug-safety.js';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* The engine-owned TRIAGE PERSIST (spec `advance-loop`, task `advance-rung-triage`,
|
|
@@ -433,7 +434,9 @@ export async function promoteObservation(
|
|
|
433
434
|
const note = options.note ?? (() => {});
|
|
434
435
|
|
|
435
436
|
const {slug: obsSlug} = resolveSidecarIdentity(item);
|
|
436
|
-
|
|
437
|
+
// The drafted slug comes from an agent verdict: an unsafe one (a `/`, `..`,
|
|
438
|
+
// shell metacharacters) is sanitised, a safe one is kept exactly as drafted.
|
|
439
|
+
const newSlug = ensureSafeSlug(options.newSlug ?? obsSlug);
|
|
437
440
|
const artifact = options.artifact ?? 'task';
|
|
438
441
|
if (newSlug === '') {
|
|
439
442
|
return {
|
|
@@ -460,13 +463,16 @@ export async function promoteObservation(
|
|
|
460
463
|
// review-nits back-pointer); this is the triage-promote lineage. Matching on this
|
|
461
464
|
// explicit field — not the slug — is what keeps an unrelated same-slug task from
|
|
462
465
|
// false-positiving into a benign skip.
|
|
466
|
+
// A drafted `stubContent` carries its own frontmatter, and the ledger reads
|
|
467
|
+
// `slug:` before the file name, so stamp the SAFE slug over whatever the
|
|
468
|
+
// draft said: otherwise a drafted `slug: ../x` would become the item's
|
|
469
|
+
// identity even though the file name was sanitised.
|
|
470
|
+
const drafted =
|
|
471
|
+
options.stubContent !== undefined
|
|
472
|
+
? setFrontmatterMarker(options.stubContent, 'slug', newSlug)
|
|
473
|
+
: buildPromotedBody(artifact, newSlug, readItem(cwd, itemPath, env));
|
|
463
474
|
const content = setFrontmatterMarker(
|
|
464
|
-
ensureTaskDispatchable(
|
|
465
|
-
artifact,
|
|
466
|
-
newSlug,
|
|
467
|
-
options.stubContent ??
|
|
468
|
-
buildPromotedBody(artifact, newSlug, readItem(cwd, itemPath, env)),
|
|
469
|
-
),
|
|
475
|
+
ensureTaskDispatchable(artifact, newSlug, drafted),
|
|
470
476
|
'promotedFrom',
|
|
471
477
|
item,
|
|
472
478
|
);
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type {ResolvedCIConfig} from './install-ci-core.js';
|
|
25
|
+
import {shouldDropCheckoutCredentials} from './install-ci-core.js';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* The required-status-check CONTEXT install-ci sets via branch protection AND the
|
|
@@ -47,7 +48,18 @@ export const VERIFY_WORKFLOW_PATH = 'workflows/verify.yml';
|
|
|
47
48
|
* so the GitHub check it produces matches the required-status `context` install-ci
|
|
48
49
|
* configures on `main` — by construction, not by hand-typed agreement.
|
|
49
50
|
*/
|
|
50
|
-
export function generateVerifyWorkflow(
|
|
51
|
+
export function generateVerifyWorkflow(config: ResolvedCIConfig): string {
|
|
52
|
+
// `persist-credentials: false` ONLY on a repo KNOWN to be public (see
|
|
53
|
+
// {@link shouldDropCheckoutCredentials}): this job has `contents: read`, so
|
|
54
|
+
// the persisted token can only ever read, and on a public repo reads need
|
|
55
|
+
// no token. Unknown / private / internal ⇒ today's checkout, unchanged.
|
|
56
|
+
const checkoutCredentials = shouldDropCheckoutCredentials(config)
|
|
57
|
+
? `
|
|
58
|
+
# Public repository: every git read works without a token, and this
|
|
59
|
+
# job cannot push, so do not leave the token in .git/config for later
|
|
60
|
+
# steps (the project-setup hook's installs, the gate) to read.
|
|
61
|
+
persist-credentials: false`
|
|
62
|
+
: '';
|
|
51
63
|
return `\
|
|
52
64
|
# dorfl — the VERIFY workflow (Tier-1 GitHub ceiling; spec
|
|
53
65
|
# land-time-reverify-and-parallel-merge-ceiling, task
|
|
@@ -102,7 +114,7 @@ jobs:
|
|
|
102
114
|
steps:
|
|
103
115
|
- uses: actions/checkout@v5
|
|
104
116
|
with:
|
|
105
|
-
fetch-depth: 0
|
|
117
|
+
fetch-depth: 0${checkoutCredentials}
|
|
106
118
|
- uses: ./.github/actions/dorfl-setup
|
|
107
119
|
- name: run the repo verify gate
|
|
108
120
|
run: dorfl verify
|