Skip to content

Commit 56b2826

Browse files
authoredMar 25, 2025··
fix: preserve GitHub Actions hashes and versions (#2007)
## 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 JoshuaKGoldberg/bingo#308. 🎁
1 parent aa860fd commit 56b2826

20 files changed

+422
-63
lines changed
 

‎docs/Configuration Files.md

+13-12
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@ This includes the options described in [CLI](./CLI.md).
1919
Some of create-typescript-app's options are rich objects, typically very long strings, or otherwise not reasonable on the CLI.
2020
These options are generally only programmatically used internally, but can still be specified in a configuration file:
2121

22-
| Option | Description | Default (If Available) |
23-
| ---------------- | ------------------------------------------------------------------------ | --------------------------------------------------------- |
24-
| `contributors` | AllContributors contributors to store in `.all-contributorsrc` | Existing contributors in the file, or just your username |
25-
| `documentation` | any additional docs to add to `.github/DEVELOPMENT.md` | Extra content in `.github/DEVELOPMENT.md` |
26-
| `existingLabels` | existing labels to switch to the standard template labels | Existing labels on the repository from the GitHub API |
27-
| `explainer` | additional `README.md` sentence(s) describing the package | Extra content in `README.md` after badges and description |
28-
| `guide` | link to a contribution guide to place at the top of development docs | Block quote on top of `.github/DEVELOPMENT.md` |
29-
| `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` |
30-
| `node` | Node.js engine version(s) to pin and require a minimum of | Values from `.nvmrc` and `package.json`'s `"engines"` |
31-
| `packageData` | additional properties to include in `package.json` | Existing values in `package.json` |
32-
| `rulesetId` | GitHub branch ruleset ID for main branch protections | Existing ruleset on the `main` branch from the GitHub API |
33-
| `usage` | Markdown docs to put in `README.md` under the `## Usage` heading | Existing usage lines in `README.md` |
22+
| Option | Description | Default (If Available) |
23+
| ------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------- |
24+
| `contributors` | AllContributors contributors to store in `.all-contributorsrc` | Existing contributors in the file, or just your username |
25+
| `documentation` | any additional docs to add to `.github/DEVELOPMENT.md` | Extra content in `.github/DEVELOPMENT.md` |
26+
| `existingLabels` | existing labels to switch to the standard template labels | Existing labels on the repository from the GitHub API |
27+
| `explainer` | additional `README.md` sentence(s) describing the package | Extra content in `README.md` after badges and description |
28+
| `guide` | link to a contribution guide to place at the top of development docs | Block quote on top of `.github/DEVELOPMENT.md` |
29+
| `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` |
30+
| `node` | Node.js engine version(s) to pin and require a minimum of | Values from `.nvmrc` and `package.json`'s `"engines"` |
31+
| `packageData` | additional properties to include in `package.json` | Existing values in `package.json` |
32+
| `rulesetId` | GitHub branch ruleset ID for main branch protections | Existing ruleset on the `main` branch from the GitHub API |
33+
| `usage` | Markdown docs to put in `README.md` under the `## Usage` heading | Existing usage lines in `README.md` |
34+
| `workflowsVersions` | existing versions of GitHub Actions workflows used | Existing action versions in `.github/workflows/*.yml` |
3435

3536
For example, changing `node` versions to values different from what would be inferred:
3637

‎package.json

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"bingo": "^0.5.8",
4141
"bingo-fs": "^0.5.4",
4242
"bingo-stratum": "^0.5.7",
43+
"cached-factory": "^0.1.0",
4344
"cspell-populate-words": "^0.3.0",
4445
"execa": "^9.5.2",
4546
"git-url-parse": "^16.0.1",

‎pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/base.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe("base", () => {
5353
title: "Create TypeScript App",
5454
usage: expect.any(String),
5555
version: expect.any(String),
56+
workflowsVersions: expect.any(Object),
5657
});
5758
});
5859
});

‎src/base.ts

