Automatic caching of dependencies

in a best effort manner by default
allowing to specify files to hash for computing the cache key
This commit is contained in:
Paul Merlin 2020-06-15 12:59:55 +02:00
parent fcc1683d01
commit 95e20daa83
15 changed files with 192 additions and 14 deletions

View file

@ -22,10 +22,10 @@ jobs:
with: with:
wrapper-directory: __tests__/data/basic wrapper-directory: __tests__/data/basic
build-root-directory: __tests__/data/basic build-root-directory: __tests__/data/basic
arguments: help arguments: test
- name: Test dist download - name: Test dist download
uses: ./ uses: ./
with: with:
gradle-version: 6.5 gradle-version: 6.5
build-root-directory: __tests__/data/basic build-root-directory: __tests__/data/basic
arguments: help arguments: test

View file

@ -21,10 +21,10 @@ jobs:
with: with:
wrapper-directory: __tests__/data/basic wrapper-directory: __tests__/data/basic
build-root-directory: __tests__/data/basic build-root-directory: __tests__/data/basic
arguments: help arguments: test
- name: Test dist download - name: Test dist download
uses: ./ uses: ./
with: with:
gradle-version: 6.5 gradle-version: 6.5
build-root-directory: __tests__/data/basic build-root-directory: __tests__/data/basic
arguments: help arguments: test

View file

@ -0,0 +1,33 @@
import * as cryptoUtils from '../src/crypto-utils'
import * as path from 'path'
describe('crypto-utils', () => {
describe('can hash', () => {
it('a directory', async () => {
const hash = await cryptoUtils.hashFiles(
path.resolve('__tests__/data/basic/gradle')
)
expect(hash).toBe(
'4ebb65b45e6f6796d5ec6ace96e9471cc6573d294c54f99c4920fe5328e75bab'
)
})
it('a directory with a glob', async () => {
const hash = await cryptoUtils.hashFiles(
path.resolve('__tests__/data/basic/'),
['gradle/**']
)
expect(hash).toBe(
'4ebb65b45e6f6796d5ec6ace96e9471cc6573d294c54f99c4920fe5328e75bab'
)
})
it('a directory with globs', async () => {
const hash = await cryptoUtils.hashFiles(
path.resolve('__tests__/data/basic/'),
['**/*.gradle', 'gradle/**']
)
expect(hash).toBe(
'2db1d5291774949ab89e18e9d82ee24748ca0f6cc78de69ea9104357c50ad4a5'
)
})
})
})

View file

@ -1,6 +1,11 @@
/* plugins {
* This file was generated by the Gradle 'init' task. id 'java'
* }
* This is a general purpose Gradle build.
* Learn how to create Gradle builds at https://guides.gradle.org/creating-new-gradle-builds repositories {
*/ mavenCentral()
}
dependencies {
testImplementation('junit:junit:4.12')
}

View file

@ -0,0 +1,10 @@
package basic;
import org.junit.Test;
public class BasicTest {
@Test
public void test() {
assert true;
}
}

View file

@ -20,6 +20,9 @@ inputs:
arguments: arguments:
description: Gradle command line arguments, see gradle --help description: Gradle command line arguments, see gradle --help
required: false required: false
dependencies-cache-key:
description: Globs of files to hash in the build root directory, separated by new lines, use best-effort if unset
required: false
outputs: outputs:
build-scan-url: build-scan-url:

2
dist/main/index.js vendored

File diff suppressed because one or more lines are too long

2
dist/post/index.js vendored

File diff suppressed because one or more lines are too long

View file

@ -24,11 +24,12 @@
"author": "Paul Merlin <paul@nosphere.org>", "author": "Paul Merlin <paul@nosphere.org>",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@actions/cache": "0.2.1",
"@actions/core": "1.2.4", "@actions/core": "1.2.4",
"@actions/exec": "1.0.4", "@actions/exec": "1.0.4",
"@actions/glob": "0.1.0",
"@actions/io": "1.0.2", "@actions/io": "1.0.2",
"@actions/tool-cache": "1.5.5", "@actions/tool-cache": "1.5.5",
"@actions/cache": "0.2.1",
"string-argv": "0.3.1", "string-argv": "0.3.1",
"typed-rest-client": "1.7.3", "typed-rest-client": "1.7.3",
"unzipper": "0.10.11" "unzipper": "0.10.11"

72
src/cache-dependencies.ts Normal file
View file

