@patronage/factory-ci 0.1.1 → 0.1.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/README.md CHANGED
@@ -12,7 +12,7 @@ It is a **pure library**. There is no `bin`: anything invocable belongs to `psf`
12
12
 
13
13
  Three repositories independently built the same scaffolding and then drifted: three wordings of the same setup block, the same `@v4` action tags pinned to different commits because they were generated months apart, two esbuild pre-bundles, two Alchemy CLI wrappers, and one disposable-stage grammar invented twice.
14
14
 
15
- The admission rule is **upstream on repetition**: nothing enters this package until it already repeats across at least two projects. Experiments live in one repository first. The public surface is made of three deep modules, not a workflow framework.
15
+ The admission rule is **upstream on repetition**: nothing enters this package until it already repeats across at least two projects. Experiments live in one repository first. The public surface is made of four deep modules, not a workflow framework.
16
16
 
17
17
  ## Exports
18
18
 
@@ -21,7 +21,7 @@ The admission rule is **upstream on repetition**: nothing enters this package un
21
21
  ```ts
22
22
  import {
23
23
  factoryWorkflow,
24
- NODE_PNPM_ACTION_FAMILY_V4,
24
+ NODE_PNPM_ACTION_FAMILY_NODE24,
25
25
  } from "@patronage/factory-ci";
26
26
  ```
27
27
 
