Skip to content

Commit f4b6bd9

Browse files
committed
Parse/render YAML documents for sync_back.ts
1 parent d1a6527 commit f4b6bd9

File tree

2 files changed

+143
-67
lines changed

2 files changed

+143
-67
lines changed

pr-checks/sync_back.test.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,18 @@ jobs:
5656

5757
const result = scanGeneratedWorkflows(workflowDir);
5858

59-
assert.equal(result["actions/checkout"], "v4");
60-
assert.equal(result["actions/setup-node"], "v5");
61-
assert.equal(result["actions/setup-go"], "v6");
59+
assert.deepEqual(result["actions/checkout"], {
60+
version: "v4",
61+
comment: undefined,
62+
});
63+
assert.deepEqual(result["actions/setup-node"], {
64+
version: "v5",
65+
comment: undefined,
66+
});
67+
assert.deepEqual(result["actions/setup-go"], {
68+
version: "v6",
69+
comment: undefined,
70+
});
6271
});
6372

6473
it("scanning workflows with version comments", () => {
@@ -78,12 +87,18 @@ jobs:
7887

7988
const result = scanGeneratedWorkflows(workflowDir);
8089

81-
assert.equal(result["actions/checkout"], "v4");
82-
assert.equal(
83-
result["ruby/setup-ruby"],
84-
"44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0",
85-
);
86-
assert.equal(result["actions/setup-python"], "v6 # Latest Python");
90+
assert.deepEqual(result["actions/checkout"], {
91+
version: "v4",
92+
comment: undefined,
93+
});
94+
assert.deepEqual(result["ruby/setup-ruby"], {
95+
version: "44511735964dcb71245e7e55f72539531f7bc0eb",
96+
comment: " v1.257.0",
97+
});
98+
assert.deepEqual(result["actions/setup-python"], {
99+
version: "v6",
100+
comment: " Latest Python",
101+
});
87102
});
88103

