Compare commits

...

7 Commits

Author SHA1 Message Date
Michael B. Gale 25599b3fc8 Use doc.toString rather than yaml.stringify 2026-03-09 14:29:04 +00:00
Michael B. Gale 639dddc728 Add missing newline at the end of action-versions.ts 2026-03-09 14:29:04 +00:00
Michael B. Gale 0b27c71731 Generate and use action-versions.ts 2026-03-07 00:29:11 +00:00
Michael B. Gale 6679b049d5 Generate separate TS file with version mappings 2026-03-07 00:24:18 +00:00
Michael B. Gale 13e47f85a5 Type options and add --force option 2026-03-07 00:24:17 +00:00
Michael B. Gale 5f152be4c2 Minor adjustments to PR check templates
These are needed to render them correctly in `sync_back.ts`
2026-03-06 23:40:49 +00:00
Michael B. Gale f4b6bd982f Parse/render YAML documents for sync_back.ts 2026-03-06 19:56:16 +00:00
7 changed files with 291 additions and 173 deletions
+30
View File
@@ -0,0 +1,30 @@
export const ACTION_VERSIONS = {
"actions/checkout": {
"version": "v6"
},
"actions/setup-go": {
"version": "v6"
},
"actions/setup-dotnet": {
"version": "v5"
},
"actions/upload-artifact": {
"version": "v7"
},
"actions/github-script": {
"version": "v8"
},
"actions/setup-python": {
"version": "v6"
},
"actions/setup-java": {
"version": "v5"
},
"actions/setup-node": {
"version": "v6"
},
"ruby/setup-ruby": {
"version": "09a7688d3b55cf0e976497ff046b70949eeaccfd",
"comment": " v1.288.0"
}
};
@@ -1,5 +1,5 @@
name: "Autobuild direct tracing (custom working directory)"
description: >
description: |
An end-to-end integration test of a Java repository built using 'build-mode: autobuild',
with direct tracing enabled and a custom working directory specified as the input to the
autobuild Action.
+8 -9
View File
@@ -4,12 +4,11 @@
# basic mechanics of multi-registry auth is working.
name: "Packaging: Download using registries"
description: "Checks that specifying a registries block and associated auth works as expected"
versions: [
# This feature is not compatible with older CLIs
"default",
"linked",
"nightly-latest",
]
versions:
# This feature is not compatible with older CLIs
- "default"
- "linked"
- "nightly-latest"
permissions:
contents: read
@@ -24,9 +23,9 @@ steps:
config-file: ./.github/codeql/codeql-config-registries.yml
languages: javascript
registries: |
- url: "https://ghcr.io/v2/"
packages: "*/*"
token: "${{ secrets.GITHUB_TOKEN }}"
- url: "https://ghcr.io/v2/"
packages: "*/*"
token: "${{ secrets.GITHUB_TOKEN }}"
- name: Verify packages installed
run: |
+1 -1
View File
@@ -4,7 +4,7 @@ versions: ["linked", "nightly-latest"]
steps:
- uses: ./../action/init
with:
languages: actions # Any language without overlay support will do
languages: actions # Any language without overlay support will do
tools: ${{ steps.prepare-test.outputs.tools-url }}
env:
CODEQL_OVERLAY_DATABASE_MODE: overlay-base
+17 -6
View File
@@ -5,6 +5,8 @@ import * as path from "path";
import * as yaml from "yaml";
import { ACTION_VERSIONS } from "./action-versions";
/** Known workflow input names. */
enum KnownInputName {
GoVersion = "go-version",
@@ -13,6 +15,9 @@ enum KnownInputName {
DotnetVersion = "dotnet-version",
}
/** Known Action names that we have version information for. */
type KnownAction = keyof typeof ACTION_VERSIONS;
/**
* Represents workflow input definitions.
*/
@@ -94,6 +99,12 @@ const THIS_DIR = __dirname;
const CHECKS_DIR = path.join(THIS_DIR, "checks");
const OUTPUT_DIR = path.join(THIS_DIR, "..", ".github", "workflows");
/** Gets an `actionName@ref` string for `actionName`. */
function getActionRef(actionName: KnownAction): string {
const versionInfo = ACTION_VERSIONS[actionName];
return `${actionName}@${versionInfo.version}`;
}
/**
* Loads and parses a YAML file.
*/
@@ -216,7 +227,7 @@ function main(): void {
const steps: any[] = [
{
name: "Check out repository",
uses: "actions/checkout@v6",
uses: getActionRef("actions/checkout"),
},
];
@@ -226,7 +237,7 @@ function main(): void {
steps.push(
{
name: "Install Node.js",
uses: "actions/setup-node@v6",
uses: getActionRef("actions/setup-node"),
with: {
"node-version": "20.x",
cache: "npm",
@@ -265,7 +276,7 @@ function main(): void {
steps.push({
name: "Install Go",
uses: "actions/setup-go@v6",
uses: getActionRef("actions/setup-go"),
with: {
"go-version":
"${{ inputs.go-version || '" + baseGoVersionExpr + "' }}",
@@ -289,7 +300,7 @@ function main(): void {
steps.push({
name: "Install Java",
uses: "actions/setup-java@v5",
uses: getActionRef("actions/setup-java"),
with: {
"java-version":
"${{ inputs.java-version || '" + baseJavaVersionExpr + "' }}",
@@ -312,7 +323,7 @@ function main(): void {
steps.push({
name: "Install Python",
if: "matrix.version != 'nightly-latest'",
uses: "actions/setup-python@v6",
uses: getActionRef("actions/setup-python"),
with: {
"python-version":
"${{ inputs.python-version || '" + basePythonVersionExpr + "' }}",
@@ -333,7 +344,7 @@ function main(): void {
steps.push({
name: "Install .NET",
uses: "actions/setup-dotnet@v5",
uses: getActionRef("actions/setup-dotnet"),
with: {
"dotnet-version":
"${{ inputs.dotnet-version || '" + baseDotNetVersionExpr + "' }}",
+95 -80
View File
@@ -11,15 +11,18 @@ import * as path from "node:path";
import { afterEach, beforeEach, describe, it } from "node:test";
import {
Options,
scanGeneratedWorkflows,
updateSyncTs,
updateActionVersions,
updateTemplateFiles,
} from "./sync_back";
let testDir: string;
let workflowDir: string;
let checksDir: string;
let syncTsPath: string;
let actionVersionsTsPath: string;
const defaultOptions: Options = { verbose: false, force: false };
beforeEach(() => {
/** Set up temporary directories and files for testing */
@@ -29,8 +32,8 @@ beforeEach(() => {
fs.mkdirSync(workflowDir, { recursive: true });
fs.mkdirSync(checksDir, { recursive: true });
// Create sync.ts file path
syncTsPath = path.join(testDir, "pr-checks", "sync.ts");
// Create action-versions.ts file path
actionVersionsTsPath = path.join(testDir, "pr-checks", "action-versions.ts");
});
afterEach(() => {
@@ -56,9 +59,18 @@ jobs:
const result = scanGeneratedWorkflows(workflowDir);
assert.equal(result["actions/checkout"], "v4");
assert.equal(result["actions/setup-node"], "v5");
assert.equal(result["actions/setup-go"], "v6");
assert.deepEqual(result["actions/checkout"], {
version: "v4",
comment: undefined,
});
assert.deepEqual(result["actions/setup-node"], {
version: "v5",
comment: undefined,
});
assert.deepEqual(result["actions/setup-go"], {
version: "v6",
comment: undefined,
});
});
it("scanning workflows with version comments", () => {
@@ -78,12 +90,18 @@ jobs:
const result = scanGeneratedWorkflows(workflowDir);
assert.equal(result["actions/checkout"], "v4");
assert.equal(
result["ruby/setup-ruby"],
"44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0",
);
assert.equal(result["actions/setup-python"], "v6 # Latest Python");
assert.deepEqual(result["actions/checkout"], {
version: "v4",
comment: undefined,
});
assert.deepEqual(result["ruby/setup-ruby"], {
version: "44511735964dcb71245e7e55f72539531f7bc0eb",
comment: " v1.257.0",
});
assert.deepEqual(result["actions/setup-python"], {
version: "v6",
comment: " Latest Python",
});
});
it("ignores local actions", () => {
@@ -103,89 +121,76 @@ jobs:
const result = scanGeneratedWorkflows(workflowDir);
assert.equal(result["actions/checkout"], "v4");
assert.deepEqual(result["actions/checkout"], {
version: "v4",
comment: undefined,
});
assert.equal("./.github/actions/local-action" in result, false);
assert.equal("./another-local-action" in result, false);
});
});
describe("updateSyncTs", () => {
it("updates sync.ts file", () => {
/** Test updating sync.ts file */
const syncTsContent = `
const steps = [
{
uses: "actions/setup-node@v4",
with: { "node-version": "16" },
describe("updateActionVersions", () => {
it("updates action-versions.ts file", () => {
/** Test updating action-versions.ts file */
const actionVersionsTsContent = `
export const ACTION_VERSIONS = {
"actions/setup-node": {
"version": "v4"
},
{
uses: "actions/setup-go@v5",
with: { "go-version": "1.19" },
},
];
`;
"actions/setup-go": {
"version": "v5"
}
};
`.trim();
fs.writeFileSync(syncTsPath, syncTsContent);
fs.writeFileSync(actionVersionsTsPath, actionVersionsTsContent);
const actionVersions = {
"actions/setup-node": "v5",
"actions/setup-go": "v6",
"actions/setup-node": { version: "v5" },
"actions/setup-go": { version: "v6" },
};
const result = updateSyncTs(syncTsPath, actionVersions);
const result = updateActionVersions(
defaultOptions,
actionVersionsTsPath,
actionVersions,
);
assert.equal(result, true);
const updatedContent = fs.readFileSync(syncTsPath, "utf8");
const updatedContent = fs.readFileSync(actionVersionsTsPath, "utf8");
assert.ok(updatedContent.includes('uses: "actions/setup-node@v5"'));
assert.ok(updatedContent.includes('uses: "actions/setup-go@v6"'));
});
it("strips comments from versions", () => {
/** Test updating sync.ts file when versions have comments */
const syncTsContent = `
const steps = [
{
uses: "actions/setup-node@v4",
with: { "node-version": "16" },
},
];
`;
fs.writeFileSync(syncTsPath, syncTsContent);
const actionVersions = {
"actions/setup-node": "v5 # Latest version",
};
const result = updateSyncTs(syncTsPath, actionVersions);
assert.equal(result, true);
const updatedContent = fs.readFileSync(syncTsPath, "utf8");
// sync.ts should get the version without comment
assert.ok(updatedContent.includes('uses: "actions/setup-node@v5"'));
assert.ok(!updatedContent.includes("# Latest version"));
assert.ok(
updatedContent.includes('"actions/setup-node": {\n "version": "v5"'),
);
assert.ok(
updatedContent.includes('"actions/setup-go": {\n "version": "v6"'),
);
});
it("returns false when no changes are needed", () => {
/** Test that updateSyncTs returns false when no changes are needed */
const syncTsContent = `
const steps = [
{
uses: "actions/setup-node@v5",
with: { "node-version": "16" },
},
];
`;
/** Test that updateActionVersions returns false when no changes are needed */
const actionVersionsTsContent = `
export const ACTION_VERSIONS = {
"actions/setup-node": {
"version": "v5"
}
};
`.trimStart();
fs.writeFileSync(syncTsPath, syncTsContent);
fs.writeFileSync(actionVersionsTsPath, actionVersionsTsContent);
const actionVersions = {
"actions/setup-node": "v5",
"actions/setup-node": { version: "v5" },
};
const result = updateSyncTs(syncTsPath, actionVersions);
const result = updateActionVersions(
defaultOptions,
actionVersionsTsPath,
actionVersions,
);
const updatedContent = fs.readFileSync(actionVersionsTsPath, "utf8");
assert.equal(updatedContent, actionVersionsTsContent);
assert.equal(result, false);
});
});
@@ -206,11 +211,15 @@ steps:
fs.writeFileSync(templatePath, templateContent);
const actionVersions = {
"actions/checkout": "v4",
"actions/setup-node": "v5 # Latest",
"actions/checkout": { version: "v4" },
"actions/setup-node": { version: "v5", comment: " Latest" },
};
const result = updateTemplateFiles(checksDir, actionVersions);
const result = updateTemplateFiles(
defaultOptions,
checksDir,
actionVersions,
);
assert.equal(result.length, 1);
assert.ok(result.includes(templatePath));
@@ -232,11 +241,17 @@ steps:
fs.writeFileSync(templatePath, templateContent);
const actionVersions = {
"ruby/setup-ruby":
"55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0",
"ruby/setup-ruby": {
version: "55511735964dcb71245e7e55f72539531f7bc0eb",
comment: " v1.257.0",
},
};
const result = updateTemplateFiles(checksDir, actionVersions);
const result = updateTemplateFiles(
defaultOptions,
checksDir,
actionVersions,
);
assert.equal(result.length, 1);
const updatedContent = fs.readFileSync(templatePath, "utf8");
+139 -76
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env npx tsx
import * as yaml from "yaml";
/*
Sync-back script to automatically update action versions in source templates
from the generated workflow files after Dependabot updates.
@@ -25,7 +27,71 @@ import * as path from "path";
const THIS_DIR = __dirname;
const CHECKS_DIR = path.join(THIS_DIR, "checks");
const WORKFLOW_DIR = path.join(THIS_DIR, "..", ".github", "workflows");
const SYNC_TS_PATH = path.join(THIS_DIR, "sync.ts");
const ACTION_VERSIONS_PATH = path.join(THIS_DIR, "action-versions.ts");
/** Command-line options for this program. */
export type Options = {
verbose: boolean;
force: boolean;
};
/** Records information about the version of an Action with an optional comment. */
type ActionVersion = { version: string; comment?: string };
/** Converts `info` to a string that includes the version and comment. */
function versionWithCommentStr(info: ActionVersion): string {
const comment = info.comment ? ` #${info.comment}` : "";
return `${info.version}${comment}`;
}
/**
* Constructs a `yaml.visitor` which calls `fn` for `yaml.Pair` nodes where the key is "uses" and
* the value is a `yaml.Scalar`.
*/
function usesVisitor(
fn: (
pair: yaml.Pair<yaml.Scalar, yaml.Scalar>,
actionName: string,
actionVersion: ActionVersion,
) => void,
): yaml.visitor {
return {
Pair(_, pair) {
if (
yaml.isScalar(pair.key) &&
yaml.isScalar(pair.value) &&
pair.key.value === "uses" &&
typeof pair.value.value === "string"
) {
const usesValue = pair.value.value;
// Only track non-local actions (those with / but not starting with ./)
if (!usesValue.startsWith("./")) {
const parts = (pair.value.value as string).split("@");
if (parts.length !== 2) {
throw new Error(`Unexpected 'uses' value: ${usesValue}`);
}
const actionName = parts[0];
const actionVersion = parts[1].trimEnd();
const comment = pair.value.comment?.trimEnd();
fn(pair as yaml.Pair<yaml.Scalar, yaml.Scalar>, actionName, {
version: actionVersion,
comment,
});
}
// Do not visit the children of this node.
return yaml.visit.SKIP;
}
// Do nothing and continue.
return undefined;
},
};
}
/**
* Scan generated workflow files to extract the latest action versions.
@@ -33,8 +99,10 @@ const SYNC_TS_PATH = path.join(THIS_DIR, "sync.ts");
* @param workflowDir - Path to .github/workflows directory
* @returns Map from action names to their latest versions (including comments)
*/
export function scanGeneratedWorkflows(workflowDir: string): Record<string, string> {
const actionVersions: Record<string, string> = {};
export function scanGeneratedWorkflows(
workflowDir: string,
): Record<string, ActionVersion> {
const actionVersions: Record<string, ActionVersion> = {};
const generatedFiles = fs
.readdirSync(workflowDir)
@@ -43,86 +111,63 @@ export function scanGeneratedWorkflows(workflowDir: string): Record<string, stri
for (const filePath of generatedFiles) {
const content = fs.readFileSync(filePath, "utf8");
const doc = yaml.parseDocument(content);
// Find all action uses in the file, including potential comments
// This pattern captures: action_name@version_with_possible_comment
const pattern = /uses:\s+([^/\s]+\/[^@\s]+)@([^@\n]+)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const actionName = match[1];
const versionWithComment = match[2].trimEnd();
// Only track non-local actions (those with / but not starting with ./)
if (!actionName.startsWith("./")) {
yaml.visit(
doc,
usesVisitor((_node, actionName, actionVersion) => {
// Assume that version numbers are consistent (this should be the case on a Dependabot update PR)
actionVersions[actionName] = versionWithComment;
}
}
actionVersions[actionName] = actionVersion;
}),
);
}
return actionVersions;
}
/**
* Update hardcoded action versions in pr-checks/sync.ts
* Update hardcoded action versions in pr-checks/action-versions.ts
*
* @param syncTsPath - Path to sync.ts file
* @param options - The command-line options.
* @param actionVersionsTsPath - Path to action-versions.ts file
* @param actionVersions - Map of action names to versions (may include comments)
* @returns True if the file was modified, false otherwise
*/
export function updateSyncTs(
syncTsPath: string,
actionVersions: Record<string, string>,
export function updateActionVersions(
options: Options,
actionVersionsTsPath: string,
actionVersions: Record<string, ActionVersion>,
): boolean {
if (!fs.existsSync(syncTsPath)) {
throw new Error(`Could not find ${syncTsPath}`);
}
// Build content for the file.
let newContent: string = `export const ACTION_VERSIONS = ${JSON.stringify(actionVersions, null, 2)};\n`;
let content = fs.readFileSync(syncTsPath, "utf8");
const originalContent = content;
if (fs.existsSync(actionVersionsTsPath)) {
const content = fs.readFileSync(actionVersionsTsPath, "utf8");
if (content === newContent && !options.force) {
console.info(`No changes needed in ${actionVersionsTsPath}`);
return false;
}
}
// Update hardcoded action versions
for (const [actionName, versionWithComment] of Object.entries(
actionVersions,
)) {
// Extract just the version part (before any comment) for sync.ts
const version = versionWithComment.includes("#")
? versionWithComment.split("#")[0].trim()
: versionWithComment.trim();
// Look for patterns like uses: "actions/setup-node@v4"
// Note that this will break if we store an Action uses reference in a
// variable - that's a risk we're happy to take since in that case the
// PR checks will just fail.
const escaped = actionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`(uses:\\s*")${escaped}@(?:[^"]+)(")`,
"g",
);
content = content.replace(pattern, `$1${actionName}@${version}$2`);
}
if (content !== originalContent) {
fs.writeFileSync(syncTsPath, content, "utf8");
console.info(`Updated ${syncTsPath}`);
return true;
} else {
console.info(`No changes needed in ${syncTsPath}`);
return false;
}
fs.writeFileSync(actionVersionsTsPath, newContent, "utf8");
console.info(`Updated ${actionVersionsTsPath}`);
return true;
}
/**
* Update action versions in template files in pr-checks/checks/
*
* @param options - The command-line options.
* @param checksDir - Path to pr-checks/checks directory
* @param actionVersions - Map of action names to versions (may include comments)
* @returns List of files that were modified
*/
export function updateTemplateFiles(
options: Options,
checksDir: string,
actionVersions: Record<string, string>,
actionVersions: Record<string, ActionVersion>,
): string[] {
const modifiedFiles: string[] = [];
@@ -132,24 +177,33 @@ export function updateTemplateFiles(
.map((f) => path.join(checksDir, f));
for (const filePath of templateFiles) {
let content = fs.readFileSync(filePath, "utf8");
const originalContent = content;
const content = fs.readFileSync(filePath, "utf8");
const doc = yaml.parseDocument(content, { keepSourceTokens: true });
let modified: boolean = false;
// Update action versions
for (const [actionName, versionWithComment] of Object.entries(
actionVersions,
)) {
// Look for patterns like 'uses: actions/setup-node@v4' or 'uses: actions/setup-node@sha # comment'
const escaped = actionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(
`(uses:\\s+${escaped})@(?:[^@\n]+)`,
"g",
yaml.visit(
doc,
usesVisitor((pair, actionName, actionVersion) => {
// Try to look up version information for this action.
const versionInfo = actionVersions[actionName];
// If we found version information, and the version is different from that in the template,
// then update the pair node accordingly.
if (versionInfo && versionInfo.version !== actionVersion.version) {
pair.value.value = `${actionName}@${versionInfo.version}`;
pair.value.comment = versionInfo.comment;
modified = true;
}
}),
);
// Write the YAML document back to the file if we made changes.
if (modified || options.force) {
fs.writeFileSync(
filePath,
doc.toString({ lineWidth: 0, flowCollectionPadding: false }),
"utf8",
);
content = content.replace(pattern, `$1@${versionWithComment}`);
}
if (content !== originalContent) {
fs.writeFileSync(filePath, content, "utf8");
modifiedFiles.push(filePath);
console.info(`Updated ${filePath}`);
}
@@ -166,6 +220,11 @@ function main(): number {
short: "v",
default: false,
},
force: {
type: "boolean",
short: "f",
default: false,
},
},
strict: true,
});
@@ -178,7 +237,7 @@ function main(): number {
if (verbose) {
console.info("Found action versions:");
for (const [action, version] of Object.entries(actionVersions)) {
console.info(` ${action}@${version}`);
console.info(` ${action}@${versionWithCommentStr(version)}`);
}
}
@@ -192,12 +251,16 @@ function main(): number {
const modifiedFiles: string[] = [];
// Update sync.ts
if (updateSyncTs(SYNC_TS_PATH, actionVersions)) {
modifiedFiles.push(SYNC_TS_PATH);
if (updateActionVersions(values, ACTION_VERSIONS_PATH, actionVersions)) {
modifiedFiles.push(ACTION_VERSIONS_PATH);
}
// Update template files
const templateModified = updateTemplateFiles(CHECKS_DIR, actionVersions);
const templateModified = updateTemplateFiles(
values,
CHECKS_DIR,
actionVersions,
);
modifiedFiles.push(...templateModified);
if (modifiedFiles.length > 0) {