@lavamoat/git-safe-dependencies
Version:
Opinionated dependency linter for your git/github dependencies
128 lines (114 loc) • 3.45 kB
JavaScript
const fs = require('node:fs')
const path = require('node:path')
const { globSync } = require('glob')
const yaml = require('js-yaml')
const { belongs, tag2commit } = require('./validate-git')
const { isCommitHash } = require('./isgit')
const { checksum } = require('./ignore')
const defaultDir = path.join(process.cwd(), '.github')
/**
* @typedef WorkflowErrorData
* @property {string} message
* @property {string[]} files
* @property {string} action
* @property {string} id
*/
/**
* @param {Partial<WorkflowErrorData>[]} errors
* @returns {WorkflowErrorData[]}
*/
const sortErrors = (errors) => {
errors.forEach((result) => {
result.id = `${result.action}:${checksum((result.files ?? []).join() + result.message)}`
})
return /** @type {WorkflowErrorData[]} */ (errors).sort((a, b) =>
a.id.localeCompare(b.id)
)
}
/**
* @param {string} [workflowsDir]
* @returns {Promise<{
* errors: WorkflowErrorData[]
* info: string[]
* }>}
*/
exports.validateWorkflows = async function (workflowsDir = defaultDir) {
const actions = new Map()
const info = []
/** @type {Partial<WorkflowErrorData>[]} */
const errors = []
// Find all workflow files in the .github directory
const files = globSync(`${workflowsDir}/**/*.yml`)
files.forEach((file) => {
const fileContent = fs.readFileSync(file, 'utf8')
/** @type {any} */
let workflow
try {
workflow = yaml.load(fileContent)
} catch (e) {
info.push(`Warning: Error loading YAML file ${file}:`, e)
return
}
if (workflow.jobs) {
Object.values(workflow.jobs).forEach((job) => {
if (job.steps) {
job.steps.forEach((step) => {
if (step.uses) {
if (actions.has(step.uses)) {
const ac = actions.get(step.uses)
ac.add(file)
} else {
actions.set(step.uses, new Set([file]))
}
}
})
}
})
}
})
info.push(
`Found ${actions.size} actions in ${files.length} workflow files in ${workflowsDir}`
)
for (const [actionSpecifier, files] of actions.entries()) {
const [action, version] = actionSpecifier.split('@')
const [owner, repo] = action.split('/')
// check whether version is a commit hash
if (!isCommitHash(version)) {
let tagConversionTip = ''
try {
const info = await tag2commit(owner, repo, version)
if (info.commit) {
if (info.isTag) {
tagConversionTip = `use: ${action}@${info.commit} #${version}`
} else {
tagConversionTip = `the commit hash under ${version} is ${info.commit} but doublecheck if that's what you wanted`
}
}
} catch (e) {
info.push(`Warning: Error looking up ${actionSpecifier}`, e)
}
errors.push({
message: `Action specifiers must use a commit hash.
expected: ${action}@COMMITHASH_40_CHARACTERS_LONG
actual: ${actionSpecifier}
${tagConversionTip}
`,
files: [...files],
action,
})
continue
}
if (!(await belongs(owner, repo, version))) {
errors.push({
message: `The commit hash in ${actionSpecifier} does not seem to belong to the repository ${owner}/${repo}.
`,
files: [...files],
action,
})
}
}
return {
errors: sortErrors(errors),
info,
}
}