diff --git a/pr-checks/action-versions.ts b/pr-checks/action-versions.ts new file mode 100644 index 0000000000..aa8914a492 --- /dev/null +++ b/pr-checks/action-versions.ts @@ -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" + } +}; diff --git a/pr-checks/checks/autobuild-direct-tracing-with-working-dir.yml b/pr-checks/checks/autobuild-direct-tracing-with-working-dir.yml index f492ac85a4..44b7b94332 100644 --- a/pr-checks/checks/autobuild-direct-tracing-with-working-dir.yml +++ b/pr-checks/checks/autobuild-direct-tracing-with-working-dir.yml @@ -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. diff --git a/pr-checks/checks/init-with-registries.yml b/pr-checks/checks/init-with-registries.yml index cedc62aab0..59559769ba 100644 --- a/pr-checks/checks/init-with-registries.yml +++ b/pr-checks/checks/init-with-registries.yml @@ -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: | diff --git a/pr-checks/checks/overlay-init-fallback.yml b/pr-checks/checks/overlay-init-fallback.yml index bfcfd27e79..88f103566c 100644 --- a/pr-checks/checks/overlay-init-fallback.yml +++ b/pr-checks/checks/overlay-init-fallback.yml @@ -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 diff --git a/pr-checks/sync.ts b/pr-checks/sync.ts index ca2b069cb6..6ed36ac237 100755 --- a/pr-checks/sync.ts +++ b/pr-checks/sync.ts @@ -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 + "' }}", diff --git a/pr-checks/sync_back.test.ts b/pr-checks/sync_back.test.ts index 316d2b7303..364f421a65 100755 --- a/pr-checks/sync_back.test.ts +++ b/pr-checks/sync_back.test.ts @@ -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" }, - }, - { - uses: "actions/setup-go@v5", - with: { "go-version": "1.19" }, - }, -]; -`; - - fs.writeFileSync(syncTsPath, syncTsContent); - - const actionVersions = { - "actions/setup-node": "v5", - "actions/setup-go": "v6", - }; - - const result = updateSyncTs(syncTsPath, actionVersions); - assert.equal(result, true); - - const updatedContent = fs.readFileSync(syncTsPath, "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" }, +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" }, -]; -`; + "actions/setup-go": { + "version": "v5" + } +}; +`.trim(); - fs.writeFileSync(syncTsPath, syncTsContent); + fs.writeFileSync(actionVersionsTsPath, actionVersionsTsContent); const actionVersions = { - "actions/setup-node": "v5 # Latest version", + "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"); - // 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" + } +}; +`.trim(); - 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"); diff --git a/pr-checks/sync_back.ts b/pr-checks/sync_back.ts index 7e1375580b..407df1ed0f 100755 --- a/pr-checks/sync_back.ts +++ b/pr-checks/sync_back.ts @@ -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, + 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, 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 { - const actionVersions: Record = {}; +export function scanGeneratedWorkflows( + workflowDir: string, +): Record { + const actionVersions: Record = {}; const generatedFiles = fs .readdirSync(workflowDir) @@ -43,86 +111,63 @@ export function scanGeneratedWorkflows(workflowDir: string): Record { // 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, +export function updateActionVersions( + options: Options, + actionVersionsTsPath: string, + actionVersions: Record, ): 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)};`; - let content = fs.readFileSync(syncTsPath, "utf8"); - const originalContent = content; + if (fs.existsSync(actionVersionsTsPath)) { + const content = fs.readFileSync(actionVersionsTsPath, "utf8"); - // 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 === newContent && !options.force) { + console.info(`No changes needed in ${actionVersionsTsPath}`); + return false; + } } - if (content !== originalContent) { - fs.writeFileSync(syncTsPath, content, "utf8"); - console.info(`Updated ${syncTsPath}`); - return true; - } else { - console.info(`No changes needed in ${syncTsPath}`); - return false; - } + // Update hardcoded action versions + 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, + actionVersions: Record, ): 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; - - // 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", - ); - content = content.replace(pattern, `$1@${versionWithComment}`); - } + const content = fs.readFileSync(filePath, "utf8"); + const doc = yaml.parseDocument(content, { keepSourceTokens: true }); + let modified: boolean = false; + + 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; + } + }), + ); - if (content !== originalContent) { - fs.writeFileSync(filePath, content, "utf8"); + // Write the YAML document back to the file if we made changes. + if (modified || options.force) { + fs.writeFileSync( + filePath, + yaml.stringify(doc, { lineWidth: 0, flowCollectionPadding: false }), + "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) {