89104
it("ignores local actions", () => {
@@ -103,7 +118,10 @@ jobs:
103118

104119
const result = scanGeneratedWorkflows(workflowDir);
105120

106-
assert.equal(result["actions/checkout"], "v4");
121+
assert.deepEqual(result["actions/checkout"], {
122+
version: "v4",
123+
comment: undefined,
124+
});
107125
assert.equal("./.github/actions/local-action" in result, false);
108126
assert.equal("./another-local-action" in result, false);
109127
});
@@ -128,8 +146,8 @@ const steps = [
128146
fs.writeFileSync(syncTsPath, syncTsContent);
129147

130148
const actionVersions = {
131-
"actions/setup-node": "v5",
132-
"actions/setup-go": "v6",
149+
"actions/setup-node": { version: "v5" },
150+
"actions/setup-go": { version: "v6" },
133151
};
134152

135153
const result = updateSyncTs(syncTsPath, actionVersions);
@@ -155,7 +173,7 @@ const steps = [
155173
fs.writeFileSync(syncTsPath, syncTsContent);
156174

157175
const actionVersions = {
158-
"actions/setup-node": "v5 # Latest version",
176+
"actions/setup-node": { version: "v5", comment: " Latest version" },
159177
};
160178

161179
const result = updateSyncTs(syncTsPath, actionVersions);
@@ -182,7 +200,7 @@ const steps = [
182200
fs.writeFileSync(syncTsPath, syncTsContent);
183201

184202
const actionVersions = {
185-
"actions/setup-node": "v5",
203+
"actions/setup-node": { version: "v5" },
186204
};
187205

188206
const result = updateSyncTs(syncTsPath, actionVersions);
@@ -206,8 +224,8 @@ steps:
206224
fs.writeFileSync(templatePath, templateContent);
207225

208226
const actionVersions = {
209-
"actions/checkout": "v4",
210-
"actions/setup-node": "v5 # Latest",
227+
"actions/checkout": { version: "v4" },
228+
"actions/setup-node": { version: "v5", comment: " Latest" },
211229
};
212230

213231
const result = updateTemplateFiles(checksDir, actionVersions);
@@ -232,8 +250,10 @@ steps:
232250
fs.writeFileSync(templatePath, templateContent);
233251

234252
const actionVersions = {
235-
"ruby/setup-ruby":
236-
"55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0",
253+
"ruby/setup-ruby": {
254+
version: "55511735964dcb71245e7e55f72539531f7bc0eb",
255+
comment: " v1.257.0",
256+
},
237257
};
238258

239259
const result = updateTemplateFiles(checksDir, actionVersions);

pr-checks/sync_back.ts

Lines changed: 105 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#!/usr/bin/env npx tsx
22

3+
import * as yaml from "yaml";
4+
35
/*
46
Sync-back script to automatically update action versions in source templates
57
from the generated workflow files after Dependabot updates.
@@ -27,14 +29,74 @@ const CHECKS_DIR = path.join(THIS_DIR, "checks");
2729
const WORKFLOW_DIR = path.join(THIS_DIR, "..", ".github", "workflows");
2830
const SYNC_TS_PATH = path.join(THIS_DIR, "sync.ts");
2931

32+
/** Records information about the version of an Action with an optional comment. */
33+
type ActionVersion = { version: string; comment?: string };
34+
35+
/** Converts `info` to a string that includes the version and comment. */
36+
function versionWithCommentStr(info: ActionVersion): string {
37+
const comment = info.comment ? ` #${info.comment}` : "";
38+
return `${info.version}${comment}`;
39+
}
40+
41+
/**
42+
* Constructs a `yaml.visitor` which calls `fn` for `yaml.Pair` nodes where the key is "uses" and
43+
* the value is a `yaml.Scalar`.
44+
*/
45+
function usesVisitor(
46+
fn: (
47+
pair: yaml.Pair<yaml.Scalar, yaml.Scalar>,
48+
actionName: string,
49+
actionVersion: ActionVersion,
50+
) => void,
51+
): yaml.visitor {
52+
return {
53+
Pair(_, pair) {
54+
if (
55+
yaml.isScalar(pair.key) &&
56+
yaml.isScalar(pair.value) &&
57+
pair.key.value === "uses" &&
58+
typeof pair.value.value === "string"
59+
) {
60+
const usesValue = pair.value.value;
61+
62+
// Only track non-local actions (those with / but not starting with ./)
63+
if (!usesValue.startsWith("./")) {
64+
const parts = (pair.value.value as string).split("@");
65+
66+
if (parts.length !== 2) {
67+
throw new Error(`Unexpected 'uses' value: ${usesValue}`);
68+
}
69+
70+
const actionName = parts[0];
71+
const actionVersion = parts[1].trimEnd();
72+
const comment = pair.value.comment?.trimEnd();
73+
74+
fn(pair as yaml.Pair<yaml.Scalar, yaml.Scalar>, actionName, {
75+
version: actionVersion,
76+
comment,
77+
});
78+
}
79+
80+
// Do not visit the children of this node.
81+
return yaml.visit.SKIP;
82+
}
83+
84+
// Do nothing and continue.
85+
return undefined;
86+
},
87+
};
88+
}
89+
3090
/**
3191
* Scan generated workflow files to extract the latest action versions.
3292
*
3393
* @param workflowDir - Path to .github/workflows directory
3494
* @returns Map from action names to their latest versions (including comments)
3595
*/
36-
export function scanGeneratedWorkflows(workflowDir: string): Record<string, string> {
37-
const actionVersions: Record<string, string> = {};
96+
export function scanGeneratedWorkflows(
97+
workflowDir: string,
98+
): Record<string, ActionVersion> {
99+
const actionVersions: Record<string, ActionVersion> = {};
38100

39101
const generatedFiles = fs
40102
.readdirSync(workflowDir)
@@ -43,22 +105,15 @@ export function scanGeneratedWorkflows(workflowDir: string): Record<string, stri
43105

44106
for (const filePath of generatedFiles) {
45107
const content = fs.readFileSync(filePath, "utf8");
108+
const doc = yaml.parseDocument(content);
46109

47-
// Find all action uses in the file, including potential comments
48-
// This pattern captures: action_name@version_with_possible_comment
49-
const pattern = /uses:\s+([^/\s]+\/[^@\s]+)@([^@\n]+)/g;
50-
let match: RegExpExecArray | null;
51-
52-
while ((match = pattern.exec(content)) !== null) {
53-
const actionName = match[1];
54-
const versionWithComment = match[2].trimEnd();
55-
56-
// Only track non-local actions (those with / but not starting with ./)
57-
if (!actionName.startsWith("./")) {
110+
yaml.visit(
111+
doc,
112+
usesVisitor((_node, actionName, actionVersion) => {
58113
// Assume that version numbers are consistent (this should be the case on a Dependabot update PR)
59-
actionVersions[actionName] = versionWithComment;
60-
}
61-
}
114+
actionVersions[actionName] = actionVersion;
115+
}),
116+
);
62117
}
63118

64119
return actionVersions;
@@ -73,7 +128,7 @@ export function scanGeneratedWorkflows(workflowDir: string): Record<string, stri
73128
*/
74129
export function updateSyncTs(
75130
syncTsPath: string,
76-
actionVersions: Record<string, string>,
131+
actionVersions: Record<string, ActionVersion>,
77132
): boolean {
78133
if (!fs.existsSync(syncTsPath)) {
79134
throw new Error(`Could not find ${syncTsPath}`);
@@ -83,24 +138,16 @@ export function updateSyncTs(
83138
const originalContent = content;
84139

85140
// Update hardcoded action versions
86-
for (const [actionName, versionWithComment] of Object.entries(
87-
actionVersions,
88-
)) {
89-
// Extract just the version part (before any comment) for sync.ts
90-
const version = versionWithComment.includes("#")
91-
? versionWithComment.split("#")[0].trim()
92-
: versionWithComment.trim();
93-
94-
// Look for patterns like uses: "actions/setup-node@v4"
141+
for (const [actionName, versionInfo] of Object.entries(actionVersions)) {
95142
// Note that this will break if we store an Action uses reference in a
96143
// variable - that's a risk we're happy to take since in that case the
97144
// PR checks will just fail.
98145
const escaped = actionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99-
const pattern = new RegExp(
100-
`(uses:\\s*")${escaped}@(?:[^"]+)(")`,
101-
"g",
146+
const pattern = new RegExp(`(uses:\\s*")${escaped}@(?:[^"]+)(")`, "g");
147+
content = content.replace(
148+
pattern,
149+
`$1${actionName}@${versionInfo.version}$2`,
102150
);
103-
content = content.replace(pattern, `$1${actionName}@${version}$2`);
104151
}
105152

106153
if (content !== originalContent) {
@@ -122,7 +169,7 @@ export function updateSyncTs(
122169
*/
123170
export function updateTemplateFiles(
124171
checksDir: string,
125-
actionVersions: Record<string, string>,
172+
actionVersions: Record<string, ActionVersion>,
126173
): string[] {
127174
const modifiedFiles: string[] = [];
128175

@@ -132,24 +179,33 @@ export function updateTemplateFiles(
132179
.map((f) => path.join(checksDir, f));
133180

134181
for (const filePath of templateFiles) {
135-
let content = fs.readFileSync(filePath, "utf8");
136-
const originalContent = content;
137-
138-
// Update action versions
139-
for (const [actionName, versionWithComment] of Object.entries(
140-
actionVersions,
141-
)) {
142-
// Look for patterns like 'uses: actions/setup-node@v4' or 'uses: actions/setup-node@sha # comment'
143-
const escaped = actionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
144-
const pattern = new RegExp(
145-
`(uses:\\s+${escaped})@(?:[^@\n]+)`,
146-
"g",
147-
);
148-
content = content.replace(pattern, `$1@${versionWithComment}`);
149-
}
182+
const content = fs.readFileSync(filePath, "utf8");
183+
const doc = yaml.parseDocument(content, { keepSourceTokens: true });
184+
let modified: boolean = false;
185+
186+
yaml.visit(
187+
doc,
188+
usesVisitor((pair, actionName, actionVersion) => {
189+
// Try to look up version information for this action.
190+
const versionInfo = actionVersions[actionName];
191+
192+
// If we found version information, and the version is different from that in the template,
193+
// then update the pair node accordingly.
194+
if (versionInfo && versionInfo.version !== actionVersion.version) {
195+
pair.value.value = `${actionName}@${versionInfo.version}`;
196+
pair.value.comment = versionInfo.comment;
197+
modified = true;
198+
}
199+
}),
200+
);
150201

151-
if (content !== originalContent) {
152-
fs.writeFileSync(filePath, content, "utf8");
202+
// Write the YAML document back to the file if we made changes.
203+
if (modified) {
204+
fs.writeFileSync(
205+
filePath,
206+
yaml.stringify(doc, { lineWidth: 0, flowCollectionPadding: false }),
207+
"utf8",
208+
);
153209
modifiedFiles.push(filePath);
154210
console.info(`Updated ${filePath}`);
155211
}
@@ -178,7 +234,7 @@ function main(): number {
178234
if (verbose) {
179235
console.info("Found action versions:");
180236
for (const [action, version] of Object.entries(actionVersions)) {
181-
console.info(` ${action}@${version}`);
237+
console.info(` ${action}@${versionWithCommentStr(version)}`);
182238
}
183239
}
184240

0 commit comments

Comments
 (0)