Configure Gradle User Home for dependency-graph

Instead of requiring an action step to generate the graph, configure Gradle User Home
so that subsequent Gradle invocations can generate a graph. Any generated graph files
are uploaded as artifacts on job completion.

- Construct job.correlator from workflow/job/matrix
- Export job.correlator as an environment var
- Upload artifacts at job completion in post-action step
- Specify the location of dependency graph report
- Only apply dependency graph init script when explicitly enabled
This commit is contained in:
daz 2023-07-01 19:00:28 -06:00
parent a6ad1901be
commit 4c9c435d2f
No known key found for this signature in database
11 changed files with 153 additions and 65 deletions

View File

@ -58,6 +58,11 @@ inputs:
required: false required: false
default: true 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 # EXPERIMENTAL & INTERNAL ACTION INPUTS
# The following action properties allow fine-grained tweaking of the action caching behaviour. # 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`. # These properties are experimental and not (yet) designed for production use, and may change without notice in a subsequent release of `gradle-build-action`.

View File

@ -175,7 +175,8 @@ export class GradleStateCache {
const initScriptFilenames = [ const initScriptFilenames = [
'build-result-capture.init.gradle', 'build-result-capture.init.gradle',
'build-result-capture-service.plugin.groovy', '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) { for (const initScriptFilename of initScriptFilenames) {
const initScriptContent = this.readInitScriptAsString(initScriptFilename) const initScriptContent = this.readInitScriptAsString(initScriptFilename)

View File

@ -125,10 +125,25 @@ function getCacheKeyJobInstance(): string {
// By default, we hash the full `matrix` data for the run, to uniquely identify this job invocation // 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. // 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]) 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 { function getCacheKeyJobExecution(): string {
// Used to associate a cache key with a particular execution (default is bound to the git commit sha) // 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 return process.env[CACHE_KEY_JOB_EXECUTION_VAR] || github.context.sha

View File

@ -4,7 +4,7 @@ import * as dependencyGraph from './dependency-graph'
export async function run(): Promise<void> { export async function run(): Promise<void> {
try { try {
// Retrieve the dependency graph artifact and submit via Dependency Submission API // Retrieve the dependency graph artifact and submit via Dependency Submission API
await dependencyGraph.submitDependencyGraph() await dependencyGraph.downloadAndSubmitDependencyGraphs()
} catch (error) { } catch (error) {
core.setFailed(String(error)) core.setFailed(String(error))
if (error instanceof Error && error.stack) { if (error instanceof Error && error.stack) {

View File

@ -10,57 +10,50 @@ import fs from 'fs'
import * as execution from './execution' import * as execution from './execution'
import * as layout from './repository-layout' import * as layout from './repository-layout'
import * as params from './input-params'
const DEPENDENCY_GRAPH_ARTIFACT = 'dependency-graph' 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<void> { export async function generateDependencyGraph(executable: string | undefined): Promise<void> {
const workspaceDirectory = layout.workspaceDirectory()
const buildRootDirectory = layout.buildRootDirectory() const buildRootDirectory = layout.buildRootDirectory()
const buildPath = getRelativePathFromWorkspace(buildRootDirectory)
const initScript = path.resolve( const args = [':GitHubDependencyGraphPlugin_generateDependencyGraph']
__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'
]
await execution.executeGradleBuild(executable, buildRootDirectory, args) await execution.executeGradleBuild(executable, buildRootDirectory, args)
const dependencyGraphJson = copyDependencyGraphToBuildRoot(buildRootDirectory) }
export async function uploadDependencyGraphs(): Promise<void> {
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() const artifactClient = artifact.create()
artifactClient.uploadArtifact(DEPENDENCY_GRAPH_ARTIFACT, [dependencyGraphJson], workspaceDirectory) artifactClient.uploadArtifact(DEPENDENCY_GRAPH_ARTIFACT, graphFiles, workspaceDirectory)
} }
function copyDependencyGraphToBuildRoot(buildRootDirectory: string): string { export async function downloadAndSubmitDependencyGraphs(): Promise<void> {
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<void> {
const workspaceDirectory = layout.workspaceDirectory() const workspaceDirectory = layout.workspaceDirectory()
submitDependencyGraphs(await retrieveDependencyGraphs(workspaceDirectory))
}
async function submitDependencyGraphs(dependencyGraphFiles: string[]): Promise<void> {
const octokit: Octokit = getOctokit() const octokit: Octokit = getOctokit()
for (const jsonFile of await retrieveDependencyGraphs(octokit, workspaceDirectory)) { for (const jsonFile of dependencyGraphFiles) {
const jsonContent = fs.readFileSync(jsonFile, 'utf8') const jsonContent = fs.readFileSync(jsonFile, 'utf8')
const jsonObject = JSON.parse(jsonContent) const jsonObject = JSON.parse(jsonContent)
@ -69,34 +62,20 @@ export async function submitDependencyGraph(): Promise<void> {
const response = await octokit.request('POST /repos/{owner}/{repo}/dependency-graph/snapshots', jsonObject) const response = await octokit.request('POST /repos/{owner}/{repo}/dependency-graph/snapshots', jsonObject)
const relativeJsonFile = getRelativePathFromWorkspace(jsonFile) const relativeJsonFile = getRelativePathFromWorkspace(jsonFile)
core.info(`Submitted ${relativeJsonFile}: ${JSON.stringify(response)}`)
core.notice(`Submitted ${relativeJsonFile}: ${response.data.message}`) core.notice(`Submitted ${relativeJsonFile}: ${response.data.message}`)
} }
} }
async function findDependencyGraphFiles(dir: string): Promise<string[]> { async function retrieveDependencyGraphs(workspaceDirectory: string): Promise<string[]> {
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<string[]> {
if (github.context.payload.workflow_run) { if (github.context.payload.workflow_run) {
return await retrieveDependencyGraphsForWorkflowRun( return await retrieveDependencyGraphsForWorkflowRun(github.context.payload.workflow_run.id, workspaceDirectory)
github.context.payload.workflow_run.id,
octokit,
workspaceDirectory
)
} }
return retrieveDependencyGraphsForCurrentWorkflow(workspaceDirectory) return retrieveDependencyGraphsForCurrentWorkflow(workspaceDirectory)
} }
async function retrieveDependencyGraphsForWorkflowRun( async function retrieveDependencyGraphsForWorkflowRun(runId: number, workspaceDirectory: string): Promise<string[]> {
runId: number, const octokit: Octokit = getOctokit()
octokit: Octokit,
workspaceDirectory: string
): Promise<string[]> {
// Find the workflow run artifacts named "dependency-graph" // Find the workflow run artifacts named "dependency-graph"
const artifacts = await octokit.rest.actions.listWorkflowRunArtifacts({ const artifacts = await octokit.rest.actions.listWorkflowRunArtifacts({
owner: github.context.repo.owner, owner: github.context.repo.owner,
@ -139,6 +118,12 @@ async function retrieveDependencyGraphsForCurrentWorkflow(workspaceDirectory: st
return await findDependencyGraphFiles(downloadPath) return await findDependencyGraphFiles(downloadPath)
} }
async function findDependencyGraphFiles(dir: string): Promise<string[]> {
const globber = await glob.create(`${dir}/dependency-graph-reports/*.json`)
const graphFiles = globber.glob()
return graphFiles
}
function getOctokit(): Octokit { function getOctokit(): Octokit {
return new Octokit({ return new Octokit({
auth: getGithubToken() auth: getGithubToken()
@ -153,3 +138,26 @@ function getRelativePathFromWorkspace(file: string): string {
const workspaceDirectory = layout.workspaceDirectory() const workspaceDirectory = layout.workspaceDirectory()
return path.relative(workspaceDirectory, file) 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()
}

View File

@ -51,7 +51,7 @@ export function getArguments(): string[] {
} }
// Internal parameters // Internal parameters
export function getJobContext(): string { export function getJobMatrix(): string {
return core.getInput('workflow-job-context') return core.getInput('workflow-job-context')
} }
@ -63,6 +63,10 @@ export function isJobSummaryEnabled(): boolean {
return getBooleanInput('generate-job-summary', true) return getBooleanInput('generate-job-summary', true)
} }
export function isDependencyGraphEnabled(): boolean {
return getBooleanInput('generate-dependency-graph', true)
}
function getBooleanInput(paramName: string, paramDefault = false): boolean { function getBooleanInput(paramName: string, paramDefault = false): boolean {
const paramValue = core.getInput(paramName) const paramValue = core.getInput(paramName)
switch (paramValue.toLowerCase().trim()) { switch (paramValue.toLowerCase().trim()) {

View File

@ -0,0 +1,6 @@
buildscript {
dependencies {
classpath files("github-dependency-graph-gradle-plugin-0.0.3.jar")
}
}
apply plugin: org.gradle.github.GitHubDependencyGraphPlugin

View File

@ -1,7 +1,17 @@
// TODO:DAZ This should be conditionally applied, since the script may be present when not required. if (System.env.GITHUB_DEPENDENCY_GRAPH_ENABLED != "true") {
initscript { return
dependencies {
classpath files("github-dependency-graph-gradle-plugin-0.0.3.jar")
}
} }
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'

View File

@ -6,6 +6,7 @@ import * as os from 'os'
import * as caches from './caches' import * as caches from './caches'
import * as layout from './repository-layout' import * as layout from './repository-layout'
import * as params from './input-params' import * as params from './input-params'
import * as dependencyGraph from './dependency-graph'
import {logJobSummary, writeJobSummary} from './job-summary' import {logJobSummary, writeJobSummary} from './job-summary'
import {loadBuildResults} from './build-results' import {loadBuildResults} from './build-results'
@ -36,6 +37,10 @@ export async function setup(): Promise<void> {
await caches.restore(gradleUserHome, cacheListener) await caches.restore(gradleUserHome, cacheListener)
core.saveState(CACHE_LISTENER, cacheListener.stringify()) core.saveState(CACHE_LISTENER, cacheListener.stringify())
if (params.isDependencyGraphEnabled()) {
dependencyGraph.prepare()
}
} }
export async function complete(): Promise<void> { export async function complete(): Promise<void> {
@ -58,6 +63,10 @@ export async function complete(): Promise<void> {
} else { } else {
logJobSummary(buildResults, cacheListener) logJobSummary(buildResults, cacheListener)
} }
if (params.isDependencyGraphEnabled()) {
dependencyGraph.uploadDependencyGraphs()
}
} }
async function determineGradleUserHome(): Promise<string> { async function determineGradleUserHome(): Promise<string> {

View File

@ -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')
})
})
})