diff --git a/action.yml b/action.yml index dbd21f9..16fffb2 100644 --- a/action.yml +++ b/action.yml @@ -58,6 +58,11 @@ inputs: required: false default: true + generate-dependency-graph: + description: When 'true', a dependency graph snapshot will be generated for Gradle builds. + required: false + default: false + # EXPERIMENTAL & INTERNAL ACTION INPUTS # The following action properties allow fine-grained tweaking of the action caching behaviour. # These properties are experimental and not (yet) designed for production use, and may change without notice in a subsequent release of `gradle-build-action`. diff --git a/src/cache-base.ts b/src/cache-base.ts index f989639..b7f3454 100644 --- a/src/cache-base.ts +++ b/src/cache-base.ts @@ -175,7 +175,8 @@ export class GradleStateCache { const initScriptFilenames = [ 'build-result-capture.init.gradle', 'build-result-capture-service.plugin.groovy', - 'github-dependency-graph.init.gradle' + 'github-dependency-graph.init.gradle', + 'github-dependency-graph-gradle-plugin-apply.groovy' ] for (const initScriptFilename of initScriptFilenames) { const initScriptContent = this.readInitScriptAsString(initScriptFilename) diff --git a/src/cache-utils.ts b/src/cache-utils.ts index c5edb43..6662be1 100644 --- a/src/cache-utils.ts +++ b/src/cache-utils.ts @@ -125,10 +125,25 @@ function getCacheKeyJobInstance(): string { // By default, we hash the full `matrix` data for the run, to uniquely identify this job invocation // The only way we can obtain the `matrix` data is via the `workflow-job-context` parameter in action.yml. - const workflowJobContext = params.getJobContext() + const workflowJobContext = params.getJobMatrix() return hashStrings([workflowJobContext]) } +export function getUniqueLabelForJobInstance(): string { + return getUniqueLabelForJobInstanceValues(github.context.workflow, github.context.job, params.getJobMatrix()) +} + +export function getUniqueLabelForJobInstanceValues(workflow: string, jobId: string, matrixJson: string): string { + const matrix = JSON.parse(matrixJson) + const matrixString = Object.values(matrix).join('-') + const label = matrixString ? `${workflow}-${jobId}-${matrixString}` : `${workflow}-${jobId}` + return sanitize(label) +} + +function sanitize(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase() +} + function getCacheKeyJobExecution(): string { // Used to associate a cache key with a particular execution (default is bound to the git commit sha) return process.env[CACHE_KEY_JOB_EXECUTION_VAR] || github.context.sha diff --git a/src/dependency-graph-submit.ts b/src/dependency-graph-submit.ts index f0be2e6..c6d1c98 100644 --- a/src/dependency-graph-submit.ts +++ b/src/dependency-graph-submit.ts @@ -4,7 +4,7 @@ import * as dependencyGraph from './dependency-graph' export async function run(): Promise { try { // Retrieve the dependency graph artifact and submit via Dependency Submission API - await dependencyGraph.submitDependencyGraph() + await dependencyGraph.downloadAndSubmitDependencyGraphs() } catch (error) { core.setFailed(String(error)) if (error instanceof Error && error.stack) { diff --git a/src/dependency-graph.ts b/src/dependency-graph.ts index 31f5390..47123aa 100644 --- a/src/dependency-graph.ts +++ b/src/dependency-graph.ts @@ -10,57 +10,50 @@ import fs from 'fs' import * as execution from './execution' import * as layout from './repository-layout' +import * as params from './input-params' const DEPENDENCY_GRAPH_ARTIFACT = 'dependency-graph' -const DEPENDENCY_GRAPH_FILE = 'dependency-graph.json' + +export function prepare(): void { + core.info('Enabling dependency graph') + const jobCorrelator = getJobCorrelator() + core.exportVariable('GITHUB_DEPENDENCY_GRAPH_ENABLED', 'true') + core.exportVariable('GITHUB_DEPENDENCY_GRAPH_JOB_CORRELATOR', jobCorrelator) + core.exportVariable('GITHUB_DEPENDENCY_GRAPH_JOB_ID', github.context.runId) + core.exportVariable( + 'GITHUB_DEPENDENCY_GRAPH_REPORT_DIR', + path.resolve(layout.workspaceDirectory(), 'dependency-graph-reports') + ) +} export async function generateDependencyGraph(executable: string | undefined): Promise { - const workspaceDirectory = layout.workspaceDirectory() const buildRootDirectory = layout.buildRootDirectory() - const buildPath = getRelativePathFromWorkspace(buildRootDirectory) - const initScript = path.resolve( - __dirname, - '..', - '..', - 'src', - 'resources', - 'init-scripts', - 'github-dependency-graph.init.gradle' - ) - const args = [ - `-Dorg.gradle.github.env.GRADLE_BUILD_PATH=${buildPath}`, - '--init-script', - initScript, - ':GitHubDependencyGraphPlugin_generateDependencyGraph' - ] + const args = [':GitHubDependencyGraphPlugin_generateDependencyGraph'] await execution.executeGradleBuild(executable, buildRootDirectory, args) - const dependencyGraphJson = copyDependencyGraphToBuildRoot(buildRootDirectory) +} + +export async function uploadDependencyGraphs(): Promise { + const workspaceDirectory = layout.workspaceDirectory() + const graphFiles = await findDependencyGraphFiles(workspaceDirectory) + + const relativeGraphFiles = graphFiles.map(x => getRelativePathFromWorkspace(x)) + core.info(`Uploading dependency graph files: ${relativeGraphFiles}`) const artifactClient = artifact.create() - artifactClient.uploadArtifact(DEPENDENCY_GRAPH_ARTIFACT, [dependencyGraphJson], workspaceDirectory) + artifactClient.uploadArtifact(DEPENDENCY_GRAPH_ARTIFACT, graphFiles, workspaceDirectory) } -function copyDependencyGraphToBuildRoot(buildRootDirectory: string): string { - const sourceFile = path.resolve( - buildRootDirectory, - 'build', - 'reports', - 'github-dependency-graph-plugin', - 'github-dependency-snapshot.json' - ) - - const destFile = path.resolve(buildRootDirectory, DEPENDENCY_GRAPH_FILE) - fs.copyFileSync(sourceFile, destFile) - return destFile -} - -export async function submitDependencyGraph(): Promise { +export async function downloadAndSubmitDependencyGraphs(): Promise { const workspaceDirectory = layout.workspaceDirectory() + submitDependencyGraphs(await retrieveDependencyGraphs(workspaceDirectory)) +} + +async function submitDependencyGraphs(dependencyGraphFiles: string[]): Promise { const octokit: Octokit = getOctokit() - for (const jsonFile of await retrieveDependencyGraphs(octokit, workspaceDirectory)) { + for (const jsonFile of dependencyGraphFiles) { const jsonContent = fs.readFileSync(jsonFile, 'utf8') const jsonObject = JSON.parse(jsonContent) @@ -69,34 +62,20 @@ export async function submitDependencyGraph(): Promise { const response = await octokit.request('POST /repos/{owner}/{repo}/dependency-graph/snapshots', jsonObject) const relativeJsonFile = getRelativePathFromWorkspace(jsonFile) - core.info(`Submitted ${relativeJsonFile}: ${JSON.stringify(response)}`) core.notice(`Submitted ${relativeJsonFile}: ${response.data.message}`) } } -async function findDependencyGraphFiles(dir: string): Promise { - const globber = await glob.create(`${dir}/**/${DEPENDENCY_GRAPH_FILE}`) - const graphFiles = globber.glob() - core.info(`Found graph files in ${dir}: ${graphFiles}`) - return graphFiles -} - -async function retrieveDependencyGraphs(octokit: Octokit, workspaceDirectory: string): Promise { +async function retrieveDependencyGraphs(workspaceDirectory: string): Promise { if (github.context.payload.workflow_run) { - return await retrieveDependencyGraphsForWorkflowRun( - github.context.payload.workflow_run.id, - octokit, - workspaceDirectory - ) + return await retrieveDependencyGraphsForWorkflowRun(github.context.payload.workflow_run.id, workspaceDirectory) } return retrieveDependencyGraphsForCurrentWorkflow(workspaceDirectory) } -async function retrieveDependencyGraphsForWorkflowRun( - runId: number, - octokit: Octokit, - workspaceDirectory: string -): Promise { +async function retrieveDependencyGraphsForWorkflowRun(runId: number, workspaceDirectory: string): Promise { + const octokit: Octokit = getOctokit() + // Find the workflow run artifacts named "dependency-graph" const artifacts = await octokit.rest.actions.listWorkflowRunArtifacts({ owner: github.context.repo.owner, @@ -139,6 +118,12 @@ async function retrieveDependencyGraphsForCurrentWorkflow(workspaceDirectory: st return await findDependencyGraphFiles(downloadPath) } +async function findDependencyGraphFiles(dir: string): Promise { + const globber = await glob.create(`${dir}/dependency-graph-reports/*.json`) + const graphFiles = globber.glob() + return graphFiles +} + function getOctokit(): Octokit { return new Octokit({ auth: getGithubToken() @@ -153,3 +138,26 @@ function getRelativePathFromWorkspace(file: string): string { const workspaceDirectory = layout.workspaceDirectory() return path.relative(workspaceDirectory, file) } + +export function getJobCorrelator(): string { + return constructJobCorrelator(github.context.workflow, github.context.job, params.getJobMatrix()) +} + +export function constructJobCorrelator(workflow: string, jobId: string, matrixJson: string): string { + const matrixString = describeMatrix(matrixJson) + const label = matrixString ? `${workflow}-${jobId}-${matrixString}` : `${workflow}-${jobId}` + return sanitize(label) +} + +function describeMatrix(matrixJson: string): string { + core.info(`Got matrix json: ${matrixJson}`) + const matrix = JSON.parse(matrixJson) + if (matrix) { + return Object.values(matrix).join('-') + } + return '' +} + +function sanitize(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, '').toLowerCase() +} diff --git a/src/input-params.ts b/src/input-params.ts index 7fb4e0c..26db6e5 100644 --- a/src/input-params.ts +++ b/src/input-params.ts @@ -51,7 +51,7 @@ export function getArguments(): string[] { } // Internal parameters -export function getJobContext(): string { +export function getJobMatrix(): string { return core.getInput('workflow-job-context') } @@ -63,6 +63,10 @@ export function isJobSummaryEnabled(): boolean { return getBooleanInput('generate-job-summary', true) } +export function isDependencyGraphEnabled(): boolean { + return getBooleanInput('generate-dependency-graph', true) +} + function getBooleanInput(paramName: string, paramDefault = false): boolean { const paramValue = core.getInput(paramName) switch (paramValue.toLowerCase().trim()) { diff --git a/src/resources/init-scripts/github-dependency-graph-gradle-plugin-0.0.3.jar b/src/resources/init-scripts/github-dependency-graph-gradle-plugin-0.0.3.jar index 1e079df..e668c92 100644 Binary files a/src/resources/init-scripts/github-dependency-graph-gradle-plugin-0.0.3.jar and b/src/resources/init-scripts/github-dependency-graph-gradle-plugin-0.0.3.jar differ diff --git a/src/resources/init-scripts/github-dependency-graph-gradle-plugin-apply.groovy b/src/resources/init-scripts/github-dependency-graph-gradle-plugin-apply.groovy new file mode 100644 index 0000000..8bfdbf4 --- /dev/null +++ b/src/resources/init-scripts/github-dependency-graph-gradle-plugin-apply.groovy @@ -0,0 +1,6 @@ +buildscript { + dependencies { + classpath files("github-dependency-graph-gradle-plugin-0.0.3.jar") + } +} +apply plugin: org.gradle.github.GitHubDependencyGraphPlugin diff --git a/src/resources/init-scripts/github-dependency-graph.init.gradle b/src/resources/init-scripts/github-dependency-graph.init.gradle index 50bcf8e..ec0c653 100644 --- a/src/resources/init-scripts/github-dependency-graph.init.gradle +++ b/src/resources/init-scripts/github-dependency-graph.init.gradle @@ -1,7 +1,17 @@ -// TODO:DAZ This should be conditionally applied, since the script may be present when not required. -initscript { - dependencies { - classpath files("github-dependency-graph-gradle-plugin-0.0.3.jar") - } +if (System.env.GITHUB_DEPENDENCY_GRAPH_ENABLED != "true") { + return } -apply plugin: org.gradle.github.GitHubDependencyGraphPlugin + +def reportDir = System.env.GITHUB_DEPENDENCY_GRAPH_REPORT_DIR +def jobCorrelator = System.env.GITHUB_DEPENDENCY_GRAPH_JOB_CORRELATOR +def reportFile = new File(reportDir, jobCorrelator + ".json") + +if (reportFile.exists()) { + println "::warning::No dependency report generated for step: report file for '${jobCorrelator}' created in earlier step. Each build invocation requires a unique job correlator: specify GITHUB_DEPENDENCY_GRAPH_JOB_CORRELATOR var for this step." + return +} + +println "Generating dependency graph for '${jobCorrelator}'" + +// TODO:DAZ This should be conditionally applied, since the script may be present when not required. +apply from: 'github-dependency-graph-gradle-plugin-apply.groovy' diff --git a/src/setup-gradle.ts b/src/setup-gradle.ts index a5f6a89..a7267b4 100644 --- a/src/setup-gradle.ts +++ b/src/setup-gradle.ts @@ -6,6 +6,7 @@ import * as os from 'os' import * as caches from './caches' import * as layout from './repository-layout' import * as params from './input-params' +import * as dependencyGraph from './dependency-graph' import {logJobSummary, writeJobSummary} from './job-summary' import {loadBuildResults} from './build-results' @@ -36,6 +37,10 @@ export async function setup(): Promise { await caches.restore(gradleUserHome, cacheListener) core.saveState(CACHE_LISTENER, cacheListener.stringify()) + + if (params.isDependencyGraphEnabled()) { + dependencyGraph.prepare() + } } export async function complete(): Promise { @@ -58,6 +63,10 @@ export async function complete(): Promise { } else { logJobSummary(buildResults, cacheListener) } + + if (params.isDependencyGraphEnabled()) { + dependencyGraph.uploadDependencyGraphs() + } } async function determineGradleUserHome(): Promise { diff --git a/test/jest/dependency-graph.test.ts b/test/jest/dependency-graph.test.ts new file mode 100644 index 0000000..a119603 --- /dev/null +++ b/test/jest/dependency-graph.test.ts @@ -0,0 +1,30 @@ +import * as dependencyGraph from '../../src/dependency-graph' + +describe('dependency-graph', () => { + describe('constructs job correlator', () => { + it('removes commas from workflow name', () => { + const id = dependencyGraph.constructJobCorrelator('Workflow, with,commas', 'jobid', '{}') + expect(id).toBe('workflowwithcommas-jobid') + }) + it('removes non word characters', () => { + const id = dependencyGraph.constructJobCorrelator('Workflow!_with()characters', 'job-*id', '{"foo": "bar!@#$%^&*("}') + expect(id).toBe('workflow_withcharacters-job-id-bar') + }) + it('without matrix', () => { + const id = dependencyGraph.constructJobCorrelator('workflow', 'jobid', 'null') + expect(id).toBe('workflow-jobid') + }) + it('with dashes in values', () => { + const id = dependencyGraph.constructJobCorrelator('workflow-name', 'job-id', '{"os": "ubuntu-latest"}') + expect(id).toBe('workflow-name-job-id-ubuntu-latest') + }) + it('with single matrix value', () => { + const id = dependencyGraph.constructJobCorrelator('workflow', 'jobid', '{"os": "windows"}') + expect(id).toBe('workflow-jobid-windows') + }) + it('with composite matrix value', () => { + const id = dependencyGraph.constructJobCorrelator('workflow', 'jobid', '{"os": "windows", "java-version": "21.1", "other": "Value, with COMMA"}') + expect(id).toBe('workflow-jobid-windows-211-valuewithcomma') + }) + }) +})