Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/__unsupported-macos.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pr-checks/checks/unsupported-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 43 additions & 18 deletions pr-checks/sync-back.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand All @@ -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);
});
});

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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));

Expand All @@ -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");
Expand Down
93 changes: 79 additions & 14 deletions pr-checks/sync-back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActionName, ActionVersion>;

/** Mappings of workflow names to {@link ActionVersions}. */
type ActionVersionsByWorkflow = Record<string, ActionVersions>;

/** The result of {@link scanGeneratedWorkflows}. */
type ScanWorkflowResult = {
/** A mapping of {@link ActionName}s to all encountered {@link ActionVersion}s. */
actionVersions: Record<ActionName, Set<ActionVersion>>;

/** 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<string, string> {
const actionVersions: Record<string, string> = {};
): ScanWorkflowResult {
Comment thread
mbg marked this conversation as resolved.
Resolved
const actionVersionsByWorkflow: ActionVersionsByWorkflow = {};
const actionVersions: Record<ActionName, Set<ActionVersion>> = {};

const generatedFiles = fs
.readdirSync(workflowDir)
Expand All @@ -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;
Expand All @@ -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 };
}

/**
Expand All @@ -79,7 +119,7 @@ export function scanGeneratedWorkflows(
*/
export function updateSyncTs(
syncTsPath: string,
actionVersions: Record<string, string>,
actionVersions: Record<ActionName, Set<ActionVersion>>,
): boolean {
if (!fs.existsSync(syncTsPath)) {
throw new Error(`Could not find ${syncTsPath}`);
Expand All @@ -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("#")
Expand Down Expand Up @@ -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<string, string>,
actionVersionsByWorkflow: ActionVersionsByWorkflow,
): string[] {
const modifiedFiles: string[] = [];

Expand All @@ -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];
Comment thread
mbg marked this conversation as resolved.
Resolved

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,
Expand Down Expand Up @@ -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}`);
}
}
}

Expand All @@ -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) {
Expand Down
Loading