+10-10
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,8 @@ import { readRepository } from "./options/readRepository.js";
3131
import { readRulesetId } from "./options/readRulesetId.js";
3232
import { readTitle } from "./options/readTitle.js";
3333
import { readUsage } from "./options/readUsage.js";
34-
35-
const zContributor = z.object({
36-
avatar_url: z.string(),
37-
contributions: z.array(z.string()),
38-
login: z.string(),
39-
name: z.string(),
40-
profile: z.string(),
41-
});
42-
43-
export type Contributor = z.infer<typeof zContributor>;
34+
import { readWorkflowsVersions } from "./options/readWorkflowsVersions.js";
35+
import { zContributor, zWorkflowsVersions } from "./schemas.js";
4436

4537
export const base = createBase({
4638
options: {
@@ -166,6 +158,9 @@ export const base = createBase({
166158
.string()
167159
.optional()
168160
.describe("package version to publish as and store in `package.json`"),
161+
workflowsVersions: zWorkflowsVersions
162+
.optional()
163+
.describe("existing versions of GitHub Actions workflows used"),
169164
},
170165
prepare({ options, take }) {
171166
const getAccess = lazyValue(async () => await readAccess(getPackageData));
@@ -278,6 +273,10 @@ export const base = createBase({
278273

279274
const getVersion = lazyValue(async () => (await getPackageData()).version);
280275

276+
const getWorkflowData = lazyValue(
277+
async () => await readWorkflowsVersions(take),
278+
);
279+
281280
return {
282281
access: getAccess,
283282
author: getAuthor,
@@ -301,6 +300,7 @@ export const base = createBase({
301300
title: getTitle,
302301
usage: getUsage,
303302
version: getVersion,
303+
workflowsVersions: getWorkflowData,
304304
};
305305
},
306306
});
+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { resolveUses } from "./resolveUses.js";
4+
5+
describe(resolveUses, () => {
6+
it("returns action@version when workflowsVersions is undefined", () => {
7+
const actual = resolveUses("test-action", "v1.2.3");
8+
9+
expect(actual).toBe("test-action@v1.2.3");
10+
});
11+
12+
it("returns action@version when workflowsVersions does not contain the action", () => {
13+
const actual = resolveUses("test-action", "v1.2.3", { other: {} });
14+
15+
expect(actual).toBe("test-action@v1.2.3");
16+
});
17+
18+
it("uses the provided version when it is greater than all the action versions in workflowsVersions", () => {
19+
const actual = resolveUses("test-action", "v1.2.3", {
20+
"test-action": {
21+
"v0.1.2": {
22+
pinned: true,
23+
},
24+
"v1.1.4": {
25+
pinned: true,
26+
},
27+
},
28+
});
29+
30+
expect(actual).toBe("test-action@v1.2.3");
31+
});
32+
33+
it("prefers a provided valid semver version when an action also has a non-semver tag", () => {
34+
const actual = resolveUses("test-action", "v1.2.3", {
35+
"test-action": {
36+
main: {
37+
pinned: true,
38+
},
39+
},
40+
});
41+
42+
expect(actual).toBe("test-action@v1.2.3");
43+
});
44+
45+
it("prefers an action's semver tag when the provided version is a non-semver tag", () => {
46+
const actual = resolveUses("test-action", "main", {
47+
"test-action": {
48+
"v1.2.3": {
49+
pinned: true,
50+
},
51+
},
52+
});
53+
54+
expect(actual).toBe("test-action@v1.2.3");
55+
});
56+
57+
it("uses the greatest version when the provided version is not bigger than all the action versions in workflowsVersions", () => {
58+
const actual = resolveUses("test-action", "v1.2.3", {
59+
"test-action": {
60+
"v0.1.2": {
61+
pinned: true,
62+
},
63+
"v1.3.5": {
64+
pinned: true,
65+
},
66+
},
67+
});
68+
69+
expect(actual).toBe("test-action@v1.3.5");
70+
});
71+
72+
it("uses a pinned hash when the greatest version contains a hash", () => {
73+
const actual = resolveUses("test-action", "v1.2.3", {
74+
"test-action": {
75+
"v0.1.2": {
76+
pinned: true,
77+
},
78+
"v1.3.5": {
79+
hash: "abc",
80+
pinned: true,
81+
},
82+
},
83+
});
84+
85+
expect(actual).toBe("test-action@abc # v1.3.5");
86+
});
87+
});

‎src/blocks/actions/resolveUses.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { CachedFactory } from "cached-factory";
2+
import semver from "semver";
3+
4+
import { WorkflowsVersions } from "../../schemas.js";
5+
6+
const semverCoercions = new CachedFactory((version: string) => {
7+
return semver.coerce(version)?.toString() ?? "0.0.0";
8+
});
9+
10+
export function resolveUses(
11+
action: string,
12+
version: string,
13+
workflowsVersions?: WorkflowsVersions,
14+
) {
15+
if (!workflowsVersions || !(action in workflowsVersions)) {
16+
return `${action}@${version}`;
17+
}
18+
19+
const workflowVersions = workflowsVersions[action];
20+
21+
const biggestVersion = Object.keys(workflowVersions).reduce(
22+
(highestVersion, potentialVersion) =>
23+
semver.gt(
24+
semverCoercions.get(potentialVersion),
25+
semverCoercions.get(highestVersion),
26+
)
27+
? potentialVersion
28+
: highestVersion,
29+
version,
30+
);
31+
32+
if (!(biggestVersion in workflowVersions)) {
33+
return `${action}@${biggestVersion}`;
34+
}
35+
36+
const atBiggestVersion = workflowVersions[biggestVersion];
37+
38+
return atBiggestVersion.hash
39+
? `${action}@${atBiggestVersion.hash} # ${biggestVersion}`
40+
: `${action}@${biggestVersion}`;
41+
}

‎src/blocks/blockAllContributors.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import _ from "lodash";
22

3-
import { base, Contributor } from "../base.js";
3+
import { base } from "../base.js";
44
import { ownerContributions } from "../data/contributions.js";
5+
import { Contributor } from "../schemas.js";
6+
import { resolveUses } from "./actions/resolveUses.js";
57
import { blockPrettier } from "./blockPrettier.js";
68
import { blockREADME } from "./blockREADME.js";
79
import { blockRepositorySecrets } from "./blockRepositorySecrets.js";
@@ -59,11 +61,22 @@ export const blockAllContributors = base.createBlock({
5961
},
6062
},
6163
steps: [
62-
{ uses: "actions/checkout@v4", with: { "fetch-depth": 0 } },
64+
{
65+
uses: resolveUses(
66+
"actions/checkout",
67+
"v4",
68+
options.workflowsVersions,
69+
),
70+
with: { "fetch-depth": 0 },
71+
},
6372
{ uses: "./.github/actions/prepare" },
6473
{
6574
env: { GITHUB_TOKEN: "${{ secrets.ACCESS_TOKEN }}" },
66-
uses: `JoshuaKGoldberg/all-contributors-auto-action@v0.5.0`,
75+
uses: resolveUses(
76+
"JoshuaKGoldberg/all-contributors-auto-action",
77+
"v0.5.0",
78+
options.workflowsVersions,
79+
),
6780
},
6881
],
6982
}),

‎src/blocks/blockCTATransitions.ts

+12-3
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { base } from "../base.js";
22
import { packageData } from "../data/packageData.js";
3+
import { resolveUses } from "./actions/resolveUses.js";
34
import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js";
45
import { blockPackageJson } from "./blockPackageJson.js";
56

67
export const blockCTATransitions = base.createBlock({
78
about: {
89
name: "CTA Transitions",
910
},
10-
produce() {
11+
produce({ options }) {
1112
return {
1213
addons: [
1314
blockGitHubActionsCI({
@@ -25,7 +26,11 @@ export const blockCTATransitions = base.createBlock({
2526
steps: [
2627
{ run: "pnpx create-typescript-app" },
2728
{
28-
uses: "stefanzweifel/git-auto-commit-action@v5",
29+
uses: resolveUses(
30+
"stefanzweifel/git-auto-commit-action",
31+
"v5",
32+
options.workflowsVersions,
33+
),
2934
with: {
3035
commit_author: "The Friendly Bingo Bot <bot@create.bingo>",
3136
commit_message:
@@ -35,7 +40,11 @@ export const blockCTATransitions = base.createBlock({
3540
},
3641
},
3742
{
38-
uses: "mshick/add-pr-comment@v2",
43+
uses: resolveUses(
44+
"mshick/add-pr-comment",
45+
"v2",
46+
options.workflowsVersions,
47+
),
3948
with: {
4049
issue: "${{ github.event.pull_request.number }}",
4150
message: [

‎src/blocks/blockCodecov.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from "zod";
22

33
import { base } from "../base.js";
4+
import { resolveUses } from "./actions/resolveUses.js";
45
import { blockGitHubApps } from "./blockGitHubApps.js";
56
import { blockRemoveFiles } from "./blockRemoveFiles.js";
67
import { blockVitest } from "./blockVitest.js";
@@ -10,7 +11,7 @@ export const blockCodecov = base.createBlock({
1011
name: "Codecov",
1112
},
1213
addons: { env: z.record(z.string(), z.string()).optional() },
13-
produce({ addons }) {
14+
produce({ addons, options }) {
1415
const { env } = addons;
1516
return {
1617
addons: [
@@ -27,7 +28,11 @@ export const blockCodecov = base.createBlock({
2728
{
2829
...(env && { env }),
2930
if: "always()",
30-
uses: "codecov/codecov-action@v3",
31+
uses: resolveUses(
32+
"codecov/codecov-action",
33+
"v3",
34+
options.workflowsVersions,
35+
),
3136
},
3237
],
3338
}),

‎src/blocks/blockGitHubActionsCI.ts

+46-25
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import jsYaml from "js-yaml";
22
import { z } from "zod";
33

44
import { base } from "../base.js";
5+
import { resolveUses } from "./actions/resolveUses.js";
56
import { blockRemoveFiles } from "./blockRemoveFiles.js";
67
import { blockRepositoryBranchRuleset } from "./blockRepositoryBranchRuleset.js";
78
import { createMultiWorkflowFile } from "./files/createMultiWorkflowFile.js";
89
import { createSoloWorkflowFile } from "./files/createSoloWorkflowFile.js";
10+
import { removeUsesQuotes } from "./files/removeUsesQuotes.js";
911

1012
export const zActionStep = z.intersection(
1113
z.object({
@@ -33,7 +35,7 @@ export const blockGitHubActionsCI = base.createBlock({
3335
.optional(),
3436
removedWorkflows: z.array(z.string()).optional(),
3537
},
36-
produce({ addons }) {
38+
produce({ addons, options }) {
3739
const { jobs } = addons;
3840

3941
return {
@@ -46,28 +48,38 @@ export const blockGitHubActionsCI = base.createBlock({
4648
".github": {
4749
actions: {
4850
prepare: {
49-
"action.yml": jsYaml
50-
.dump({
51-
description: "Prepares the repo for a typical CI job",
52-
name: "Prepare",
53-
runs: {
54-
steps: [
55-
{
56-
uses: "pnpm/action-setup@v4",
57-
},
58-
{
59-
uses: "actions/setup-node@v4",
60-
with: { cache: "pnpm", "node-version": "20" },
61-
},
62-
{
63-
run: "pnpm install --frozen-lockfile",
64-
shell: "bash",
65-
},
66-
],
67-
using: "composite",
68-
},
69-
})
70-
.replaceAll(/\n(\S)/g, "\n\n$1"),
51+
"action.yml": removeUsesQuotes(
52+
jsYaml
53+
.dump({
54+
description: "Prepares the repo for a typical CI job",
55+
name: "Prepare",
56+
runs: {
57+
steps: [
58+
{
59+
uses: resolveUses(
60+
"pnpm/action-setup",
61+
"v4",
62+
options.workflowsVersions,
63+
),
64+
},
65+
{
66+
uses: resolveUses(
67+
"actions/setup-node",
68+
"v4",
69+
options.workflowsVersions,
70+
),
71+
with: { cache: "pnpm", "node-version": "20" },
72+
},
73+
{
74+
run: "pnpm install --frozen-lockfile",
75+
shell: "bash",
76+
},
77+
],
78+
using: "composite",
79+
},
80+
})
81+
.replaceAll(/\n(\S)/g, "\n\n$1"),
82+
),
7183
},
7284
},
7385
workflows: {
@@ -91,7 +103,11 @@ export const blockGitHubActionsCI = base.createBlock({
91103
},
92104
steps: [
93105
{
94-
uses: "github/accessibility-alt-text-bot@v1.4.0",
106+
uses: resolveUses(
107+
"github/accessibility-alt-text-bot",
108+
"v1.4.0",
109+
options.workflowsVersions,
110+
),
95111
},
96112
],
97113
}),
@@ -100,6 +116,7 @@ export const blockGitHubActionsCI = base.createBlock({
100116
createMultiWorkflowFile({
101117
jobs: jobs.sort((a, b) => a.name.localeCompare(b.name)),
102118
name: "CI",
119+
workflowsVersions: options.workflowsVersions,
103120
}),
104121
"pr-review-requested.yml": createSoloWorkflowFile({
105122
name: "PR Review Requested",
@@ -113,7 +130,11 @@ export const blockGitHubActionsCI = base.createBlock({
113130
},
114131
steps: [
115132
{
116-
uses: "actions-ecosystem/action-remove-labels@v1",
133+
uses: resolveUses(
134+
"actions-ecosystem/action-remove-labels",
135+
"v1",
136+
options.workflowsVersions,
137+
),
117138
with: {
118139
labels: "status: waiting for author",
119140
},

‎src/blocks/blockPRCompliance.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { base } from "../base.js";
2+
import { resolveUses } from "./actions/resolveUses.js";
23
import { createSoloWorkflowFile } from "./files/createSoloWorkflowFile.js";
34

45
export const blockPRCompliance = base.createBlock({
56
about: {
67
name: "PR Compliance",
78
},
8-
produce() {
9+
produce({ options }) {
910
return {
1011
files: {
1112
".github": {
@@ -23,7 +24,11 @@ export const blockPRCompliance = base.createBlock({
2324
},
2425
steps: [
2526
{
26-
uses: "mtfoley/pr-compliance-action@main",
27+
uses: resolveUses(
28+
"mtfoley/pr-compliance-action",
29+
"main",
30+
options.workflowsVersions,
31+
),
2732
with: {
2833
"body-auto-close": false,
2934
"ignore-authors": [

‎src/blocks/blockReleaseIt.ts

+24-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { base } from "../base.js";
22
import { getPackageDependencies } from "../data/packageData.js";
3+
import { resolveUses } from "./actions/resolveUses.js";
34
import { blockCSpell } from "./blockCSpell.js";
45
import { blockPackageJson } from "./blockPackageJson.js";
56
import { blockRemoveDependencies } from "./blockRemoveDependencies.js";
@@ -61,12 +62,23 @@ export const blockReleaseIt = base.createBlock({
6162
"pull-requests": "write",
6263
},
6364
steps: [
64-
{ uses: "actions/checkout@v4", with: { "fetch-depth": 0 } },
65+
{
66+
uses: resolveUses(
67+
"actions/checkout",
68+
"v4",
69+
options.workflowsVersions,
70+
),
71+
with: { "fetch-depth": 0 },
72+
},
6573
{
6674
run: `echo "npm_version=$(npm pkg get version | tr -d '"')" >> "$GITHUB_ENV"`,
6775
},
6876
{
69-
uses: "apexskier/github-release-commenter@v1",
77+
uses: resolveUses(
78+
"apexskier/github-release-commenter",
79+
"v1",
80+
options.workflowsVersions,
81+
),
7082
with: {
7183
"comment-template": `
7284
:tada: This is included in version {release_link} :tada:
@@ -99,7 +111,11 @@ export const blockReleaseIt = base.createBlock({
99111
},
100112
steps: [
101113
{
102-
uses: "actions/checkout@v4",
114+
uses: resolveUses(
115+
"actions/checkout",
116+
"v4",
117+
options.workflowsVersions,
118+
),
103119
with: {
104120
"fetch-depth": 0,
105121
ref: "main",
@@ -117,7 +133,11 @@ export const blockReleaseIt = base.createBlock({
117133
GITHUB_TOKEN: "${{ secrets.ACCESS_TOKEN }}",
118134
NPM_TOKEN: "${{ secrets.NPM_TOKEN }}",
119135
},
120-
uses: "JoshuaKGoldberg/release-it-action@v0.2.2",
136+
uses: resolveUses(
137+
"JoshuaKGoldberg/release-it-action",
138+
"v0.2.2",
139+
options.workflowsVersions,
140+
),
121141
},
122142
],
123143
}),

‎src/blocks/files/createMultiWorkflowFile.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import { WorkflowsVersions } from "../../schemas.js";
2+
import { resolveUses } from "../actions/resolveUses.js";
13
import { createJobName } from "./createJobName.js";
24
import { formatWorkflowYaml } from "./formatWorkflowYaml.js";
35

46
export interface MultiWorkflowFileOptions {
57
jobs: MultiWorkflowJobOptions[];
68
name: string;
9+
workflowsVersions: undefined | WorkflowsVersions;
710
}
811

912
export interface MultiWorkflowJobOptions {
@@ -21,6 +24,7 @@ export type MultiWorkflowJobStep = { if?: string } & (
2124
export function createMultiWorkflowFile({
2225
jobs,
2326
name,
27+
workflowsVersions,
2428
}: MultiWorkflowFileOptions) {
2529
return formatWorkflowYaml({
2630
jobs: Object.fromEntries(
@@ -31,7 +35,10 @@ export function createMultiWorkflowFile({
3135
name: job.name,
3236
"runs-on": "ubuntu-latest",
3337
steps: [
34-
{ uses: "actions/checkout@v4", with: job.checkoutWith },
38+
{
39+
uses: resolveUses("actions/checkout", "v4", workflowsVersions),
40+
with: job.checkoutWith,
41+
},
3542
{ uses: "./.github/actions/prepare" },
3643
...job.steps,
3744
],

‎src/blocks/files/formatYaml.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import jsYaml from "js-yaml";
22

3+
import { removeUsesQuotes } from "./removeUsesQuotes.js";
4+
35
const options: jsYaml.DumpOptions = {
46
lineWidth: -1,
57
noCompatMode: true,
@@ -21,5 +23,5 @@ const options: jsYaml.DumpOptions = {
2123
};
2224

2325
export function formatYaml(value: unknown) {
24-
return jsYaml.dump(value, options);
26+
return removeUsesQuotes(jsYaml.dump(value, options));
2527
}
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, test } from "vitest";
2+
3+
import { removeUsesQuotes } from "./removeUsesQuotes.js";
4+
5+
describe(removeUsesQuotes, () => {
6+
test.each([
7+
[""],
8+
["run: pnpm run build"],
9+
["- uses: actions/checkout@v4"],
10+
["- uses: 'actions/checkout@v4'", "- uses: actions/checkout@v4"],
11+
["- uses: actions/checkout@abc # v4"],
12+
[
13+
"- uses: 'actions/checkout@abc # v4'",
14+
"- uses: actions/checkout@abc # v4",
15+
],
16+
])("%s", (input, expected = input) => {
17+
expect(removeUsesQuotes(input)).toBe(expected);
18+
});
19+
});

‎src/blocks/files/removeUsesQuotes.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function removeUsesQuotes(original: string) {
2+
return original.replaceAll(/ uses: '.+'/gu, (line) =>
3+
line.replaceAll("'", ""),
4+
);
5+
}

‎src/inputs/inputFromDirectory.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createInput } from "bingo";
2+
import { z } from "zod";
3+
4+
export const inputFromDirectory = createInput({
5+
args: {
6+
directoryPath: z.string(),
7+
},
8+
async produce({ args, fs }) {
9+
return await fs.readDirectory(args.directoryPath);
10+
},
11+
});

‎src/options/readWorkflowsVersions.ts

+82
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { TakeInput } from "bingo";
2+
import { inputFromFile } from "input-from-file";
3+
4+
import { inputFromDirectory } from "../inputs/inputFromDirectory.js";
5+
import { WorkflowsVersions } from "../schemas.js";
6+
7+
export async function readWorkflowsVersions(
8+
take: TakeInput,
9+
): Promise<WorkflowsVersions> {
10+
const workflowsVersions: WorkflowsVersions = {};
11+
12+
// TODO: This would be more straightforward if bingo-fs provided globbing...
13+
// If you want to increase test coverage here, please do that first :)
14+
// https://github.com/JoshuaKGoldberg/bingo/issues/308
15+
16+
async function collectCompositeUses() {
17+
const compositeNames = await take(inputFromDirectory, {
18+
directoryPath: ".github/actions",
19+
});
20+
21+
await Promise.all(
22+
compositeNames.map(async (compositeName) => {
23+
const compositeFileNames = await take(inputFromDirectory, {
24+
directoryPath: `.github/actions/${compositeName}`,
25+
});
26+
27+
for (const compositeFileName of compositeFileNames) {
28+
await collectFile(
29+
`.github/actions/${compositeName}/${compositeFileName}`,
30+
);
31+
}
32+
}),
33+
);
34+
}
35+
36+
async function collectWorkflowUses() {
37+
const workflowFileNames = await take(inputFromDirectory, {
38+
directoryPath: ".github/workflows",
39+
});
40+
41+
await Promise.all(
42+
workflowFileNames.map(async (workflowFileName) => {
43+
await collectFile(`.github/workflows/${workflowFileName}`);
44+
}),
45+
);
46+
}
47+
48+
async function collectFile(filePath: string) {
49+
const raw = await take(inputFromFile, { filePath });
50+
if (raw instanceof Error) {
51+
return;
52+
}
53+
54+
for (const match of raw.matchAll(/uses:\s*(\w.+)/g)) {
55+
const [, uses] = match;
56+
collectUses(uses);
57+
}
58+
}
59+
60+
function collectUses(uses: string) {
61+
const matched = /([^#@]+)@([^ #]+)(?: # ([a-z\d.]+))?/.exec(uses);
62+
if (!matched) {
63+
return;
64+
}
65+
66+
const [, action, actual, commented] = matched;
67+
68+
workflowsVersions[action] ??= {};
69+
70+
if (commented) {
71+
workflowsVersions[action][commented] ??= {};
72+
workflowsVersions[action][commented].hash = actual;
73+
} else {
74+
workflowsVersions[action][actual] ??= {};
75+
workflowsVersions[action][actual].pinned = true;
76+
}
77+
}
78+
79+
await Promise.all([collectCompositeUses(), collectWorkflowUses()]);
80+
81+
return workflowsVersions;
82+
}

‎src/schemas.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { z } from "zod";
2+
3+
export const zContributor = z.object({
4+
avatar_url: z.string(),
5+
contributions: z.array(z.string()),
6+
login: z.string(),
7+
name: z.string(),
8+
profile: z.string(),
9+
});
10+
11+
export type Contributor = z.infer<typeof zContributor>;
12+
13+
export const zWorkflowVersion = z.object({
14+
hash: z.string().optional(),
15+
pinned: z.boolean().optional(),
16+
});
17+
18+
export type WorkflowVersion = z.infer<typeof zWorkflowVersion>;
19+
20+
export const zWorkflowVersions = z.record(zWorkflowVersion);
21+
22+
export type WorkflowVersions = z.infer<typeof zWorkflowVersions>;
23+
24+
export const zWorkflowsVersions = z.record(zWorkflowVersions);
25+
26+
export type WorkflowsVersions = z.infer<typeof zWorkflowsVersions>;

0 commit comments

Comments
 (0)
Please sign in to comment.