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

Issue:
🐛 Bug: Transition mode shouldn't downgrade prepare/action.yml's Node version
### Bug Report Checklist

- [x] I have tried restarting my IDE and the issue persists.
- [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.

### Expected

Running `npx create-typescript-app` in a repo that already has CTA's `.github/actions/prepare/action.yml` shouldn't downgrade any versions mentioned in the file.

### Actual

The `with.node-version` on `actions/setup-node` is being overridden to 20, even if it was something higher like 22.

https://github.com/JoshuaKGoldberg/zod-tsconfig/pull/19/files#diff-a9d4fab00e8a7db561d841247150ac84dbf3c80ac94f96c4969bc4b66417eb4eR11

### Additional Info

🎁 

Pull request description:
fix: use options.node in CI prepare action
## PR Checklist

- [x] Addresses an existing open issue: fixes #2096
- [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

Switches `.github/actions/prepare/action.yml` to use the Node version requested by options, not a hardcoded `"20"`.

Also fixes the `node` option schema to no longer be marked `.optional()`. It was always inferred, so this was inaccurate.

🎁 

Patch:
diff --git a/.github/actions/prepare/action.yml b/.github/actions/prepare/action.yml
index 0314491..33e3f1e 100644
--- a/.github/actions/prepare/action.yml
+++ b/.github/actions/prepare/action.yml
@@ -8,7 +8,7 @@ runs:
     - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
       with:
         cache: pnpm
-        node-version: "20"
+        node-version: 22.14.0
     - run: pnpm install --frozen-lockfile
       shell: bash
   using: composite
diff --git a/src/base.ts b/src/base.ts
index 9a7996a..df48725 100644
--- a/src/base.ts
+++ b/src/base.ts
@@ -130,7 +130,6 @@ export const base = createBase({
 				minimum: z.string(),
 				pinned: z.string().optional(),
 			})
-			.optional()
 			.describe("Node.js engine version(s) to pin and require a minimum of"),
 		owner: z.string().describe("organization or user owning the repository"),
 		packageData: z
diff --git a/src/blocks/blockGitHubActionsCI.ts b/src/blocks/blockGitHubActionsCI.ts
index 7404f93..51baa03 100644
--- a/src/blocks/blockGitHubActionsCI.ts
+++ b/src/blocks/blockGitHubActionsCI.ts
@@ -64,7 +64,11 @@ export const blockGitHubActionsCI = base.createBlock({
 												"v4",
 												options.workflowsVersions,
 											),
-											with: { cache: "pnpm", "node-version": "20" },
+											with: {
+												cache: "pnpm",
+												"node-version":
+													options.node.pinned ?? options.node.minimum,
+											},
 										},
 										{
 											run: "pnpm install --frozen-lockfile",
diff --git a/src/blocks/blockNvmrc.ts b/src/blocks/blockNvmrc.ts
index 413d7f6..467c121 100644
--- a/src/blocks/blockNvmrc.ts
+++ b/src/blocks/blockNvmrc.ts
@@ -12,7 +12,7 @@ export const blockNvmrc = base.createBlock({
 					overrides: [{ files: ".nvmrc", options: { parser: "yaml" } }],
 				}),
 			],
-			...(options.node?.pinned && {
+			...(options.node.pinned && {
 				files: {
 					".nvmrc": `${options.node.pinned}\n`,
 				},
diff --git a/src/blocks/blockPackageJson.ts b/src/blocks/blockPackageJson.ts
index 6d30b17..2f9c55c 100644
--- a/src/blocks/blockPackageJson.ts
+++ b/src/blocks/blockPackageJson.ts
@@ -53,11 +53,9 @@ export const blockPackageJson = base.createBlock({
 							devDependencies: Object.keys(devDependencies).length
 								? devDependencies
 								: undefined,
-							...(options.node && {
-								engines: {
-									node: `>=${options.node.minimum}`,
-								},
-							}),
+							engines: {
+								node: `>=${options.node.minimum}`,
+							},
 							...(options.pnpm && {
 								packageManager: `pnpm@${options.pnpm}`,
 							}),
diff --git a/src/blocks/options.fakes.ts b/src/blocks/options.fakes.ts
index 6acd668..f7a5f2e 100644
--- a/src/blocks/options.fakes.ts
+++ b/src/blocks/options.fakes.ts
@@ -14,6 +14,9 @@ export const optionsBase = {
 		npm: "npm@email.com",
 	},
 	emoji: "💖",
+	node: {
+		minimum: "20.12.0",
+	},
 	owner: "test-owner",
 	preset: "minimal",
 	repository: "test-repository",


Test patch:
diff --git a/src/blocks/blockCSpell.test.ts b/src/blocks/blockCSpell.test.ts
index 0a1be95..fb8ae68 100644
--- a/src/blocks/blockCSpell.test.ts
+++ b/src/blocks/blockCSpell.test.ts
@@ -280,7 +280,7 @@ describe("blockCSpell", () => {
 			  "scripts": [
 			    {
 			      "commands": [
-			        "node path/to/cspell-populate-words/bin/index.mjs --words "access" --words "public" --words "description" --words "Test description" --words "directory" --words "." --words "documentation" --words "readme" --words "usage" --words "Test usage." --words "email" --words "github" --words "github@email.com" --words "npm" --words "npm@email.com" --words "emoji" --words "💖" --words "owner" --words "test-owner" --words "preset" --words "minimal" --words "repository" --words "test-repository" --words "title" --words "Test Title"",
+			        "node path/to/cspell-populate-words/bin/index.mjs --words "access" --words "public" --words "description" --words "Test description" --words "directory" --words "." --words "documentation" --words "readme" --words "usage" --words "Test usage." --words "email" --words "github" --words "github@email.com" --words "npm" --words "npm@email.com" --words "emoji" --words "💖" --words "node" --words "minimum" --words "20.12.0" --words "owner" --words "test-owner" --words "preset" --words "minimal" --words "repository" --words "test-repository" --words "title" --words "Test Title"",
 			      ],
 			      "phase": 3,
 			    },
diff --git a/src/blocks/blockGitHubActionsCI.test.ts b/src/blocks/blockGitHubActionsCI.test.ts
index 0f167df..b8b5dee 100644
--- a/src/blocks/blockGitHubActionsCI.test.ts
+++ b/src/blocks/blockGitHubActionsCI.test.ts
@@ -5,6 +5,217 @@ import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js";
 import { optionsBase } from "./options.fakes.js";
 
 describe("blockGitHubActionsCI", () => {
+	test("without options.node.pinned", () => {
+		const creation = testBlock(blockGitHubActionsCI, {
+			options: {
+				...optionsBase,
+				node: {
+					minimum: "20.12.0",
+				},
+			},
+		});
+
+		expect(creation).toMatchInlineSnapshot(`
+			{
+			  "addons": [
+			    {
+			      "addons": {
+			        "requiredStatusChecks": undefined,
+			      },
+			      "block": [Function],
+			    },
+			  ],
+			  "files": {
+			    ".github": {
+			      "actions": {
+			        "prepare": {
+			          "action.yml": "description: Prepares the repo for a typical CI job
+
+			name: Prepare
+
+			runs:
+			  steps:
+			    - uses: pnpm/action-setup@v4
+			    - uses: actions/setup-node@v4
+			      with:
+			        cache: pnpm
+			        node-version: 20.12.0
+			    - run: pnpm install --frozen-lockfile
+			      shell: bash
+			  using: composite
+			",
+			        },
+			      },
+			      "workflows": {
+			        "accessibility-alt-text-bot.yml": "jobs:
+			  accessibility_alt_text_bot:
+			    if: \${{ !endsWith(github.actor, '[bot]') }}
+			    runs-on: ubuntu-latest
+			    steps:
+			      - uses: github/accessibility-alt-text-bot@v1.4.0
+
+
+			name: Accessibility Alt Text Bot
+
+
+			on:
+			  issue_comment:
+			    types:
+			      - created
+			      - edited
+			  issues:
+			    types:
+			      - edited
+			      - opened
+			  pull_request:
+			    types:
+			      - edited
+			      - opened
+
+
+			permissions:
+			  issues: write
+			  pull-requests: write
+			",
+			        "ci.yml": undefined,
+			        "pr-review-requested.yml": "jobs:
+			  pr_review_requested:
+			    runs-on: ubuntu-latest
+			    steps:
+			      - uses: actions-ecosystem/action-remove-labels@v1
+			        with:
+			          labels: 'status: waiting for author'
+			      - if: failure()
+			        run: |
+			          echo "Don't worry if the previous step failed."
+			          echo "See https://github.com/actions-ecosystem/action-remove-labels/issues/221."
+
+
+			name: PR Review Requested
+
+
+			on:
+			  pull_request_target:
+			    types:
+			      - review_requested
+
+
+			permissions:
+			  pull-requests: write
+			",
+			      },
+			    },
+			  },
+			}
+		`);
+	});
+
+	test("with options.node.pinned", () => {
+		const creation = testBlock(blockGitHubActionsCI, {
+			options: {
+				...optionsBase,
+				node: {
+					minimum: "20.12.0",
+					pinned: "22.12.0",
+				},
+			},
+		});
+
+		expect(creation).toMatchInlineSnapshot(`
+			{
+			  "addons": [
+			    {
+			      "addons": {
+			        "requiredStatusChecks": undefined,
+			      },
+			      "block": [Function],
+			    },
+			  ],
+			  "files": {
+			    ".github": {
+			      "actions": {
+			        "prepare": {
+			          "action.yml": "description: Prepares the repo for a typical CI job
+
+			name: Prepare
+
+			runs:
+			  steps:
+			    - uses: pnpm/action-setup@v4
+			    - uses: actions/setup-node@v4
+			      with:
+			        cache: pnpm
+			        node-version: 22.12.0
+			    - run: pnpm install --frozen-lockfile
+			      shell: bash
+			  using: composite
+			",
+			        },
+			      },
+			      "workflows": {
+			        "accessibility-alt-text-bot.yml": "jobs:
+			  accessibility_alt_text_bot:
+			    if: \${{ !endsWith(github.actor, '[bot]') }}
+			    runs-on: ubuntu-latest
+			    steps:
+			      - uses: github/accessibility-alt-text-bot@v1.4.0
+
+
+			name: Accessibility Alt Text Bot
+
+
+			on:
+			  issue_comment:
+			    types:
+			      - created
+			      - edited
+			  issues:
+			    types:
+			      - edited
+			      - opened
+			  pull_request:
+			    types:
+			      - edited
+			      - opened
+
+
+			permissions:
+			  issues: write
+			  pull-requests: write
+			",
+			        "ci.yml": undefined,
+			        "pr-review-requested.yml": "jobs:
+			  pr_review_requested:
+			    runs-on: ubuntu-latest
+			    steps:
+			      - uses: actions-ecosystem/action-remove-labels@v1
+			        with:
+			          labels: 'status: waiting for author'
+			      - if: failure()
+			        run: |
+			          echo "Don't worry if the previous step failed."
+			          echo "See https://github.com/actions-ecosystem/action-remove-labels/issues/221."
+
+
+			name: PR Review Requested
+
+
+			on:
+			  pull_request_target:
+			    types:
+			      - review_requested
+
+
+			permissions:
+			  pull-requests: write
+			",
+			      },
+			    },
+			  },
+			}
+		`);
+	});
+
 	test("without addons or mode", () => {
 		const creation = testBlock(blockGitHubActionsCI, {
 			options: optionsBase,
@@ -34,7 +245,7 @@ describe("blockGitHubActionsCI", () => {
 			    - uses: actions/setup-node@v4
 			      with:
 			        cache: pnpm
-			        node-version: '20'
+			        node-version: 20.12.0
 			    - run: pnpm install --frozen-lockfile
 			      shell: bash
 			  using: composite
@@ -144,7 +355,7 @@ describe("blockGitHubActionsCI", () => {
 			    - uses: actions/setup-node@v4
 			      with:
 			        cache: pnpm
-			        node-version: '20'
+			        node-version: 20.12.0
 			    - run: pnpm install --frozen-lockfile
 			      shell: bash
 			  using: composite
@@ -261,7 +472,7 @@ describe("blockGitHubActionsCI", () => {
 			    - uses: actions/setup-node@v4
 			      with:
 			        cache: pnpm
-			        node-version: '20'
+			        node-version: 20.12.0
 			    - run: pnpm install --frozen-lockfile
 			      shell: bash
 			  using: composite
diff --git a/src/blocks/blockNvmrc.test.ts b/src/blocks/blockNvmrc.test.ts
index 341801b..135135a 100644
--- a/src/blocks/blockNvmrc.test.ts
+++ b/src/blocks/blockNvmrc.test.ts
@@ -18,7 +18,7 @@ describe("blockNvmrc", () => {
 		});
 	});
 
-	it("also includes files when options.node exists", () => {
+	it("also includes files when options.node.pinned exists", () => {
 		const creation = testBlock(blockNvmrc, {
 			options: {
 				...optionsBase,
diff --git a/src/blocks/blockPackageJson.test.ts b/src/blocks/blockPackageJson.test.ts
index ae4884a..1c43051 100644
--- a/src/blocks/blockPackageJson.test.ts
+++ b/src/blocks/blockPackageJson.test.ts
@@ -17,7 +17,7 @@ describe("blockPackageJson", () => {
 		expect(creation).toMatchInlineSnapshot(`
 			{
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"]}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"engines":{"node":">=20.12.0"}}",
 			  },
 			  "scripts": [
 			    {
@@ -50,7 +50,7 @@ describe("blockPackageJson", () => {
 			    },
 			  ],
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"]}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"engines":{"node":">=20.12.0"}}",
 			  },
 			  "scripts": [
 			    {
@@ -81,7 +81,7 @@ describe("blockPackageJson", () => {
 		expect(creation).toMatchInlineSnapshot(`
 			{
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"dependencies":{"is-odd":"1.2.3"},"other":true}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"dependencies":{"is-odd":"1.2.3"},"engines":{"node":">=20.12.0"},"other":true}",
 			  },
 			  "scripts": [
 			    {
@@ -116,7 +116,7 @@ describe("blockPackageJson", () => {
 		expect(creation).toMatchInlineSnapshot(`
 			{
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"dependencies":{"is-odd":"1.2.3"},"devDependencies":{"is-even":"4.5.6"},"other":true}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"dependencies":{"is-odd":"1.2.3"},"devDependencies":{"is-even":"4.5.6"},"engines":{"node":">=20.12.0"},"other":true}",
 			  },
 			  "scripts": [
 			    {
@@ -142,7 +142,7 @@ describe("blockPackageJson", () => {
 		expect(creation).toMatchInlineSnapshot(`
 			{
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","keywords":["abc","def","ghi"],"repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"]}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","keywords":["abc","def","ghi"],"repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"engines":{"node":">=20.12.0"}}",
 			  },
 			  "scripts": [
 			    {
@@ -195,7 +195,7 @@ describe("blockPackageJson", () => {
 		expect(creation).toMatchInlineSnapshot(`
 			{
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","bin":"bin/index.js","files":["README.md","bin/index.js","package.json"]}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","bin":"bin/index.js","files":["README.md","bin/index.js","package.json"],"engines":{"node":">=20.12.0"}}",
 			  },
 			  "scripts": [
 			    {
@@ -223,7 +223,7 @@ describe("blockPackageJson", () => {
 		expect(creation).toMatchInlineSnapshot(`
 			{
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","bin":{"absolute":"bin/absolute.js","relative":"./bin/relative.js"},"files":["README.md","bin/absolute.js","bin/relative.js","package.json"]}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","bin":{"absolute":"bin/absolute.js","relative":"./bin/relative.js"},"files":["README.md","bin/absolute.js","bin/relative.js","package.json"],"engines":{"node":">=20.12.0"}}",
 			  },
 			  "scripts": [
 			    {
@@ -258,7 +258,7 @@ describe("blockPackageJson", () => {
 		expect(creation).toMatchInlineSnapshot(`
 			{
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"peerDependencies":{"@types/estree":">=1","eslint":">=8"},"peerDependenciesMeta":{"@types/estree":{"optional":true}}}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"peerDependencies":{"@types/estree":">=1","eslint":">=8"},"peerDependenciesMeta":{"@types/estree":{"optional":true}},"engines":{"node":">=20.12.0"}}",
 			  },
 			  "scripts": [
 			    {
@@ -281,7 +281,7 @@ describe("blockPackageJson", () => {
 		expect(creation).toMatchInlineSnapshot(`
 			{
 			  "files": {
-			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"]}",
+			    "package.json": "{"name":"test-repository","version":"0.0.0","description":"A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵","repository":{"type":"git","url":"git+https://github.com/test-owner/test-repository.git"},"license":"MIT","author":{"email":"npm@email.com"},"type":"module","files":["README.md","package.json"],"engines":{"node":">=20.12.0"}}",
 			  },
 			  "scripts": [
 			    {