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
package/src/install-ci-core.ts
CHANGED
|
@@ -25,10 +25,21 @@
|
|
|
25
25
|
* `DORFL_*` env block, NOT a new config knob minted here.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
readFileSync,
|
|
30
|
+
writeFileSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
} from 'node:fs';
|
|
29
35
|
import {dirname, join, relative} from 'node:path';
|
|
30
36
|
import {fileURLToPath} from 'node:url';
|
|
31
37
|
import type {HarnessAdapter} from './config.js';
|
|
38
|
+
import {
|
|
39
|
+
ACTION_PINS,
|
|
40
|
+
pinnedUses,
|
|
41
|
+
preserveExistingPins,
|
|
42
|
+
} from './install-ci-action-pins.js';
|
|
32
43
|
|
|
33
44
|
// ─── Config model ──────────────────────────────────────────────────────────
|
|
34
45
|
|
|
@@ -141,6 +152,44 @@ export interface ResolvedCIConfig {
|
|
|
141
152
|
projectSetup?: Record<string, unknown>;
|
|
142
153
|
/** See {@link CIConfigFile.maxParallel}. Always resolved (default applied). */
|
|
143
154
|
maxParallel: number;
|
|
155
|
+
/**
|
|
156
|
+
* The repo's visibility, DETECTED at generation time through the provider
|
|
157
|
+
* seam ({@link CIProviderContext.getRepoVisibility}); never read from or
|
|
158
|
+
* written to a config file (it is a fact about the repo, not a choice).
|
|
159
|
+
* `undefined` ⇒ unknown, which keeps the conservative output. See
|
|
160
|
+
* {@link shouldDropCheckoutCredentials}.
|
|
161
|
+
*/
|
|
162
|
+
repoVisibility?: RepoVisibility;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A repository's visibility on its CI provider. Used ONLY to decide whether the
|
|
167
|
+
* read-only jobs (verify, close-job) may check out with
|
|
168
|
+
* `persist-credentials: false`: on a PUBLIC repo every git read works without a
|
|
169
|
+
* token, so dropping the persisted token cannot break a fetch; on a private or
|
|
170
|
+
* internal repo (or when visibility is unknown) a fetch may need it.
|
|
171
|
+
*/
|
|
172
|
+
export type RepoVisibility = 'public' | 'private' | 'internal';
|
|
173
|
+
|
|
174
|
+
/** Parse a provider's visibility answer (`PUBLIC`, `private`, ...); unknown ⇒ `undefined`. */
|
|
175
|
+
export function parseRepoVisibility(raw: string): RepoVisibility | undefined {
|
|
176
|
+
const v = raw.trim().toLowerCase();
|
|
177
|
+
return v === 'public' || v === 'private' || v === 'internal' ? v : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether the read-only jobs (verify, close-job) should check out with
|
|
182
|
+
* `persist-credentials: false`. True ONLY when the repo is KNOWN to be public:
|
|
183
|
+
* then the persisted token adds nothing a git read needs (those jobs have
|
|
184
|
+
* `contents: read`, so they cannot push either way), while leaving it in
|
|
185
|
+
* `.git/config` hands it to every later step, including the project-setup
|
|
186
|
+
* hook's dependency installs. Unknown or non-public ⇒ keep today's checkout, so
|
|
187
|
+
* a project gate that runs `git fetch` on a private repo keeps working.
|
|
188
|
+
*/
|
|
189
|
+
export function shouldDropCheckoutCredentials(
|
|
190
|
+
config: Pick<ResolvedCIConfig, 'repoVisibility'>,
|
|
191
|
+
): boolean {
|
|
192
|
+
return config.repoVisibility === 'public';
|
|
144
193
|
}
|
|
145
194
|
|
|
146
195
|
/** The default harness the composite setup action installs. */
|
|
@@ -222,6 +271,13 @@ export interface CIProviderContext {
|
|
|
222
271
|
* no default-branch concept.
|
|
223
272
|
*/
|
|
224
273
|
getDefaultBranch?(): Promise<string | undefined>;
|
|
274
|
+
/**
|
|
275
|
+
* OPTIONALLY report the repo's VISIBILITY (GitHub:
|
|
276
|
+
* `gh repo view --json visibility`). Read-only. Returns `undefined` when the
|
|
277
|
+
* lookup fails; absent on a provider with no such concept. Drives
|
|
278
|
+
* {@link shouldDropCheckoutCredentials}.
|
|
279
|
+
*/
|
|
280
|
+
getRepoVisibility?(): Promise<RepoVisibility | undefined>;
|
|
225
281
|
/**
|
|
226
282
|
* OPTIONALLY create/replace a branch RULESET (GitHub:
|
|
227
283
|
* `POST /repos/{owner}/{repo}/rulesets`) carrying the deadlock-guard
|
|
@@ -673,6 +729,75 @@ function indent(text: string, spaces: number): string {
|
|
|
673
729
|
.join('\n');
|
|
674
730
|
}
|
|
675
731
|
|
|
732
|
+
/**
|
|
733
|
+
* The npm package the composite setup action installs for the `pi` harness.
|
|
734
|
+
*
|
|
735
|
+
* Upstream moved the harness from `@mariozechner/pi-coding-agent` (deprecated,
|
|
736
|
+
* frozen at 0.73.1) to this name. The binary is still `pi` and it still reads
|
|
737
|
+
* `~/.pi/agent/` (models.json / auth.json), so nothing else in the generated
|
|
738
|
+
* action changes.
|
|
739
|
+
*/
|
|
740
|
+
export const PI_HARNESS_PACKAGE = '@earendil-works/pi-coding-agent';
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* The EXACT `pi` harness version the composite setup action installs.
|
|
744
|
+
*
|
|
745
|
+
* Pinned because the install runs in jobs that hold `contents: write` and a
|
|
746
|
+
* provider API key: an unpinned `npm install -g` resolves whatever `latest` is
|
|
747
|
+
* at RUN time, so a compromised or broken release would reach those
|
|
748
|
+
* credentials with no review and no lockfile to catch it.
|
|
749
|
+
*
|
|
750
|
+
* It is a constant dorfl DECLARES (not an install-ci parameter) because the
|
|
751
|
+
* harness is coupled to dorfl, not to the consumer: dorfl drives `pi` through
|
|
752
|
+
* its CLI flags and reads its session files (`session-path.ts`,
|
|
753
|
+
* `watch-session.ts`), so the harness version that works is a property of the
|
|
754
|
+
* dorfl release. Bump it here, with a changeset, when a dorfl release needs a
|
|
755
|
+
* newer harness; every consumer picks it up on their next `install-ci` run.
|
|
756
|
+
*
|
|
757
|
+
* 0.80.6 is the version dorfl is exercised against day to day (the maintainer's
|
|
758
|
+
* installed `pi`), not the newest release: a harness upgrade should be a
|
|
759
|
+
* deliberate, tested bump. It requires Node >= 22.19.0, which the action's
|
|
760
|
+
* `node-version: '22'` satisfies (setup-node resolves the latest 22.x).
|
|
761
|
+
*/
|
|
762
|
+
export const PI_HARNESS_VERSION = '0.80.6';
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* The version of THIS dorfl package, read at runtime from its own
|
|
766
|
+
* `package.json` (the single source changesets bumps on release). The composite
|
|
767
|
+
* setup action installs `dorfl@<this version>` so a consumer's CI runs the SAME
|
|
768
|
+
* CLI that generated its workflows, instead of whatever `latest` is at run time.
|
|
769
|
+
*
|
|
770
|
+
* Resolved relative to this module (`dist/install-ci-core.js` or
|
|
771
|
+
* `src/install-ci-core.ts` → `../package.json`), so it works from the built CLI
|
|
772
|
+
* and under tsx. Throws when the version cannot be read: silently falling back
|
|
773
|
+
* to an unpinned install would reintroduce exactly the risk the pin removes.
|
|
774
|
+
*/
|
|
775
|
+
export function dorflPackageVersion(): string {
|
|
776
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
777
|
+
const pkgPath = join(here, '..', 'package.json');
|
|
778
|
+
let version: unknown;
|
|
779
|
+
try {
|
|
780
|
+
version = (JSON.parse(readFileSync(pkgPath, 'utf8')) as {version?: unknown})
|
|
781
|
+
.version;
|
|
782
|
+
} catch (err) {
|
|
783
|
+
throw new Error(
|
|
784
|
+
`install-ci: cannot read dorfl's own version from ${pkgPath} ` +
|
|
785
|
+
`(needed to pin \`npm install -g dorfl@<version>\`): ` +
|
|
786
|
+
(err instanceof Error ? err.message : String(err)),
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
if (
|
|
790
|
+
typeof version !== 'string' ||
|
|
791
|
+
!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]+)?$/.test(version)
|
|
792
|
+
) {
|
|
793
|
+
throw new Error(
|
|
794
|
+
`install-ci: dorfl's package.json at ${pkgPath} has no usable semver ` +
|
|
795
|
+
`"version" (got ${JSON.stringify(version)}); cannot pin the CI install.`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
return version;
|
|
799
|
+
}
|
|
800
|
+
|
|
676
801
|
/**
|
|
677
802
|
* The install step for the configured harness (the `pi` CLI; `''` ⇒ none).
|
|
678
803
|
* `registry` mode installs via `npm install -g`; `workspace` mode installs via
|
|
@@ -684,11 +809,15 @@ function harnessInstallStep(
|
|
|
684
809
|
installSource: InstallSource,
|
|
685
810
|
): string {
|
|
686
811
|
if (harness === 'pi') {
|
|
812
|
+
const spec = `${PI_HARNESS_PACKAGE}@${PI_HARNESS_VERSION}`;
|
|
687
813
|
const run =
|
|
688
814
|
installSource === 'workspace'
|
|
689
|
-
?
|
|
690
|
-
:
|
|
815
|
+
? `pnpm add -g ${spec}`
|
|
816
|
+
: `npm install -g ${spec}`;
|
|
691
817
|
return `
|
|
818
|
+
# Pinned to the exact harness version this dorfl release declares
|
|
819
|
+
# (PI_HARNESS_VERSION): the job holds write access and a provider key, so the
|
|
820
|
+
# version must not move on its own. Re-run \`dorfl install-ci\` to upgrade.
|
|
692
821
|
- name: Install agent harness (pi)
|
|
693
822
|
shell: bash
|
|
694
823
|
run: ${run}`;
|
|
@@ -807,7 +936,7 @@ ${indent(modelsJsonStr, 8)}
|
|
|
807
936
|
if (config.installSource === 'workspace') {
|
|
808
937
|
installSteps = `\
|
|
809
938
|
- name: Setup pnpm
|
|
810
|
-
uses:
|
|
939
|
+
uses: ${pinnedUses(ACTION_PINS.pnpmSetup)}
|
|
811
940
|
|
|
812
941
|
- name: Add pnpm global bin to PATH
|
|
813
942
|
shell: bash
|
|
@@ -815,17 +944,25 @@ ${indent(modelsJsonStr, 8)}
|
|
|
815
944
|
pnpm setup
|
|
816
945
|
echo "$HOME/.local/share/pnpm" >> "$GITHUB_PATH"
|
|
817
946
|
|
|
947
|
+
# \`--ignore-scripts\`: nothing here needs an install-time script. The build is
|
|
948
|
+
# explicit (\`pnpm -r build\` below; the root \`prepare\` would only repeat it),
|
|
949
|
+
# and verify runs on fork pull requests, where a lifecycle script would run
|
|
950
|
+
# the fork's code before any gate does.
|
|
818
951
|
- name: Install dependencies and build dorfl
|
|
819
952
|
shell: bash
|
|
820
953
|
run: |
|
|
821
|
-
pnpm install
|
|
954
|
+
pnpm install --ignore-scripts
|
|
822
955
|
pnpm -r build
|
|
823
956
|
cd packages/dorfl && pnpm link --global${installHarness}`;
|
|
824
957
|
} else {
|
|
825
958
|
installSteps = `\
|
|
959
|
+
# Pinned to the dorfl version that generated this action, so CI runs the
|
|
960
|
+
# same CLI that wrote these workflows (and never an unreviewed \`latest\` in a
|
|
961
|
+
# job holding write access and a provider key). Re-run \`dorfl install-ci\`
|
|
962
|
+
# after upgrading dorfl to move the pin.
|
|
826
963
|
- name: Install dorfl
|
|
827
964
|
shell: bash
|
|
828
|
-
run: npm install -g dorfl${installHarness}`;
|
|
965
|
+
run: npm install -g dorfl@${dorflPackageVersion()}${installHarness}`;
|
|
829
966
|
}
|
|
830
967
|
|
|
831
968
|
// NO bespoke CI resolver shim (task install-ci-shim-converges-on-dorfl-cmd,
|
|
@@ -895,15 +1032,16 @@ runs:
|
|
|
895
1032
|
using: composite
|
|
896
1033
|
steps:
|
|
897
1034
|
${projectSetupBlock} - name: Setup Node.js
|
|
898
|
-
uses:
|
|
1035
|
+
uses: ${pinnedUses(ACTION_PINS.setupNode)}
|
|
899
1036
|
with:
|
|
900
1037
|
node-version: '22'
|
|
901
|
-
# setup-node
|
|
902
|
-
#
|
|
903
|
-
# before that manager
|
|
904
|
-
# locate executable file").
|
|
905
|
-
#
|
|
906
|
-
#
|
|
1038
|
+
# setup-node (v5 and later) auto-enables package-manager caching. In v5
|
|
1039
|
+
# it probed whatever manager package.json's packageManager named, during
|
|
1040
|
+
# its own run and before that manager was on PATH, and crashed ("Unable
|
|
1041
|
+
# to locate executable file"). Since v6 it only fires when package.json
|
|
1042
|
+
# declares npm, and then fails if there is no npm lockfile. Either way
|
|
1043
|
+
# it is the project's cache, not dorfl's: dorfl provisions ONLY its own
|
|
1044
|
+
# runtime (toolchain boundary), so disable it explicitly.
|
|
907
1045
|
package-manager-cache: false
|
|
908
1046
|
|
|
909
1047
|
- name: Configure git identity
|
|
@@ -1069,6 +1207,13 @@ export function outputBaseName(fake: boolean): string {
|
|
|
1069
1207
|
* Write the artifacts under `<workDir>/<base>/` (base = `.fake` when `fake`, else
|
|
1070
1208
|
* `.github`). Returns the repo-relative paths actually written. Pure filesystem
|
|
1071
1209
|
* I/O — no secrets, no git. Creates parent dirs as needed.
|
|
1210
|
+
*
|
|
1211
|
+
* PIN PRESERVATION. Before writing a YAML artifact, any `uses:` of an action the
|
|
1212
|
+
* repository's EXISTING `.github/<path>` already pins to a full commit SHA keeps
|
|
1213
|
+
* that reference and its trailing comment (see {@link preserveExistingPins}),
|
|
1214
|
+
* so a Dependabot bump survives regeneration and a pin is never swapped for a
|
|
1215
|
+
* tag. The existing file is always read from `.github/`, also in `--fake` mode,
|
|
1216
|
+
* so the `.fake/` preview shows what a real run would write.
|
|
1072
1217
|
*/
|
|
1073
1218
|
export function writeArtifacts(options: {
|
|
1074
1219
|
workDir: string;
|
|
@@ -1079,8 +1224,15 @@ export function writeArtifacts(options: {
|
|
|
1079
1224
|
const written: string[] = [];
|
|
1080
1225
|
for (const file of options.files) {
|
|
1081
1226
|
const abs = join(options.workDir, base, file.path);
|
|
1227
|
+
let content = file.content;
|
|
1228
|
+
if (/\.ya?ml$/.test(file.path)) {
|
|
1229
|
+
const current = join(options.workDir, outputBaseName(false), file.path);
|
|
1230
|
+
if (existsSync(current)) {
|
|
1231
|
+
content = preserveExistingPins(content, readFileSync(current, 'utf8'));
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1082
1234
|
mkdirSync(dirname(abs), {recursive: true});
|
|
1083
|
-
writeFileSync(abs,
|
|
1235
|
+
writeFileSync(abs, content, 'utf8');
|
|
1084
1236
|
written.push(relative(options.workDir, abs));
|
|
1085
1237
|
}
|
|
1086
1238
|
return written;
|
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) {
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
*/
|
|
69
69
|
|
|
70
70
|
import {brand} from './brand.js';
|
|
71
|
+
import {ACTION_PINS, pinnedUses} from './install-ci-action-pins.js';
|
|
71
72
|
import type {ResolvedCIConfig} from './install-ci-core.js';
|
|
72
73
|
import {providerSecretsWithBlock} from './install-ci-core.js';
|
|
73
74
|
|
|
@@ -365,7 +366,7 @@ jobs:
|
|
|
365
366
|
if: \${{ github.event.issue.number && !github.event.issue.pull_request }}
|
|
366
367
|
runs-on: ubuntu-latest
|
|
367
368
|
steps:
|
|
368
|
-
- uses:
|
|
369
|
+
- uses: ${pinnedUses(ACTION_PINS.checkout)}
|
|
369
370
|
with:
|
|
370
371
|
fetch-depth: 0
|
|
371
372
|
- uses: ./.github/actions/dorfl-setup${setupWith}
|
|
@@ -463,13 +464,23 @@ jobs:
|
|
|
463
464
|
# lone-task review/edit loop and posts its findings as questions back into
|
|
464
465
|
# THIS issue thread (insertion point E) through the issue-comment seam —
|
|
465
466
|
# CI surfaces E by invoking intake; it adds no new review mechanism.
|
|
467
|
+
#
|
|
468
|
+
# Every value reaches the shell through \`env:\` as DATA and is read back as a
|
|
469
|
+
# quoted variable, never as \`\${{ }}\` text spliced into the script
|
|
470
|
+
# (GitHub's script-injection guidance). The issue number is an integer and
|
|
471
|
+
# the policy outputs are fixed flags today; routing them through env anyway
|
|
472
|
+
# keeps "no \`\${{ }}\` inside \`run:\`" a rule with no exceptions.
|
|
466
473
|
env:
|
|
467
474
|
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
475
|
+
ISSUE_NUMBER: \${{ github.event.issue.number }}
|
|
476
|
+
SPEC_FLAG: \${{ steps.policy.outputs.spec_flag }}
|
|
477
|
+
TASK_FLAG: \${{ steps.policy.outputs.task_flag }}
|
|
478
|
+
ORIGIN_TRUST_FLAG: \${{ steps.policy.outputs.origin_trust_flag }}
|
|
468
479
|
run: |
|
|
469
|
-
dorfl intake "\${
|
|
470
|
-
"\${
|
|
471
|
-
"\${
|
|
472
|
-
"\${
|
|
480
|
+
dorfl intake "\${ISSUE_NUMBER}" \\
|
|
481
|
+
"\${SPEC_FLAG}" \\
|
|
482
|
+
"\${TASK_FLAG}" \\
|
|
483
|
+
"\${ORIGIN_TRUST_FLAG}" \\
|
|
473
484
|
--arbiter origin
|
|
474
485
|
`;
|
|
475
486
|
}
|
|
@@ -521,10 +532,20 @@ export function validateIntakeWorkflow(text: string): IntakeTriggerValidation {
|
|
|
521
532
|
'schedules).');
|
|
522
533
|
// The issue number rides the explicit positional — never a bare slug, and the
|
|
523
534
|
// number comes from the event payload (the issue under intake).
|
|
524
|
-
require('intake-explicit-issue-number', /
|
|
535
|
+
require('intake-explicit-issue-number', /ISSUE_NUMBER:\s*\$\{\{\s*github\.event\.issue\.number\s*\}\}[\s\S]*?dorfl intake "\$\{ISSUE_NUMBER\}"/.test(
|
|
525
536
|
operative,
|
|
526
537
|
), 'the intake invocation must pass the explicit issue NUMBER ' +
|
|
527
|
-
'(`github.event.issue.number`), never a bare slug.');
|
|
538
|
+
'(`github.event.issue.number`, via the step env as `ISSUE_NUMBER`), never a bare slug.');
|
|
539
|
+
// No `${{ }}` spliced into the intake `run:` script (script injection): the
|
|
540
|
+
// number and the policy flags reach the shell through the step `env:`.
|
|
541
|
+
require('intake-args-not-spliced-into-run', !/dorfl intake "?\$\{\{/.test(
|
|
542
|
+
operative,
|
|
543
|
+
) &&
|
|
544
|
+
!/"\$\{\{\s*steps\.policy\.outputs\./.test(
|
|
545
|
+
operative,
|
|
546
|
+
), 'the intake invocation must NOT interpolate `${{ github.event.issue.number }}` ' +
|
|
547
|
+
'or `${{ steps.policy.outputs.* }}` into its `run:` script: pass them through ' +
|
|
548
|
+
'the step `env:` and quote the variables.');
|
|
528
549
|
// CI owns ONLY the trigger/policy/delivery — it must NOT invoke a build/task
|
|
529
550
|
// verb (that is the build/task tick), nor re-implement the transform.
|
|
530
551
|
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. ` +
|