Repository: JoshuaKGoldberg/create-typescript-app (ts)

Issue:
🚀 Feature: Turn the Node.js version used in CI into an Addon for blockGitHubActionsCI
### Feature Request Checklist

- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue.

### Overview

Most of my repos run on the _latest_ known+supported Node.js version in CI. This hasn't been an issue yet that I know of, and I like running many repos on this "latest and greatest" strategy as a form of testing to see whether & how it ever could be.

But, some repos are higher visibility + usage and would prefer to run on the _oldest_ supported Node.js version in CI. They'll want to be able to customize the version set in the preparation action. Example: https://github.com/JoshuaKGoldberg/eslint-plugin-package-json/pull/993#discussion_r2032053502

### Additional Info

🎁 

Pull request description:
feat: added nodeVersion Addon to blockGitHubActionsCI
## PR Checklist

- [x] Addresses an existing open issue: fixes #2183
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/create-typescript-app/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Includes an `intake` method so it can be inferred from disk.

🎁

Patch:
diff --git a/src/blocks/blockGitHubActionsCI.ts b/src/blocks/blockGitHubActionsCI.ts
index 51baa03..6597177 100644
--- a/src/blocks/blockGitHubActionsCI.ts
+++ b/src/blocks/blockGitHubActionsCI.ts
@@ -1,3 +1,4 @@
+import _ from "lodash";
 import { z } from "zod";
 
 import { base } from "../base.js";
@@ -7,6 +8,7 @@ import { blockRepositoryBranchRuleset } from "./blockRepositoryBranchRuleset.js"
 import { createMultiWorkflowFile } from "./files/createMultiWorkflowFile.js";
 import { createSoloWorkflowFile } from "./files/createSoloWorkflowFile.js";
 import { formatYaml } from "./files/formatYaml.js";
+import { intakeFileAsYaml } from "./intake/intakeFileAsYaml.js";
 
 export const zActionStep = z.intersection(
 	z.object({
@@ -17,6 +19,11 @@ export const zActionStep = z.intersection(
 	z.union([z.object({ run: z.string() }), z.object({ uses: z.string() })]),
 );
 
+interface RunStep {
+	uses?: unknown;
+	with?: Record<string, string>;
+}
+
 export const blockGitHubActionsCI = base.createBlock({
 	about: {
 		name: "GitHub Actions CI",
@@ -32,9 +39,43 @@ export const blockGitHubActionsCI = base.createBlock({
 				}),
 			)
 			.optional(),
+		nodeVersion: z.union([z.number(), z.string()]).optional(),
+	},
+	intake({ files }) {
+		const actionYml = intakeFileAsYaml(files, [
+			".github",
+			"actions",
+			"prepare",
+			"action.yml",
+		]);
+		if (!actionYml) {
+			return;
+		}
+
+		const steps = _.get(actionYml, ["runs", "steps"]) as RunStep[] | undefined;
+		if (!steps || !Array.isArray(steps)) {
+			return undefined;
+		}
+
+		const setupNodeStep = steps.find(
+			(step) =>
+				typeof step.uses === "string" &&
+				step.uses.startsWith("actions/setup-node"),
+		);
+		if (!setupNodeStep) {
+			return undefined;
+		}
+
+		const nodeVersion = setupNodeStep.with?.["node-version"];
+		if (!nodeVersion) {
+			return undefined;
+		}
+
+		return { nodeVersion };
 	},
 	produce({ addons, options }) {
-		const { jobs } = addons;
+		const { jobs, nodeVersion = options.node.pinned ?? options.node.minimum } =
+			addons;
 
 		return {
 			addons: [
@@ -66,8 +107,7 @@ export const blockGitHubActionsCI = base.createBlock({
 											),
 											with: {
 												cache: "pnpm",
-												"node-version":
-													options.node.pinned ?? options.node.minimum,
+												"node-version": nodeVersion,
 											},
 										},
 										{


Test patch:
diff --git a/src/blocks/blockGitHubActionsCI.test.ts b/src/blocks/blockGitHubActionsCI.test.ts
index b8b5dee..78df455 100644
--- a/src/blocks/blockGitHubActionsCI.test.ts
+++ b/src/blocks/blockGitHubActionsCI.test.ts
@@ -1,5 +1,6 @@
-import { testBlock } from "bingo-stratum-testers";
-import { describe, expect, test } from "vitest";
+import { testBlock, testIntake } from "bingo-stratum-testers";
+import jsYaml from "js-yaml";
+import { describe, expect, it, test } from "vitest";
 
 import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js";
 import { optionsBase } from "./options.fakes.js";
@@ -565,4 +566,157 @@ describe("blockGitHubActionsCI", () => {
 			}
 		`);
 	});
+
+	describe("intake", () => {
+		it("returns undefined when action.yml does not exist", () => {
+			const actual = testIntake(blockGitHubActionsCI, {
+				files: {},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined when action.yml contains invalid yml", () => {
+			const actual = testIntake(blockGitHubActionsCI, {
+				files: {
+					".github": {
+						actions: {
+							prepare: {
+								"action.yml": ["invalid yml!"],
+							},
+						},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined when action.yml does not contain a runs entry", () => {
+			const actual = testIntake(blockGitHubActionsCI, {
+				files: {
+					".github": {
+						actions: {
+							prepare: {
+								"action.yml": [
+									jsYaml.dump({
+										other: {
+											steps: [],
+										},
+									}),
+								],
+							},
+						},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined when action.yml runs steps is not an array", () => {
+			const actual = testIntake(blockGitHubActionsCI, {
+				files: {
+					".github": {
+						actions: {
+							prepare: {
+								"action.yml": [
+									jsYaml.dump({
+										runs: {
+											steps: true,
+										},
+									}),
+								],
+							},
+						},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined env when action.yml contains a test action with actions/setup-node step", () => {
+			const actual = testIntake(blockGitHubActionsCI, {
+				files: {
+					".github": {
+						actions: {
+							prepare: {
+								"action.yml": [
+									jsYaml.dump({
+										runs: {
+											steps: [
+												{
+													uses: "actions/other@v1",
+												},
+											],
+										},
+									}),
+								],
+							},
+						},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns undefined env when action.yml contains a test action with no env in its actions/setup-node step", () => {
+			const actual = testIntake(blockGitHubActionsCI, {
+				files: {
+					".github": {
+						actions: {
+							prepare: {
+								"action.yml": [
+									jsYaml.dump({
+										runs: {
+											steps: [
+												{
+													uses: "actions/setup-node@v4",
+												},
+											],
+										},
+									}),
+								],
+							},
+						},
+					},
+				},
+			});
+
+			expect(actual).toBeUndefined();
+		});
+
+		it("returns nodeVersion when action.yml contains a test action with node-version in its actions/setup/node step", () => {
+			const nodeVersion = "20.10.0";
+
+			const actual = testIntake(blockGitHubActionsCI, {
+				files: {
+					".github": {
+						actions: {
+							prepare: {
+								"action.yml": [
+									jsYaml.dump({
+										runs: {
+											steps: [
+												{
+													uses: "actions/setup-node@v4",
+													with: {
+														"node-version": nodeVersion,
+													},
+												},
+											],
+										},
+									}),
+								],
+							},
+						},
+					},
+				},
+			});
+
+			expect(actual).toEqual({ nodeVersion });
+		});
+	});
 });