@@ -31,15 +31,15 @@ import {
31
31
  - `setupSteps`: checkout → pnpm → Node → install, customizable by named role
32
32
  - `writeOptions`: the provenance banner and `pinDeps: false`, ready to spread into gagen's `writeOrLint`
33
33
 
34
- Every action is validated as `owner/repo@<full sha>`, with its release tag kept separately. A complete `actionFamily` is required; there is no default that could silently move a consumer between action majors. `NODE_PNPM_ACTION_FAMILY_V4` is the family already proven across HQ's workflows. Firedup supplies its own complete v6 family until that family repeats in another project and qualifies for upstream admission.
34
+ Every action is validated as `owner/repo@<full sha>`, with its release tag kept separately. A complete `actionFamily` is required; there is no default that could silently move a consumer between action majors. `NODE_PNPM_ACTION_FAMILY_NODE24` is the canonical family whose checkout, Node setup, and pnpm setup actions all declare a Node 24 JavaScript runtime.
35
35
 
36
36
  ```ts
37
37
  const generated = factoryWorkflow({
38
- actionFamily: NODE_PNPM_ACTION_FAMILY_V4,
38
+ actionFamily: NODE_PNPM_ACTION_FAMILY_NODE24,
39
39
  additionalActions: {
40
40
  pathsFilter: {
41
- tag: "v3",
42
- uses: "dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590",
41
+ tag: "v4.0.2",
42
+ uses: "dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706",
43
43
  },
44
44
  },
45
45
  generated: {
@@ -59,6 +59,39 @@ workflow({/* caller-owned jobs and topology */}).writeOrLint({
59
59
 
60
60
  The **runner is not this package's business**. Jobs, runners, permissions, workflow topology, and deploy policy remain with the caller. The returned values are plain structural objects; this package does not depend on gagen.
61
61
 
62
+ ### Hosted docs-only classification
63
+
64
+ ```ts
65
+ import {
66
+ hostedDocsOnlyDetectorStep,
67
+ NODE_PNPM_ACTION_FAMILY_NODE24,
68
+ } from "@patronage/factory-ci";
69
+ import { job, step } from "gagen";
70
+
71
+ import profile from "../../software-factory.profile.json" with { type: "json" };
72
+
73
+ const detector = step(
74
+ hostedDocsOnlyDetectorStep(profile.proof.classificationPolicy)
75
+ );
76
+
77
+ const changes = job("changes", {
78
+ runsOn: "ubuntu-latest",
79
+ steps: [
80
+ {
81
+ name: "Checkout",
82
+ uses: NODE_PNPM_ACTION_FAMILY_NODE24.checkout.uses,
83
+ with: { "fetch-depth": 0 },
84
+ },
85
+ detector,
86
+ ],
87
+ outputs: { docs_only: detector.outputs.docs_only },
88
+ });
89
+ ```
90
+
91
+ `hostedDocsOnlyDetectorStep()` takes the consumer's canonical `proof.classificationPolicy` object directly. It emits a small Bash step structurally accepted by gagen, matches the factory classifier's exact-file, prefix, and case-sensitive `.md` semantics, checks both paths of rename and copy records, and forces full CI when `software-factory.profile.json` changes. The package understands the stable classification-policy vocabulary without loading JSON or depending on the rest of the profile schema.
92
+
93
+ The emitted step needs only checkout, Bash, and Git. It does not install dependencies or invoke `psf`. Missing or zero SHAs, empty diffs, malformed policy, unknown Git change records, and detector errors all leave `docs_only` false (or unset), so the consumer runs full CI. Hosted classification may therefore conservatively run more work when detection fails, but it never carries a second path policy that can drift from the profile.
94
+
62
95
  ### Alchemy entries
63
96
 
64
97
  ```ts
@@ -89,7 +122,7 @@ The tests pack the actual tarball, extract it into a throwaway external consumer
89
122
 
90
123
  ## No configuration surface
91
124
 
92
- Functions take plain typed options objects. Proof and preview configuration lives in the canonical `software-factory.profile.json` schema, never in this package (ADR 0021); mapping a profile into these options is the profile's job.
125
+ Functions take plain typed objects. Proof and preview configuration lives in the canonical `software-factory.profile.json` schema, never in this package (ADR 0021); consumers pass the relevant profile policy directly at generation time.
93
126
 
94
127
  ## Secrets
95
128
 
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * impossible: regenerating a workflow can no longer move a pin.
12
12
  *
13
13
  * `uses` is a bare `owner/repo@<sha>` string, safe to hand straight to a
14
- * gagen step. The tag lives in its own field rather than as a `# v4.4.0`
14
+ * gagen step. The tag lives in its own field rather than as a `# v7.0.1`
15
15
  * suffix on `uses`, because a `#` inside a YAML scalar forces quoting and
16
16
  * gagen's pin pass reads the value with `\S+` — the two together corrupt the
17
17
  * emitted `uses:` line. gagen writes the `# <tag>` comment itself.
@@ -30,25 +30,25 @@ interface NodePnpmActionFamily {
30
30
  readonly setupPnpm: PinnedAction;
31
31
  }
32
32
  /**
33
- * The v4 family already shared by HQ's generated workflows.
33
+ * The canonical Node 24 family shared by factory-project workflows.
34
34
  *
35
35
  * Consumers must pass this (or their own complete family) to
36
- * `factoryWorkflow`; there is deliberately no implicit default. In
37
- * particular, Firedup's v6 action family must stay v6 during adoption.
36
+ * `factoryWorkflow`; there is deliberately no implicit default. Every action
37
+ * in this family declares `runs.using: node24` at the named release.
38
38
  */
39
- declare const NODE_PNPM_ACTION_FAMILY_V4: {
39
+ declare const NODE_PNPM_ACTION_FAMILY_NODE24: {
40
40
  readonly checkout: {
41
- readonly tag: "v4.4.0";
42
- readonly uses: "actions/checkout@11d5960a326750d5838078e36cf38b85af677262";
41
+ readonly tag: "v7.0.1";
42
+ readonly uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1";
43
43
  };
44
- readonly id: "node-pnpm-v4";
44
+ readonly id: "node-pnpm-node24";
45
45
  readonly setupNode: {
46
- readonly tag: "v4.4.0";
47
- readonly uses: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020";
46
+ readonly tag: "v7.0.0";
47
+ readonly uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020";
48
48
  };
49
49
  readonly setupPnpm: {
50
- readonly tag: "v4.3.0";
51
- readonly uses: "pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1";
50
+ readonly tag: "v6.0.9";
51
+ readonly uses: "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271";
52
52
  };
53
53
  };
54
54
  //#endregion
@@ -194,6 +194,30 @@ interface FactoryWorkflowArtifact<Additional extends Readonly<Record<string, Pin
194
194
  */
195
195
  declare const factoryWorkflow: <const Additional extends Readonly<Record<string, PinnedAction>> = Record<never, never>>(options: FactoryWorkflowOptions<Additional>) => FactoryWorkflowArtifact<Additional>;
196
196
  //#endregion
197
+ //#region src/hosted-docs-only-detector.d.ts
198
+ interface HostedDocsOnlyClassificationPolicy {
199
+ readonly docsOnlyFiles?: readonly string[];
200
+ readonly docsOnlyMarkdownPrefixes?: readonly string[];
201
+ readonly docsOnlyPrefixes?: readonly string[];
202
+ }
203
+ interface HostedDocsOnlyDetectorStep {
204
+ readonly continueOnError: true;
205
+ readonly env: Readonly<{
206
+ BASE_SHA: string;
207
+ HEAD_SHA: string;
208
+ }>;
209
+ readonly id: "docs-only";
210
+ readonly name: "Detect docs-only changes";
211
+ readonly outputs: readonly ["docs_only"];
212
+ readonly run: string;
213
+ }
214
+ /**
215
+ * Generate the pre-install hosted detector from a consumer's canonical
216
+ * classification policy. The returned object is structurally accepted by
217
+ * gagen's `step()` without adding a gagen runtime dependency.
218
+ */
219
+ declare const hostedDocsOnlyDetectorStep: (policy: HostedDocsOnlyClassificationPolicy) => HostedDocsOnlyDetectorStep;
220
+ //#endregion
197
221
  //#region src/execute-alchemy-entry.d.ts
198
222
  interface ExecuteAlchemyEntryOptions {
199
223
  /**
@@ -225,4 +249,4 @@ interface ExecuteAlchemyEntryResult {
225
249
  */
226
250
  declare const executeAlchemyEntry: (options: ExecuteAlchemyEntryOptions) => Promise<ExecuteAlchemyEntryResult>;
227
251
  //#endregion
228
- export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_V4, type NodePnpmActionFamily, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type SetupNodeStepOptions, type WorkflowStep, bundleAlchemyEntry, executeAlchemyEntry, factoryWorkflow, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage };
252
+ export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, type HostedDocsOnlyClassificationPolicy, type HostedDocsOnlyDetectorStep, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type SetupNodeStepOptions, type WorkflowStep, bundleAlchemyEntry, executeAlchemyEntry, factoryWorkflow, hostedDocsOnlyDetectorStep, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage };
package/dist/index.js CHANGED
@@ -5,25 +5,25 @@ import { build } from "esbuild";
5
5
  import { spawnSync } from "node:child_process";
6
6
  //#region src/actions.ts
7
7
  /**
8
- * The v4 family already shared by HQ's generated workflows.
8
+ * The canonical Node 24 family shared by factory-project workflows.
9
9
  *
10
10
  * Consumers must pass this (or their own complete family) to
11
- * `factoryWorkflow`; there is deliberately no implicit default. In
12
- * particular, Firedup's v6 action family must stay v6 during adoption.
11
+ * `factoryWorkflow`; there is deliberately no implicit default. Every action
12
+ * in this family declares `runs.using: node24` at the named release.
13
13
  */
14
- const NODE_PNPM_ACTION_FAMILY_V4 = {
14
+ const NODE_PNPM_ACTION_FAMILY_NODE24 = {
15
15
  checkout: {
16
- tag: "v4.4.0",
17
- uses: "actions/checkout@11d5960a326750d5838078e36cf38b85af677262"
16
+ tag: "v7.0.1",
17
+ uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1"
18
18
  },
19
- id: "node-pnpm-v4",
19
+ id: "node-pnpm-node24",
20
20
  setupNode: {
21
- tag: "v4.4.0",
22
- uses: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020"
21
+ tag: "v7.0.0",
22
+ uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020"
23
23
  },
24
24
  setupPnpm: {
25
- tag: "v4.3.0",
26
- uses: "pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1"
25
+ tag: "v6.0.9",
26
+ uses: "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271"
27
27
  }
28
28
  };
29
29
  //#endregion
@@ -210,6 +210,137 @@ const factoryWorkflow = (options) => {
210
210
  });
211
211
  };