@ -0,0 +1,72 @@
import * as path from 'path'
import * as fs from 'fs'
import * as os from 'os'
import * as core from '@actions/core'
import * as cache from '@actions/cache'
import * as github from './github-utils'
import * as crypto from './crypto-utils'
const DEPENDENCIES_CACHE_PATH = 'DEPENDENCIES_CACHE_PATH'
const DEPENDENCIES_CACHE_KEY = 'DEPENDENCIES_CACHE_KEY'
const DEPENDENCIES_CACHE_RESULT = 'DEPENDENCIES_CACHE_RESULT'
export async function restoreCachedDependencies(
rootDir: string
): Promise<void> {
const cachePath = path.resolve(os.homedir(), '.gradle/caches/modules-2')
core.saveState(DEPENDENCIES_CACHE_PATH, cachePath)
const inputCacheKeyGlobs = github.inputArrayOrNull('dependencies-cache-key')
const cacheKeyGlobs = inputCacheKeyGlobs
? inputCacheKeyGlobs
: [
'**/*.gradle',
'**/*.gradle.kts',
'**/gradle.properties',
'gradle/**'
]
const hash = await crypto.hashFiles(rootDir, cacheKeyGlobs)
const cacheKeyPrefix = 'dependencies-'
const cacheKey = `${cacheKeyPrefix}${hash}`
core.saveState(DEPENDENCIES_CACHE_KEY, cacheKey)
const cacheResult = await cache.restoreCache([cachePath], cacheKey, [
cacheKeyPrefix
])
core.saveState(DEPENDENCIES_CACHE_RESULT, cacheResult)
}
export async function cacheDependencies(): Promise<void> {
const cachePath = core.getState(DEPENDENCIES_CACHE_PATH)
const cacheKey = core.getState(DEPENDENCIES_CACHE_KEY)
const cacheResult = core.getState(DEPENDENCIES_CACHE_RESULT)
if (!cachePath || !fs.existsSync(cachePath)) {
core.debug('No dependencies to cache.')
return
}
if (cacheResult && cacheKey === cacheResult) {
core.info(
`Dependencies cache hit occurred on the cache key ${cacheKey}, not saving cache.`
)
return
}
try {
await cache.saveCache([cachePath], cacheKey)
} catch (error) {
if (error.name === cache.ValidationError.name) {
throw error
} else if (error.name === cache.ReserveCacheError.name) {
core.info(error.message)
} else {
core.info(`[warning] ${error.message}`)
}
}
return
}

View file

@ -52,7 +52,7 @@ export async function cacheWrapperDist(): Promise<void> {
const cachePath = core.getState(WRAPPER_CACHE_PATH) const cachePath = core.getState(WRAPPER_CACHE_PATH)
const cacheResult = core.getState(WRAPPER_CACHE_RESULT) const cacheResult = core.getState(WRAPPER_CACHE_RESULT)
if (!cachePath) { if (!cachePath || !fs.existsSync(cachePath)) {
core.debug('No wrapper installation to cache.') core.debug('No wrapper installation to cache.')
return return
} }

40
src/crypto-utils.ts Normal file
View file

@ -0,0 +1,40 @@
import * as crypto from 'crypto'
import * as fs from 'fs'
import * as path from 'path'
import * as stream from 'stream'
import * as util from 'util'
import * as glob from '@actions/glob'
export async function hashFiles(
baseDir: string,
globs: string[] = ['**'],
followSymbolicLinks = false
): Promise<string | null> {
let hasMatch = false
const result = crypto.createHash('sha256')
for await (const globPattern of globs) {
const globMatch = `${baseDir}/${globPattern}`
const globber = await glob.create(globMatch, {followSymbolicLinks})
for await (const file of globber.globGenerator()) {
// console.log(file)
if (!file.startsWith(`${baseDir}${path.sep}`)) {
// console.log(`Ignore '${file}' since it is not under '${baseDir}'.`)
continue
}
if (fs.statSync(file).isDirectory()) {
// console.log(`Skip directory '${file}'.`)
continue
}
const hash = crypto.createHash('sha256')
const pipeline = util.promisify(stream.pipeline)
await pipeline(fs.createReadStream(file), hash)
result.write(hash.digest())
if (!hasMatch) {
hasMatch = true
}
}
}
result.end()
return hasMatch ? result.digest('hex') : null
}

View file

@ -1,10 +1,13 @@
import * as exec from '@actions/exec' import * as exec from '@actions/exec'
import * as cacheDependencies from './cache-dependencies'
export async function execute( export async function execute(
executable: string, executable: string,
root: string, root: string,
argv: string[] argv: string[]
): Promise<BuildResult> { ): Promise<BuildResult> {
await cacheDependencies.restoreCachedDependencies(root)
let publishing = false let publishing = false
let buildScanUrl: string | undefined let buildScanUrl: string | undefined

View file

@ -7,3 +7,12 @@ export function inputOrNull(name: string): string | null {
} }
return inputString return inputString
} }
export function inputArrayOrNull(name: string): string[] | null {
const string = inputOrNull(name)
if (!string) return null
return string
.split('\n')
.map(s => s.trim())
.filter(s => s !== '')
}

View file

@ -1,8 +1,10 @@
import * as cacheWrapper from './cache-wrapper' import * as cacheWrapper from './cache-wrapper'
import * as cacheDependencies from './cache-dependencies'
// Invoked by GitHub Actions // Invoked by GitHub Actions
export async function run(): Promise<void> { export async function run(): Promise<void> {
await cacheWrapper.cacheWrapperDist() await cacheWrapper.cacheWrapperDist()
await cacheDependencies.cacheDependencies()
} }
run() run()