diff --git a/.github/workflows/__unsupported-macos.yml b/.github/workflows/__unsupported-macos.yml index 5e388d1ec0..66bdb69cd6 100644 --- a/.github/workflows/__unsupported-macos.yml +++ b/.github/workflows/__unsupported-macos.yml @@ -114,7 +114,7 @@ jobs: - name: Install dependencies run: npm ci - name: Download workflow artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: test-mode-sarif-* path: sarif diff --git a/pr-checks/checks/unsupported-macos.yml b/pr-checks/checks/unsupported-macos.yml index 5881b174a8..f5388f1d68 100644 --- a/pr-checks/checks/unsupported-macos.yml +++ b/pr-checks/checks/unsupported-macos.yml @@ -48,7 +48,7 @@ validationJobs: installNode: true steps: - name: Download workflow artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: test-mode-sarif-* path: sarif diff --git a/pr-checks/sync-back.test.ts b/pr-checks/sync-back.test.ts index 52d4c98f3f..142487449f 100755 --- a/pr-checks/sync-back.test.ts +++ b/pr-checks/sync-back.test.ts @@ -56,9 +56,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.actionVersions["actions/checkout"], + new Set(["v4"]), + ); + assert.deepEqual( + result.actionVersions["actions/setup-node"], + new Set(["v5"]), + ); + assert.deepEqual( + result.actionVersions["actions/setup-go"], + new Set(["v6"]), + ); }); await it("scanning workflows with version comments", () => { @@ -78,12 +87,18 @@ jobs: const result = scanGeneratedWorkflows(workflowDir); - assert.equal(result["actions/checkout"], "v4"); - assert.equal( - result["ruby/setup-ruby"], - "44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0", + assert.deepEqual( + result.actionVersions["actions/checkout"], + new Set(["v4"]), + ); + assert.deepEqual( + result.actionVersions["ruby/setup-ruby"], + new Set(["44511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0"]), + ); + assert.deepEqual( + result.actionVersions["actions/setup-python"], + new Set(["v6 # Latest Python"]), ); - assert.equal(result["actions/setup-python"], "v6 # Latest Python"); }); await it("ignores local actions", () => { @@ -103,9 +118,15 @@ jobs: const result = scanGeneratedWorkflows(workflowDir); - assert.equal(result["actions/checkout"], "v4"); - assert.equal("./.github/actions/local-action" in result, false); - assert.equal("./another-local-action" in result, false); + assert.deepEqual( + result.actionVersions["actions/checkout"], + new Set(["v4"]), + ); + assert.equal( + "./.github/actions/local-action" in result.actionVersions, + false, + ); + assert.equal("./another-local-action" in result.actionVersions, false); }); }); @@ -128,8 +149,8 @@ const steps = [ fs.writeFileSync(syncTsPath, syncTsContent); const actionVersions = { - "actions/setup-node": "v5", - "actions/setup-go": "v6", + "actions/setup-node": new Set(["v5"]), + "actions/setup-go": new Set(["v6"]), }; const result = updateSyncTs(syncTsPath, actionVersions); @@ -155,7 +176,7 @@ const steps = [ fs.writeFileSync(syncTsPath, syncTsContent); const actionVersions = { - "actions/setup-node": "v5 # Latest version", + "actions/setup-node": new Set(["v5 # Latest version"]), }; const result = updateSyncTs(syncTsPath, actionVersions); @@ -182,7 +203,7 @@ const steps = [ fs.writeFileSync(syncTsPath, syncTsContent); const actionVersions = { - "actions/setup-node": "v5", + "actions/setup-node": new Set(["v5"]), }; const result = updateSyncTs(syncTsPath, actionVersions); @@ -206,7 +227,9 @@ const steps = [ fs.writeFileSync(syncTsPath, syncTsContent); const actionVersions = { - "actions/setup-node": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", + "actions/setup-node": new Set([ + "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0", + ]), }; const result = updateSyncTs(syncTsPath, actionVersions); @@ -244,8 +267,9 @@ steps: "actions/checkout": "v4", "actions/setup-node": "v5 # Latest", }; + const byWorkflow = { ["test.yml"]: actionVersions }; - const result = updateTemplateFiles(checksDir, actionVersions); + const result = updateTemplateFiles(checksDir, byWorkflow); assert.equal(result.length, 1); assert.ok(result.includes(templatePath)); @@ -269,8 +293,9 @@ steps: const actionVersions = { "ruby/setup-ruby": "55511735964dcb71245e7e55f72539531f7bc0eb # v1.257.0", }; + const byWorkflow = { ["test.yml"]: actionVersions }; - const result = updateTemplateFiles(checksDir, actionVersions); + const result = updateTemplateFiles(checksDir, byWorkflow); 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 233621ea74..7028864672 100755 --- a/pr-checks/sync-back.ts +++ b/pr-checks/sync-back.ts @@ -27,16 +27,39 @@ const CHECKS_DIR = path.join(PR_CHECKS_DIR, "checks"); const WORKFLOW_DIR = path.join(REPO_ROOT, ".github", "workflows"); const SYNC_TS_PATH = path.join(PR_CHECKS_DIR, "sync.ts"); +/** Type alias for action names. */ +type ActionName = string; + +/** Type alias for action versions. May include a comment. */ +type ActionVersion = string; + +/** Mappings of {@link ActionName}s to {@link ActionVersion}s. */ +type ActionVersions = Record; + +/** Mappings of workflow names to {@link ActionVersions}. */ +type ActionVersionsByWorkflow = Record; + +/** The result of {@link scanGeneratedWorkflows}. */ +type ScanWorkflowResult = { + /** A mapping of {@link ActionName}s to all encountered {@link ActionVersion}s. */ + actionVersions: Record>; + + /** A mapping of workflow names to {@link ActionVersions}. */ + actionVersionsByWorkflow: ActionVersionsByWorkflow; +}; + /** * Scan generated workflow files to extract the latest action versions. * * @param workflowDir - Path to .github/workflows directory - * @returns Map from action names to their latest versions (including comments) + * + * @returns A {@link ScanWorkflowResult} object containing information about the extracted action versions. */ export function scanGeneratedWorkflows( workflowDir: string, -): Record { - const actionVersions: Record = {}; +): ScanWorkflowResult { + const actionVersionsByWorkflow: ActionVersionsByWorkflow = {}; + const actionVersions: Record> = {}; const generatedFiles = fs .readdirSync(workflowDir) @@ -46,6 +69,10 @@ export function scanGeneratedWorkflows( for (const filePath of generatedFiles) { const content = fs.readFileSync(filePath, "utf8"); + // Get the basename of the workflow and drop the leading `__` from it. + const workflowName = path.basename(filePath).slice(2); + actionVersionsByWorkflow[workflowName] = {}; + // 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; @@ -57,13 +84,26 @@ export function scanGeneratedWorkflows( // Only track non-local actions (those with / but not starting with ./) if (!actionName.startsWith("./")) { - // Assume that version numbers are consistent (this should be the case on a Dependabot update PR) - actionVersions[actionName] = versionWithComment; + // Record the version of the action for this workflow. + actionVersionsByWorkflow[workflowName][actionName] = versionWithComment; + + // Record this version for the action so that we can write it to `sync.ts` if needed. + // Usually we'd expect there to be exactly one version per action across all workflows, + // but sometimes these can go out-of-sync if e.g. a workflow is added in a branch and a + // Dependabot PR is merged in the meantime that changes the version of an action that is + // used in the new workflow. Different versions of the same action across different + // workflows is not fundamentally a problem, but we may need to pick one for `sync.ts` + // so we warn there if we have multiple versions of an action. + if (actionVersions[actionName] === undefined) { + actionVersions[actionName] = new Set([versionWithComment]); + } else if (!actionVersions[actionName].has(versionWithComment)) { + actionVersions[actionName].add(versionWithComment); + } } } } - return actionVersions; + return { actionVersions, actionVersionsByWorkflow }; } /** @@ -79,7 +119,7 @@ export function scanGeneratedWorkflows( */ export function updateSyncTs( syncTsPath: string, - actionVersions: Record, + actionVersions: Record>, ): boolean { if (!fs.existsSync(syncTsPath)) { throw new Error(`Could not find ${syncTsPath}`); @@ -89,9 +129,18 @@ export function updateSyncTs( const originalContent = content; // Update hardcoded action versions - for (const [actionName, versionWithComment] of Object.entries( + for (const [actionName, versionsWithComments] of Object.entries( actionVersions, )) { + // We usually expect `versionsWithComments` to contain one version. If there are multiple, + // we arbitrarily pick one and warn about it. + const versionsWithCommentsArray = Array.from(versionsWithComments); + const versionWithComment = versionsWithCommentsArray[0]; + if (versionsWithComments.size > 1) { + console.warn( + `Inconsistent versions for ${actionName}. Arbitrarily picking '${versionWithComment}'. All found: ${versionsWithCommentsArray.join(", ")}`, + ); + } // Split the scanned value into the ref (e.g. a commit SHA) and the optional // trailing version comment (e.g. `v6.0.3`). const ref = versionWithComment.includes("#") @@ -138,12 +187,12 @@ export function updateSyncTs( * Update action versions in template files in pr-checks/checks/ * * @param checksDir - Path to pr-checks/checks directory - * @param actionVersions - Map of action names to versions (may include comments) + * @param actionVersionsByWorkflow - Map of workflows to maps of action names to versions (may include comments) * @returns List of files that were modified */ export function updateTemplateFiles( checksDir: string, - actionVersions: Record, + actionVersionsByWorkflow: ActionVersionsByWorkflow, ): string[] { const modifiedFiles: string[] = []; @@ -156,6 +205,16 @@ export function updateTemplateFiles( let content = fs.readFileSync(filePath, "utf8"); const originalContent = content; + const workflowName = path.basename(filePath); + const actionVersions = actionVersionsByWorkflow[workflowName]; + + if (actionVersions === undefined) { + console.info( + `Found no action versions for workflow template '${filePath}'.`, + ); + continue; + } + // Update action versions for (const [actionName, versionWithComment] of Object.entries( actionVersions, @@ -191,12 +250,15 @@ function main(): number { const verbose = values.verbose ?? false; console.info("Scanning generated workflows for latest action versions..."); - const actionVersions = scanGeneratedWorkflows(WORKFLOW_DIR); + const { actionVersions, actionVersionsByWorkflow } = + scanGeneratedWorkflows(WORKFLOW_DIR); if (verbose) { console.info("Found action versions:"); - for (const [action, version] of Object.entries(actionVersions)) { - console.info(` ${action}@${version}`); + for (const [action, versions] of Object.entries(actionVersions)) { + for (const version of versions) { + console.info(` ${action}@${version}`); + } } } @@ -215,7 +277,10 @@ function main(): number { } // Update template files - const templateModified = updateTemplateFiles(CHECKS_DIR, actionVersions); + const templateModified = updateTemplateFiles( + CHECKS_DIR, + actionVersionsByWorkflow, + ); modifiedFiles.push(...templateModified); if (modifiedFiles.length > 0) {