212
212
  //#endregion
213
+ //#region src/hosted-docs-only-detector.ts
214
+ const githubExpression = (expression) => `\${{ ${expression} }}`;
215
+ const BASE_SHA_EXPRESSION = githubExpression("github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before");
216
+ const HEAD_SHA_EXPRESSION = githubExpression("github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha");
217
+ const stringArray = (value) => {
218
+ if (value === void 0) return [];
219
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string" && entry.length > 0)) return value;
220
+ };
221
+ const normalizePolicy = (value) => {
222
+ if (!(value && typeof value === "object" && !Array.isArray(value))) return;
223
+ const policy = value;
224
+ const docsOnlyFiles = stringArray(policy.docsOnlyFiles);
225
+ const docsOnlyMarkdownPrefixes = stringArray(policy.docsOnlyMarkdownPrefixes);
226
+ const docsOnlyPrefixes = stringArray(policy.docsOnlyPrefixes);
227
+ if (!(docsOnlyFiles && docsOnlyMarkdownPrefixes && docsOnlyPrefixes)) return;
228
+ return {
229
+ docsOnlyFiles,
230
+ docsOnlyMarkdownPrefixes,
231
+ docsOnlyPrefixes
232
+ };
233
+ };
234
+ const shellQuoteSegment = (value) => `'${value.replaceAll("'", String.raw`'\''`)}'`;
235
+ const shellQuote = (value) => value.split("$").map(shellQuoteSegment).join("'$'");
236
+ const shellArray = (values) => `(${values.map(shellQuote).join(" ")})`;
237
+ const shellExpansion = (name) => `\${${name}}`;
238
+ const malformedPolicyScript = String.raw`printf 'docs_only=false\n' >> "$GITHUB_OUTPUT"`;
239
+ const detectorScript = (policy) => String.raw`docs_only=false
240
+ changed_entries_file=""
241
+
242
+ finish() {
243
+ detector_status=$?
244
+ if [ -n "$changed_entries_file" ]; then
245
+ rm -f "$changed_entries_file" || true
246
+ fi
247
+ if [ "$detector_status" -ne 0 ]; then
248
+ docs_only=false
249
+ fi
250
+ printf 'docs_only=%s\n' "$docs_only" >> "$GITHUB_OUTPUT"
251
+ exit 0
252
+ }
253
+ trap finish EXIT
254
+ set -eo pipefail
255
+
256
+ if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ] ||
257
+ [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ] ||
258
+ [ "$HEAD_SHA" = "0000000000000000000000000000000000000000" ]; then
259
+ exit 0
260
+ fi
261
+
262
+ changed_entries_file="$(mktemp)"
263
+ git diff --name-status -z --find-renames --find-copies-harder \
264
+ "$BASE_SHA...$HEAD_SHA" -- > "$changed_entries_file"
265
+ if [ ! -s "$changed_entries_file" ]; then
266
+ exit 0
267
+ fi
268
+
269
+ docs_only_files=${shellArray(policy.docsOnlyFiles)}
270
+ docs_only_prefixes=${shellArray(policy.docsOnlyPrefixes)}
271
+ docs_only_markdown_prefixes=${shellArray(policy.docsOnlyMarkdownPrefixes)}
272
+
273
+ is_docs_only_path() {
274
+ candidate="$1"
275
+ if [ "$candidate" = "software-factory.profile.json" ]; then
276
+ return 1
277
+ fi
278
+ for policy_path in "${shellExpansion("docs_only_files[@]")}"; do
279
+ if [ "$candidate" = "$policy_path" ]; then
280
+ return 0
281
+ fi
282
+ done
283
+ for policy_path in "${shellExpansion("docs_only_prefixes[@]")}"; do
284
+ if [[ "$candidate" == "$policy_path"* ]]; then
285
+ return 0
286
+ fi
287
+ done
288
+ if [[ "$candidate" == *.md ]]; then
289
+ for policy_path in "${shellExpansion("docs_only_markdown_prefixes[@]")}"; do
290
+ if [[ "$candidate" == "$policy_path"* ]]; then
291
+ return 0
292
+ fi
293
+ done
294
+ fi
295
+ return 1
296
+ }
297
+
298
+ entry_count=0
299
+ while IFS= read -r -d '' change_status <&3; do
300
+ entry_count=$((entry_count + 1))
301
+ case "$change_status" in
302
+ A|D|M)
303
+ IFS= read -r -d '' changed_path <&3 || exit 0
304
+ is_docs_only_path "$changed_path" || exit 0
305
+ ;;
306
+ R*|C*)
307
+ if [[ ! "$change_status" =~ ^[RC][0-9]{1,3}$ ]]; then
308
+ exit 0
309
+ fi
310
+ IFS= read -r -d '' source_path <&3 || exit 0
311
+ IFS= read -r -d '' destination_path <&3 || exit 0
312
+ is_docs_only_path "$source_path" || exit 0
313
+ is_docs_only_path "$destination_path" || exit 0
314
+ ;;
315
+ *)
316
+ exit 0
317
+ ;;
318
+ esac
319
+ done 3< "$changed_entries_file"
320
+
321
+ if [ "$entry_count" -gt 0 ]; then
322
+ docs_only=true
323
+ fi`;
324
+ /**
325
+ * Generate the pre-install hosted detector from a consumer's canonical
326
+ * classification policy. The returned object is structurally accepted by
327
+ * gagen's `step()` without adding a gagen runtime dependency.
328
+ */
329
+ const hostedDocsOnlyDetectorStep = (policy) => {
330
+ const normalized = normalizePolicy(policy);
331
+ return Object.freeze({
332
+ continueOnError: true,
333
+ env: Object.freeze({
334
+ BASE_SHA: BASE_SHA_EXPRESSION,
335
+ HEAD_SHA: HEAD_SHA_EXPRESSION
336
+ }),
337
+ id: "docs-only",
338
+ name: "Detect docs-only changes",
339
+ outputs: ["docs_only"],
340
+ run: normalized ? detectorScript(normalized) : malformedPolicyScript
341
+ });
342
+ };
343
+ //#endregion
213
344
  //#region src/execute-alchemy-entry.ts
