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

Issue:
🐛 Bug: Transition mode should not lower GitHub Action pins/versions
### 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

Many repos set up with CTA have Renovate automatically bumping GHA dependencies to their latest versions. If:

1. A GitHub Actions dependency is bumped in a repo
2. `npx create-typescript-app` run in that repo resolves to a CTA with an older version of that dependency

...then the repo will get downgraded back down to the older version.


### Actual

The GitHub Actions Block(s) should check if there's an existing version for deps, and use the greater of (it, CTA's). That way transition mode won't lower versions in `.yml` files.


### Additional Info

Sibling issue for npm deps: #1997

🎁 

Pull request:
fix: preserve GitHub Actions hashes and versions
## PR Checklist

- [x] Addresses an existing open issue: fixes #1998
- [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

Adds a new `workflowsVersions` option that reads in any existing pins and comment hashes.

Printing out comment hashes is actually tricky: they're not part of the AST, so I had to add a post-processing step after YAMl printing to turn any `uses:` string ending with a `#` comment back from `uses: '... # ...'` to `uses: ... # ...`.

Skips adding tests for `readWorkflowVersions` pending https://github.com/JoshuaKGoldberg/bingo/issues/308.

🎁 


Patch:
diff --git a/docs/Configuration Files.md b/docs/Configuration Files.md
index fedb449..542a747 100644
--- a/docs/Configuration Files.md
+++ b/docs/Configuration Files.md
@@ -19,18 +19,19 @@ This includes the options described in [CLI](./CLI.md).
 Some of create-typescript-app's options are rich objects, typically very long strings, or otherwise not reasonable on the CLI.
 These options are generally only programmatically used internally, but can still be specified in a configuration file:
 
-| Option           | Description                                                              | Default (If Available)                                    |
-| ---------------- | ------------------------------------------------------------------------ | --------------------------------------------------------- |
-| `contributors`   | AllContributors contributors to store in `.all-contributorsrc`           | Existing contributors in the file, or just your username  |
-| `documentation`  | any additional docs to add to `.github/DEVELOPMENT.md`                   | Extra content in `.github/DEVELOPMENT.md`                 |
-| `existingLabels` | existing labels to switch to the standard template labels                | Existing labels on the repository from the GitHub API     |
-| `explainer`      | additional `README.md` sentence(s) describing the package                | Extra content in `README.md` after badges and description |
-| `guide`          | link to a contribution guide to place at the top of development docs     | Block quote on top of `.github/DEVELOPMENT.md`            |
-| `logo`           | local image file and alt text to display near the top of the `README.md` | First non-badge image's `alt` and `src` in `README.md`    |
-| `node`           | Node.js engine version(s) to pin and require a minimum of                | Values from `.nvmrc` and `package.json`'s `"engines"`     |
-| `packageData`    | additional properties to include in `package.json`                       | Existing values in `package.json`                         |
-| `rulesetId`      | GitHub branch ruleset ID for main branch protections                     | Existing ruleset on the `main` branch from the GitHub API |
-| `usage`          | Markdown docs to put in `README.md` under the `## Usage` heading         | Existing usage lines in `README.md`                       |
+| Option              | Description                                                              | Default (If Available)                                    |
+| ------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------- |
+| `contributors`      | AllContributors contributors to store in `.all-contributorsrc`           | Existing contributors in the file, or just your username  |
+| `documentation`     | any additional docs to add to `.github/DEVELOPMENT.md`                   | Extra content in `.github/DEVELOPMENT.md`                 |
+| `existingLabels`    | existing labels to switch to the standard template labels                | Existing labels on the repository from the GitHub API     |
+| `explainer`         | additional `README.md` sentence(s) describing the package                | Extra content in `README.md` after badges and description |
+| `guide`             | link to a contribution guide to place at the top of development docs     | Block quote on top of `.github/DEVELOPMENT.md`            |
+| `logo`              | local image file and alt text to display near the top of the `README.md` | First non-badge image's `alt` and `src` in `README.md`    |
+| `node`              | Node.js engine version(s) to pin and require a minimum of                | Values from `.nvmrc` and `package.json`'s `"engines"`     |
+| `packageData`       | additional properties to include in `package.json`                       | Existing values in `package.json`                         |
+| `rulesetId`         | GitHub branch ruleset ID for main branch protections                     | Existing ruleset on the `main` branch from the GitHub API |
+| `usage`             | Markdown docs to put in `README.md` under the `## Usage` heading         | Existing usage lines in `README.md`                       |
+| `workflowsVersions` | existing versions of GitHub Actions workflows used                       | Existing action versions in `.github/workflows/*.yml`     |
 
 For example, changing `node` versions to values different from what would be inferred:
 
diff --git a/package.json b/package.json
index 96c9a35..7234718 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
 		"bingo": "^0.5.8",
 		"bingo-fs": "^0.5.4",
 		"bingo-stratum": "^0.5.7",
+		"cached-factory": "^0.1.0",
 		"cspell-populate-words": "^0.3.0",
 		"execa": "^9.5.2",
 		"git-url-parse": "^16.0.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4fde15c..5d08f08 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
       bingo-stratum:
         specifier: ^0.5.7
         version: 0.5.7(bingo-fs@0.5.4)(bingo-systems@0.5.4)(bingo@0.5.8)(zod@3.24.2)
+      cached-factory:
+        specifier: ^0.1.0
+        version: 0.1.0
       cspell-populate-words:
         specifier: ^0.3.0
         version: 0.3.0
diff --git a/src/base.ts b/src/base.ts
index 14c3649..d0e6224 100644
--- a/src/base.ts
+++ b/src/base.ts
@@ -31,16 +31,8 @@ import { readRepository } from "./options/readRepository.js";
 import { readRulesetId } from "./options/readRulesetId.js";
 import { readTitle } from "./options/readTitle.js";
 import { readUsage } from "./options/readUsage.js";
-
-const zContributor = z.object({
-	avatar_url: z.string(),
-	contributions: z.array(z.string()),
-	login: z.string(),
-	name: z.string(),
-	profile: z.string(),
-});
-
-export type Contributor = z.infer<typeof zContributor>;
+import { readWorkflowsVersions } from "./options/readWorkflowsVersions.js";
+import { zContributor, zWorkflowsVersions } from "./schemas.js";
 
 export const base = createBase({
 	options: {
@@ -166,6 +158,9 @@ export const base = createBase({
 			.string()
 			.optional()
 			.describe("package version to publish as and store in `package.json`"),
+		workflowsVersions: zWorkflowsVersions
+			.optional()
+			.describe("existing versions of GitHub Actions workflows used"),
 	},
 	prepare({ options, take }) {
 		const getAccess = lazyValue(async () => await readAccess(getPackageData));
@@ -278,6 +273,10 @@ export const base = createBase({
 
 		const getVersion = lazyValue(async () => (await getPackageData()).version);
 
+		const getWorkflowData = lazyValue(
+			async () => await readWorkflowsVersions(take),
+		);
+
 		return {
 			access: getAccess,
 			author: getAuthor,
@@ -301,6 +300,7 @@ export const base = createBase({
 			title: getTitle,
 			usage: getUsage,
 			version: getVersion,
+			workflowsVersions: getWorkflowData,
 		};
 	},
 });
diff --git a/src/blocks/actions/resolveUses.ts b/src/blocks/actions/resolveUses.ts
new file mode 100644
index 0000000..e4dcb72
--- /dev/null
+++ b/src/blocks/actions/resolveUses.ts
@@ -0,0 +1,41 @@
+import { CachedFactory } from "cached-factory";
+import semver from "semver";
+
+import { WorkflowsVersions } from "../../schemas.js";
+
+const semverCoercions = new CachedFactory((version: string) => {
+	return semver.coerce(version)?.toString() ?? "0.0.0";
+});
+
+export function resolveUses(
+	action: string,
+	version: string,
+	workflowsVersions?: WorkflowsVersions,
+) {
+	if (!workflowsVersions || !(action in workflowsVersions)) {
+		return `${action}@${version}`;
+	}
+
+	const workflowVersions = workflowsVersions[action];
+
+	const biggestVersion = Object.keys(workflowVersions).reduce(
+		(highestVersion, potentialVersion) =>
+			semver.gt(
+				semverCoercions.get(potentialVersion),
+				semverCoercions.get(highestVersion),
+			)
+				? potentialVersion
+				: highestVersion,
+		version,
+	);
+
+	if (!(biggestVersion in workflowVersions)) {
+		return `${action}@${biggestVersion}`;
+	}
+
+	const atBiggestVersion = workflowVersions[biggestVersion];
+
+	return atBiggestVersion.hash
+		? `${action}@${atBiggestVersion.hash} # ${biggestVersion}`
+		: `${action}@${biggestVersion}`;
+}
diff --git a/src/blocks/blockAllContributors.ts b/src/blocks/blockAllContributors.ts
index aca2812..1a2fb2b 100644
--- a/src/blocks/blockAllContributors.ts
+++ b/src/blocks/blockAllContributors.ts
@@ -1,7 +1,9 @@
 import _ from "lodash";
 
-import { base, Contributor } from "../base.js";
+import { base } from "../base.js";
 import { ownerContributions } from "../data/contributions.js";
+import { Contributor } from "../schemas.js";
+import { resolveUses } from "./actions/resolveUses.js";
 import { blockPrettier } from "./blockPrettier.js";
 import { blockREADME } from "./blockREADME.js";
 import { blockRepositorySecrets } from "./blockRepositorySecrets.js";
@@ -59,11 +61,22 @@ export const blockAllContributors = base.createBlock({
 								},
 							},
 							steps: [
-								{ uses: "actions/checkout@v4", with: { "fetch-depth": 0 } },
+								{
+									uses: resolveUses(
+										"actions/checkout",
+										"v4",
+										options.workflowsVersions,
+									),
+									with: { "fetch-depth": 0 },
+								},
 								{ uses: "./.github/actions/prepare" },
 								{
 									env: { GITHUB_TOKEN: "${{ secrets.ACCESS_TOKEN }}" },
-									uses: `JoshuaKGoldberg/all-contributors-auto-action@v0.5.0`,
+									uses: resolveUses(
+										"JoshuaKGoldberg/all-contributors-auto-action",
+										"v0.5.0",
+										options.workflowsVersions,
+									),
 								},
 							],
 						}),
diff --git a/src/blocks/blockCTATransitions.ts b/src/blocks/blockCTATransitions.ts
index e2a4d92..8c2fa7a 100644
--- a/src/blocks/blockCTATransitions.ts
+++ b/src/blocks/blockCTATransitions.ts
@@ -1,5 +1,6 @@
 import { base } from "../base.js";
 import { packageData } from "../data/packageData.js";
+import { resolveUses } from "./actions/resolveUses.js";
 import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js";
 import { blockPackageJson } from "./blockPackageJson.js";
 
@@ -7,7 +8,7 @@ export const blockCTATransitions = base.createBlock({
 	about: {
 		name: "CTA Transitions",
 	},
-	produce() {
+	produce({ options }) {
 		return {
 			addons: [
 				blockGitHubActionsCI({
@@ -25,7 +26,11 @@ export const blockCTATransitions = base.createBlock({
 							steps: [
 								{ run: "pnpx create-typescript-app" },
 								{
-									uses: "stefanzweifel/git-auto-commit-action@v5",
+									uses: resolveUses(
+										"stefanzweifel/git-auto-commit-action",
+										"v5",
+										options.workflowsVersions,
+									),
 									with: {
 										commit_author: "The Friendly Bingo Bot <bot@create.bingo>",
 										commit_message:
@@ -35,7 +40,11 @@ export const blockCTATransitions = base.createBlock({
 									},
 								},
 								{
-									uses: "mshick/add-pr-comment@v2",
+									uses: resolveUses(
+										"mshick/add-pr-comment",
+										"v2",
+										options.workflowsVersions,
+									),
 									with: {
 										issue: "${{ github.event.pull_request.number }}",
 										message: [
diff --git a/src/blocks/blockCodecov.ts b/src/blocks/blockCodecov.ts
index 602305b..1045dab 100644
--- a/src/blocks/blockCodecov.ts
+++ b/src/blocks/blockCodecov.ts
@@ -1,6 +1,7 @@
 import { z } from "zod";
 
 import { base } from "../base.js";
+import { resolveUses } from "./actions/resolveUses.js";
 import { blockGitHubApps } from "./blockGitHubApps.js";
 import { blockRemoveFiles } from "./blockRemoveFiles.js";
 import { blockVitest } from "./blockVitest.js";
@@ -10,7 +11,7 @@ export const blockCodecov = base.createBlock({
 		name: "Codecov",
 	},
 	addons: { env: z.record(z.string(), z.string()).optional() },
-	produce({ addons }) {
+	produce({ addons, options }) {
 		const { env } = addons;
 		return {
 			addons: [
@@ -27,7 +28,11 @@ export const blockCodecov = base.createBlock({
 						{
 							...(env && { env }),
 							if: "always()",
-							uses: "codecov/codecov-action@v3",
+							uses: resolveUses(
+								"codecov/codecov-action",
+								"v3",
+								options.workflowsVersions,
+							),
 						},
 					],
 				}),
diff --git a/src/blocks/blockGitHubActionsCI.ts b/src/blocks/blockGitHubActionsCI.ts
index 83106d0..c7bb3b7 100644
--- a/src/blocks/blockGitHubActionsCI.ts
+++ b/src/blocks/blockGitHubActionsCI.ts
@@ -2,10 +2,12 @@ import jsYaml from "js-yaml";
 import { z } from "zod";
 
 import { base } from "../base.js";
+import { resolveUses } from "./actions/resolveUses.js";
 import { blockRemoveFiles } from "./blockRemoveFiles.js";
 import { blockRepositoryBranchRuleset } from "./blockRepositoryBranchRuleset.js";
 import { createMultiWorkflowFile } from "./files/createMultiWorkflowFile.js";
 import { createSoloWorkflowFile } from "./files/createSoloWorkflowFile.js";
+import { removeUsesQuotes } from "./files/removeUsesQuotes.js";
 
 export const zActionStep = z.intersection(
 	z.object({
@@ -33,7 +35,7 @@ export const blockGitHubActionsCI = base.createBlock({
 			.optional(),
 		removedWorkflows: z.array(z.string()).optional(),
 	},
-	produce({ addons }) {
+	produce({ addons, options }) {
 		const { jobs } = addons;
 
 		return {
@@ -46,28 +48,38 @@ export const blockGitHubActionsCI = base.createBlock({
 				".github": {
 					actions: {
 						prepare: {
-							"action.yml": jsYaml
-								.dump({
-									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" },
-											},
-											{
-												run: "pnpm install --frozen-lockfile",
-												shell: "bash",
-											},
-										],
-										using: "composite",
-									},
-								})
-								.replaceAll(/\n(\S)/g, "\n\n$1"),
+							"action.yml": removeUsesQuotes(
+								jsYaml
+									.dump({
+										description: "Prepares the repo for a typical CI job",
+										name: "Prepare",
+										runs: {
+											steps: [
+												{
+													uses: resolveUses(
+														"pnpm/action-setup",
+														"v4",
+														options.workflowsVersions,
+													),
+												},
+												{
+													uses: resolveUses(
+														"actions/setup-node",
+														"v4",
+														options.workflowsVersions,
+													),
+													with: { cache: "pnpm", "node-version": "20" },
+												},
+												{
+													run: "pnpm install --frozen-lockfile",
+													shell: "bash",
+												},
+											],
+											using: "composite",
+										},
+									})
+									.replaceAll(/\n(\S)/g, "\n\n$1"),
+							),
 						},
 					},
 					workflows: {
@@ -91,7 +103,11 @@ export const blockGitHubActionsCI = base.createBlock({
 							},
 							steps: [
 								{
-									uses: "github/accessibility-alt-text-bot@v1.4.0",
+									uses: resolveUses(
+										"github/accessibility-alt-text-bot",
+										"v1.4.0",
+										options.workflowsVersions,
+									),
 								},
 							],
 						}),
@@ -100,6 +116,7 @@ export const blockGitHubActionsCI = base.createBlock({
 							createMultiWorkflowFile({
 								jobs: jobs.sort((a, b) => a.name.localeCompare(b.name)),
 								name: "CI",
+								workflowsVersions: options.workflowsVersions,
 							}),
 						"pr-review-requested.yml": createSoloWorkflowFile({
 							name: "PR Review Requested",
@@ -113,7 +130,11 @@ export const blockGitHubActionsCI = base.createBlock({
 							},
 							steps: [
 								{
-									uses: "actions-ecosystem/action-remove-labels@v1",
+									uses: resolveUses(
+										"actions-ecosystem/action-remove-labels",
+										"v1",
+										options.workflowsVersions,
+									),
 									with: {
 										labels: "status: waiting for author",
 									},
diff --git a/src/blocks/blockPRCompliance.ts b/src/blocks/blockPRCompliance.ts
index a671555..80a5b38 100644
--- a/src/blocks/blockPRCompliance.ts
+++ b/src/blocks/blockPRCompliance.ts
@@ -1,11 +1,12 @@
 import { base } from "../base.js";
+import { resolveUses } from "./actions/resolveUses.js";
 import { createSoloWorkflowFile } from "./files/createSoloWorkflowFile.js";
 
 export const blockPRCompliance = base.createBlock({
 	about: {
 		name: "PR Compliance",
 	},
-	produce() {
+	produce({ options }) {
 		return {
 			files: {
 				".github": {
@@ -23,7 +24,11 @@ export const blockPRCompliance = base.createBlock({
 							},
 							steps: [
 								{
-									uses: "mtfoley/pr-compliance-action@main",
+									uses: resolveUses(
+										"mtfoley/pr-compliance-action",
+										"main",
+										options.workflowsVersions,
+									),
 									with: {
 										"body-auto-close": false,
 										"ignore-authors": [
diff --git a/src/blocks/blockReleaseIt.ts b/src/blocks/blockReleaseIt.ts
index ffa34b1..43e0bc9 100644
--- a/src/blocks/blockReleaseIt.ts
+++ b/src/blocks/blockReleaseIt.ts
@@ -1,5 +1,6 @@
 import { base } from "../base.js";
 import { getPackageDependencies } from "../data/packageData.js";
+import { resolveUses } from "./actions/resolveUses.js";
 import { blockCSpell } from "./blockCSpell.js";
 import { blockPackageJson } from "./blockPackageJson.js";
 import { blockRemoveDependencies } from "./blockRemoveDependencies.js";
@@ -61,12 +62,23 @@ export const blockReleaseIt = base.createBlock({
 								"pull-requests": "write",
 							},
 							steps: [
-								{ uses: "actions/checkout@v4", with: { "fetch-depth": 0 } },
+								{
+									uses: resolveUses(
+										"actions/checkout",
+										"v4",
+										options.workflowsVersions,
+									),
+									with: { "fetch-depth": 0 },
+								},
 								{
 									run: `echo "npm_version=$(npm pkg get version | tr -d '"')" >> "$GITHUB_ENV"`,
 								},
 								{
-									uses: "apexskier/github-release-commenter@v1",
+									uses: resolveUses(
+										"apexskier/github-release-commenter",
+										"v1",
+										options.workflowsVersions,
+									),
 									with: {
 										"comment-template": `
 							:tada: This is included in version {release_link} :tada:
@@ -99,7 +111,11 @@ export const blockReleaseIt = base.createBlock({
 							},
 							steps: [
 								{
-									uses: "actions/checkout@v4",
+									uses: resolveUses(
+										"actions/checkout",
+										"v4",
+										options.workflowsVersions,
+									),
 									with: {
 										"fetch-depth": 0,
 										ref: "main",
@@ -117,7 +133,11 @@ export const blockReleaseIt = base.createBlock({
 										GITHUB_TOKEN: "${{ secrets.ACCESS_TOKEN }}",
 										NPM_TOKEN: "${{ secrets.NPM_TOKEN }}",
 									},
-									uses: "JoshuaKGoldberg/release-it-action@v0.2.2",
+									uses: resolveUses(
+										"JoshuaKGoldberg/release-it-action",
+										"v0.2.2",
+										options.workflowsVersions,
+									),
 								},
 							],
 						}),
diff --git a/src/blocks/files/createMultiWorkflowFile.ts b/src/blocks/files/createMultiWorkflowFile.ts
index 614a14e..e7e5dc2 100644
--- a/src/blocks/files/createMultiWorkflowFile.ts
+++ b/src/blocks/files/createMultiWorkflowFile.ts
@@ -1,9 +1,12 @@
+import { WorkflowsVersions } from "../../schemas.js";
+import { resolveUses } from "../actions/resolveUses.js";
 import { createJobName } from "./createJobName.js";
 import { formatWorkflowYaml } from "./formatWorkflowYaml.js";
 
 export interface MultiWorkflowFileOptions {
 	jobs: MultiWorkflowJobOptions[];
 	name: string;
+	workflowsVersions: undefined | WorkflowsVersions;
 }
 
 export interface MultiWorkflowJobOptions {
@@ -21,6 +24,7 @@ export type MultiWorkflowJobStep = { if?: string } & (
 export function createMultiWorkflowFile({
 	jobs,
 	name,
+	workflowsVersions,
 }: MultiWorkflowFileOptions) {
 	return formatWorkflowYaml({
 		jobs: Object.fromEntries(
@@ -31,7 +35,10 @@ export function createMultiWorkflowFile({
 					name: job.name,
 					"runs-on": "ubuntu-latest",
 					steps: [
-						{ uses: "actions/checkout@v4", with: job.checkoutWith },
+						{
+							uses: resolveUses("actions/checkout", "v4", workflowsVersions),
+							with: job.checkoutWith,
+						},
 						{ uses: "./.github/actions/prepare" },
 						...job.steps,
 					],
diff --git a/src/blocks/files/formatYaml.ts b/src/blocks/files/formatYaml.ts
index 8ad1ff5..21a7784 100644
--- a/src/blocks/files/formatYaml.ts
+++ b/src/blocks/files/formatYaml.ts
@@ -1,5 +1,7 @@
 import jsYaml from "js-yaml";
 
+import { removeUsesQuotes } from "./removeUsesQuotes.js";
+
 const options: jsYaml.DumpOptions = {
 	lineWidth: -1,
 	noCompatMode: true,
@@ -21,5 +23,5 @@ const options: jsYaml.DumpOptions = {
 };
 
 export function formatYaml(value: unknown) {
-	return jsYaml.dump(value, options);
+	return removeUsesQuotes(jsYaml.dump(value, options));
 }
diff --git a/src/blocks/files/removeUsesQuotes.ts b/src/blocks/files/removeUsesQuotes.ts
new file mode 100644
index 0000000..60b7137
--- /dev/null
+++ b/src/blocks/files/removeUsesQuotes.ts
@@ -0,0 +1,5 @@
+export function removeUsesQuotes(original: string) {
+	return original.replaceAll(/ uses: '.+'/gu, (line) =>
+		line.replaceAll("'", ""),
+	);
+}
diff --git a/src/inputs/inputFromDirectory.ts b/src/inputs/inputFromDirectory.ts
new file mode 100644
index 0000000..45e19ae
--- /dev/null
+++ b/src/inputs/inputFromDirectory.ts
@@ -0,0 +1,11 @@
+import { createInput } from "bingo";
+import { z } from "zod";
+
+export const inputFromDirectory = createInput({
+	args: {
+		directoryPath: z.string(),
+	},
+	async produce({ args, fs }) {
+		return await fs.readDirectory(args.directoryPath);
+	},
+});
diff --git a/src/options/readWorkflowsVersions.ts b/src/options/readWorkflowsVersions.ts
new file mode 100644
index 0000000..c0a916d
--- /dev/null
+++ b/src/options/readWorkflowsVersions.ts
@@ -0,0 +1,82 @@
+import { TakeInput } from "bingo";
+import { inputFromFile } from "input-from-file";
+
+import { inputFromDirectory } from "../inputs/inputFromDirectory.js";
+import { WorkflowsVersions } from "../schemas.js";
+
+export async function readWorkflowsVersions(
+	take: TakeInput,
+): Promise<WorkflowsVersions> {
+	const workflowsVersions: WorkflowsVersions = {};
+
+	// TODO: This would be more straightforward if bingo-fs provided globbing...
+	// If you want to increase test coverage here, please do that first :)
+	// https://github.com/JoshuaKGoldberg/bingo/issues/308
+
+	async function collectCompositeUses() {
+		const compositeNames = await take(inputFromDirectory, {
+			directoryPath: ".github/actions",
+		});
+
+		await Promise.all(
+			compositeNames.map(async (compositeName) => {
+				const compositeFileNames = await take(inputFromDirectory, {
+					directoryPath: `.github/actions/${compositeName}`,
+				});
+
+				for (const compositeFileName of compositeFileNames) {
+					await collectFile(
+						`.github/actions/${compositeName}/${compositeFileName}`,
+					);
+				}
+			}),
+		);
+	}
+
+	async function collectWorkflowUses() {
+		const workflowFileNames = await take(inputFromDirectory, {
+			directoryPath: ".github/workflows",
+		});
+
+		await Promise.all(
+			workflowFileNames.map(async (workflowFileName) => {
+				await collectFile(`.github/workflows/${workflowFileName}`);
+			}),
+		);
+	}
+
+	async function collectFile(filePath: string) {
+		const raw = await take(inputFromFile, { filePath });
+		if (raw instanceof Error) {
+			return;
+		}
+
+		for (const match of raw.matchAll(/uses:\s*(\w.+)/g)) {
+			const [, uses] = match;
+			collectUses(uses);
+		}
+	}
+
+	function collectUses(uses: string) {
+		const matched = /([^#@]+)@([^ #]+)(?: # ([a-z\d.]+))?/.exec(uses);
+		if (!matched) {
+			return;
+		}
+
+		const [, action, actual, commented] = matched;
+
+		workflowsVersions[action] ??= {};
+
+		if (commented) {
+			workflowsVersions[action][commented] ??= {};
+			workflowsVersions[action][commented].hash = actual;
+		} else {
+			workflowsVersions[action][actual] ??= {};
+			workflowsVersions[action][actual].pinned = true;
+		}
+	}
+
+	await Promise.all([collectCompositeUses(), collectWorkflowUses()]);
+
+	return workflowsVersions;
+}
diff --git a/src/schemas.ts b/src/schemas.ts
new file mode 100644
index 0000000..79da9ba
--- /dev/null
+++ b/src/schemas.ts
@@ -0,0 +1,26 @@
+import { z } from "zod";
+
+export const zContributor = z.object({
+	avatar_url: z.string(),
+	contributions: z.array(z.string()),
+	login: z.string(),
+	name: z.string(),
+	profile: z.string(),
+});
+
+export type Contributor = z.infer<typeof zContributor>;
+
+export const zWorkflowVersion = z.object({
+	hash: z.string().optional(),
+	pinned: z.boolean().optional(),
+});
+
+export type WorkflowVersion = z.infer<typeof zWorkflowVersion>;
+
+export const zWorkflowVersions = z.record(zWorkflowVersion);
+
+export type WorkflowVersions = z.infer<typeof zWorkflowVersions>;
+
+export const zWorkflowsVersions = z.record(zWorkflowVersions);
+
+export type WorkflowsVersions = z.infer<typeof zWorkflowsVersions>;


Test patch:
diff --git a/src/base.test.ts b/src/base.test.ts
index 420f2a1..c15fc17 100644
--- a/src/base.test.ts
+++ b/src/base.test.ts
@@ -53,6 +53,7 @@ describe("base", () => {
 			title: "Create TypeScript App",
 			usage: expect.any(String),
 			version: expect.any(String),
+			workflowsVersions: expect.any(Object),
 		});
 	});
 });
diff --git a/src/blocks/actions/resolveUses.test.ts b/src/blocks/actions/resolveUses.test.ts
new file mode 100644
index 0000000..78ce1a5
--- /dev/null
+++ b/src/blocks/actions/resolveUses.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveUses } from "./resolveUses.js";
+
+describe(resolveUses, () => {
+	it("returns action@version when workflowsVersions is undefined", () => {
+		const actual = resolveUses("test-action", "v1.2.3");
+
+		expect(actual).toBe("test-action@v1.2.3");
+	});
+
+	it("returns action@version when workflowsVersions does not contain the action", () => {
+		const actual = resolveUses("test-action", "v1.2.3", { other: {} });
+
+		expect(actual).toBe("test-action@v1.2.3");
+	});
+
+	it("uses the provided version when it is greater than all the action versions in workflowsVersions", () => {
+		const actual = resolveUses("test-action", "v1.2.3", {
+			"test-action": {
+				"v0.1.2": {
+					pinned: true,
+				},
+				"v1.1.4": {
+					pinned: true,
+				},
+			},
+		});
+
+		expect(actual).toBe("test-action@v1.2.3");
+	});
+
+	it("prefers a provided valid semver version when an action also has a non-semver tag", () => {
+		const actual = resolveUses("test-action", "v1.2.3", {
+			"test-action": {
+				main: {
+					pinned: true,
+				},
+			},
+		});
+
+		expect(actual).toBe("test-action@v1.2.3");
+	});
+
+	it("prefers an action's semver tag when the provided version is a non-semver tag", () => {
+		const actual = resolveUses("test-action", "main", {
+			"test-action": {
+				"v1.2.3": {
+					pinned: true,
+				},
+			},
+		});
+
+		expect(actual).toBe("test-action@v1.2.3");
+	});
+
+	it("uses the greatest version when the provided version is not bigger than all the action versions in workflowsVersions", () => {
+		const actual = resolveUses("test-action", "v1.2.3", {
+			"test-action": {
+				"v0.1.2": {
+					pinned: true,
+				},
+				"v1.3.5": {
+					pinned: true,
+				},
+			},
+		});
+
+		expect(actual).toBe("test-action@v1.3.5");
+	});
+
+	it("uses a pinned hash when the greatest version contains a hash", () => {
+		const actual = resolveUses("test-action", "v1.2.3", {
+			"test-action": {
+				"v0.1.2": {
+					pinned: true,
+				},
+				"v1.3.5": {
+					hash: "abc",
+					pinned: true,
+				},
+			},
+		});
+
+		expect(actual).toBe("test-action@abc # v1.3.5");
+	});
+});
diff --git a/src/blocks/files/removeUsesQuotes.test.ts b/src/blocks/files/removeUsesQuotes.test.ts
new file mode 100644
index 0000000..5de8af0
--- /dev/null
+++ b/src/blocks/files/removeUsesQuotes.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, test } from "vitest";
+
+import { removeUsesQuotes } from "./removeUsesQuotes.js";
+
+describe(removeUsesQuotes, () => {
+	test.each([
+		[""],
+		["run: pnpm run build"],
+		["- uses: actions/checkout@v4"],
+		["- uses: 'actions/checkout@v4'", "- uses: actions/checkout@v4"],
+		["- uses: actions/checkout@abc # v4"],
+		[
+			"- uses: 'actions/checkout@abc # v4'",
+			"- uses: actions/checkout@abc # v4",
+		],
+	])("%s", (input, expected = input) => {
+		expect(removeUsesQuotes(input)).toBe(expected);
+	});
+});