214
345
  /**
215
346
  * Bundle a consumer-owned Alchemy entry, resolve that consumer's Alchemy CLI,
@@ -241,4 +372,4 @@ const executeAlchemyEntry = async (options) => {
241
372
  };
242
373
  };
243
374
  //#endregion
244
- export { NODE_PNPM_ACTION_FAMILY_V4, bundleAlchemyEntry, executeAlchemyEntry, factoryWorkflow, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage };
375
+ export { NODE_PNPM_ACTION_FAMILY_NODE24, bundleAlchemyEntry, executeAlchemyEntry, factoryWorkflow, hostedDocsOnlyDetectorStep, isLocalPreviewStage, localPreviewStage, parseLocalPreviewStage };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@patronage/factory-ci",
3
- "version": "0.1.1",
4
- "description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, Alchemy entry execution, and disposable-stage semantics",
3
+ "version": "0.1.3",
4
+ "description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
5
5
  "keywords": [
6
6
  "alchemy",
7
7
  "cloudflare",
package/src/actions.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * impossible: regenerating a workflow can no longer move a pin.
11
11
  *
12
12
  * `uses` is a bare `owner/repo@<sha>` string, safe to hand straight to a
13
- * gagen step. The tag lives in its own field rather than as a `# v4.4.0`
13
+ * gagen step. The tag lives in its own field rather than as a `# v7.0.1`
14
14
  * suffix on `uses`, because a `#` inside a YAML scalar forces quoting and
15
15
  * gagen's pin pass reads the value with `\S+` — the two together corrupt the
16
16
  * emitted `uses:` line. gagen writes the `# <tag>` comment itself.
@@ -31,24 +31,24 @@ export interface NodePnpmActionFamily {
31
31
  }
32
32
 
33
33
  /**
34
- * The v4 family already shared by HQ's generated workflows.
34
+ * The canonical Node 24 family shared by factory-project workflows.
35
35
  *
36
36
  * Consumers must pass this (or their own complete family) to
37
- * `factoryWorkflow`; there is deliberately no implicit default. In
38
- * particular, Firedup's v6 action family must stay v6 during adoption.
37
+ * `factoryWorkflow`; there is deliberately no implicit default. Every action
38
+ * in this family declares `runs.using: node24` at the named release.
39
39
  */
40
- export const NODE_PNPM_ACTION_FAMILY_V4 = {
40
+ export const NODE_PNPM_ACTION_FAMILY_NODE24 = {
41
41
  checkout: {
42
- tag: "v4.4.0",
43
- uses: "actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
42
+ tag: "v7.0.1",
43
+ uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1",
44
44
  },
45
- id: "node-pnpm-v4",
45
+ id: "node-pnpm-node24",
46
46
  setupNode: {
47
- tag: "v4.4.0",
48
- uses: "actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020",
47
+ tag: "v7.0.0",
48
+ uses: "actions/setup-node@820762786026740c76f36085b0efc47a31fe5020",
49
49
  },
50
50
  setupPnpm: {
51
- tag: "v4.3.0",
52
- uses: "pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1",
51
+ tag: "v6.0.9",
52
+ uses: "pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271",
53
53
  },
54
54
  } as const satisfies NodePnpmActionFamily;
@@ -0,0 +1,192 @@
1
+ const githubExpression = (expression: string): string =>
2
+ `\${{ ${expression} }}`;
3
+
4
+ const BASE_SHA_EXPRESSION = githubExpression(
5
+ "github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before"
6
+ );
7
+ const HEAD_SHA_EXPRESSION = githubExpression(
8
+ "github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha"
9
+ );
10
+
11
+ export interface HostedDocsOnlyClassificationPolicy {
12
+ readonly docsOnlyFiles?: readonly string[];
13
+ readonly docsOnlyMarkdownPrefixes?: readonly string[];
14
+ readonly docsOnlyPrefixes?: readonly string[];
15
+ }
16
+
17
+ interface NormalizedHostedDocsOnlyClassificationPolicy {
18
+ readonly docsOnlyFiles: readonly string[];
19
+ readonly docsOnlyMarkdownPrefixes: readonly string[];
20
+ readonly docsOnlyPrefixes: readonly string[];
21
+ }
22
+
23
+ export interface HostedDocsOnlyDetectorStep {
24
+ readonly continueOnError: true;
25
+ readonly env: Readonly<{
26
+ BASE_SHA: string;
27
+ HEAD_SHA: string;
28
+ }>;
29
+ readonly id: "docs-only";
30
+ readonly name: "Detect docs-only changes";
31
+ readonly outputs: readonly ["docs_only"];
32
+ readonly run: string;
33
+ }
34
+
35
+ const stringArray = (value: unknown): readonly string[] | undefined => {
36
+ if (value === undefined) {
37
+ return [];
38
+ }
39
+ if (
40
+ Array.isArray(value) &&
41
+ value.every((entry) => typeof entry === "string" && entry.length > 0)
42
+ ) {
43
+ return value;
44
+ }
45
+ };
46
+
47
+ const normalizePolicy = (
48
+ value: unknown
49
+ ): NormalizedHostedDocsOnlyClassificationPolicy | undefined => {
50
+ if (!(value && typeof value === "object" && !Array.isArray(value))) {
51
+ return;
52
+ }
53
+
54
+ const policy = value as Record<string, unknown>;
55
+ const docsOnlyFiles = stringArray(policy.docsOnlyFiles);
56
+ const docsOnlyMarkdownPrefixes = stringArray(policy.docsOnlyMarkdownPrefixes);
57
+ const docsOnlyPrefixes = stringArray(policy.docsOnlyPrefixes);
58
+
59
+ if (!(docsOnlyFiles && docsOnlyMarkdownPrefixes && docsOnlyPrefixes)) {
60
+ return;
61
+ }
62
+
63
+ return {
64
+ docsOnlyFiles,
65
+ docsOnlyMarkdownPrefixes,
66
+ docsOnlyPrefixes,
67
+ };
68
+ };
69
+
70
+ const shellQuoteSegment = (value: string): string =>
71
+ `'${value.replaceAll("'", String.raw`'\''`)}'`;
72
+
73
+ const shellQuote = (value: string): string =>
74
+ value.split("$").map(shellQuoteSegment).join("'$'");
75
+
76
+ const shellArray = (values: readonly string[]): string =>
77
+ `(${values.map(shellQuote).join(" ")})`;
78
+
79
+ const shellExpansion = (name: string): string => `\${${name}}`;
80
+
81
+ const malformedPolicyScript = String.raw`printf 'docs_only=false\n' >> "$GITHUB_OUTPUT"`;
82
+
83
+ const detectorScript = (
84
+ policy: NormalizedHostedDocsOnlyClassificationPolicy
85
+ ): string => String.raw`docs_only=false
86
+ changed_entries_file=""
87
+
88
+ finish() {
89
+ detector_status=$?
90
+ if [ -n "$changed_entries_file" ]; then
91
+ rm -f "$changed_entries_file" || true
92
+ fi
93
+ if [ "$detector_status" -ne 0 ]; then
94
+ docs_only=false
95
+ fi
96
+ printf 'docs_only=%s\n' "$docs_only" >> "$GITHUB_OUTPUT"
97
+ exit 0
98
+ }
99
+ trap finish EXIT
100
+ set -eo pipefail
101
+
102
+ if [ -z "$BASE_SHA" ] || [ -z "$HEAD_SHA" ] ||
103
+ [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ] ||
104
+ [ "$HEAD_SHA" = "0000000000000000000000000000000000000000" ]; then
105
+ exit 0
106
+ fi
107
+
108
+ changed_entries_file="$(mktemp)"
109
+ git diff --name-status -z --find-renames --find-copies-harder \
110
+ "$BASE_SHA...$HEAD_SHA" -- > "$changed_entries_file"
111
+ if [ ! -s "$changed_entries_file" ]; then
112
+ exit 0
113
+ fi
114
+
115
+ docs_only_files=${shellArray(policy.docsOnlyFiles)}
116
+ docs_only_prefixes=${shellArray(policy.docsOnlyPrefixes)}
117
+ docs_only_markdown_prefixes=${shellArray(policy.docsOnlyMarkdownPrefixes)}
118
+
119
+ is_docs_only_path() {
120
+ candidate="$1"
121
+ if [ "$candidate" = "software-factory.profile.json" ]; then
122
+ return 1
123
+ fi
124
+ for policy_path in "${shellExpansion("docs_only_files[@]")}"; do
125
+ if [ "$candidate" = "$policy_path" ]; then
126
+ return 0
127
+ fi
128
+ done
129
+ for policy_path in "${shellExpansion("docs_only_prefixes[@]")}"; do
130
+ if [[ "$candidate" == "$policy_path"* ]]; then
131
+ return 0
132
+ fi
133
+ done
134
+ if [[ "$candidate" == *.md ]]; then
135
+ for policy_path in "${shellExpansion("docs_only_markdown_prefixes[@]")}"; do
136
+ if [[ "$candidate" == "$policy_path"* ]]; then
137
+ return 0
138
+ fi
139
+ done
140
+ fi
141
+ return 1
142
+ }
143
+
144
+ entry_count=0
145
+ while IFS= read -r -d '' change_status <&3; do
146
+ entry_count=$((entry_count + 1))
147
+ case "$change_status" in
148
+ A|D|M)
149
+ IFS= read -r -d '' changed_path <&3 || exit 0
150
+ is_docs_only_path "$changed_path" || exit 0
151
+ ;;
152
+ R*|C*)
153
+ if [[ ! "$change_status" =~ ^[RC][0-9]{1,3}$ ]]; then
154
+ exit 0
155
+ fi
156
+ IFS= read -r -d '' source_path <&3 || exit 0
157
+ IFS= read -r -d '' destination_path <&3 || exit 0
158
+ is_docs_only_path "$source_path" || exit 0
159
+ is_docs_only_path "$destination_path" || exit 0
160
+ ;;
161
+ *)
162
+ exit 0
163
+ ;;
164
+ esac
165
+ done 3< "$changed_entries_file"
166
+
167
+ if [ "$entry_count" -gt 0 ]; then
168
+ docs_only=true
169
+ fi`;
170
+
171
+ /**
172
+ * Generate the pre-install hosted detector from a consumer's canonical
173
+ * classification policy. The returned object is structurally accepted by
174
+ * gagen's `step()` without adding a gagen runtime dependency.
175
+ */
176
+ export const hostedDocsOnlyDetectorStep = (
177
+ policy: HostedDocsOnlyClassificationPolicy
178
+ ): HostedDocsOnlyDetectorStep => {
179
+ const normalized = normalizePolicy(policy);
180
+
181
+ return Object.freeze({
182
+ continueOnError: true as const,
183
+ env: Object.freeze({
184
+ BASE_SHA: BASE_SHA_EXPRESSION,
185
+ HEAD_SHA: HEAD_SHA_EXPRESSION,
186
+ }),
187
+ id: "docs-only" as const,
188
+ name: "Detect docs-only changes" as const,
189
+ outputs: ["docs_only"] as const,
190
+ run: normalized ? detectorScript(normalized) : malformedPolicyScript,
191
+ });
192
+ };
package/src/index.ts CHANGED
@@ -3,13 +3,13 @@
3
3
  * repeat across Patronage factory projects (#266, #268).
4
4
  *
5
5
  * A pure library: no `bin`, no `alchemy` or `effect` dependency, no config
6
- * surface of its own. Everything takes a plain typed options object; mapping
7
- * `software-factory.profile.json` into those objects is the profile's job, not
8
- * this package's (ADR 0021).
6
+ * surface of its own. Everything takes plain typed values; callers pass
7
+ * canonical policy from `software-factory.profile.json` without this package
8
+ * loading or owning that profile (ADR 0021).
9
9
  */
10
10
 
11
11
  export {
12
- NODE_PNPM_ACTION_FAMILY_V4,
12
+ NODE_PNPM_ACTION_FAMILY_NODE24,
13
13
  type NodePnpmActionFamily,
14
14
  type PinnedAction,
15
15
  } from "./actions.ts";
@@ -36,6 +36,11 @@ export {
36
36
  type SetupNodeStepOptions,
37
37
  type WorkflowStep,
38
38
  } from "./factory-workflow.ts";
39
+ export {
40
+ type HostedDocsOnlyClassificationPolicy,
41
+ type HostedDocsOnlyDetectorStep,
42
+ hostedDocsOnlyDetectorStep,
43
+ } from "./hosted-docs-only-detector.ts";
39
44
  export {
40
45
  type ExecuteAlchemyEntryOptions,
41
46
  type ExecuteAlchemyEntryResult,