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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Rename `audit-fix` command to `fix-vulnerabilities`
- `fix-vulnerabilities` also runs `dotnet package update --vulnerable` for NuGet repositories

## [5.1.0] - 2026-08-06

### Added
Expand Down
22 changes: 12 additions & 10 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import startProductRelease from './startProductRelease.js'
import promptForReleaseName from './promptForReleaseName.js'
import getRepositoryPlugin from './repositories-plugins/plugins-factory.js'
import startUpdateDependents from './startUpdateDependents.js'
import startAuditFix from './startAuditFix.js'
import startFixVulnerabilities from './startFixVulnerabilities.js'
import waitForNpmPackageVersion from './waitForNpmPackageVersion.js'
import { readPackageUp } from 'read-package-up'

Expand All @@ -35,12 +35,14 @@ ${chalk.bold('Examples')}
oneblink-release product
oneblink-release product --name="Inappropriate Release Name"

${chalk.bold.blue('oneblink-release audit-fix [--force] [--ticket]')}
${chalk.bold.blue('oneblink-release fix-vulnerabilities [--force] [--ticket]')}

${chalk.grey(
`Run "npm audit fix --package-lock-only" across each Product repository. Where
package-lock.json changes, create a shared branch, commit, push, and create pull
requests when GITHUB_OAUTH_TOKEN is set (otherwise print create-PR URLs).`,
`Run "npm audit fix --package-lock-only" for NPM repositories and "dotnet
package update --vulnerable" for NuGet repositories across each Product
repository. Where dependency files change, create a shared branch, commit,
push, and create pull requests when GITHUB_OAUTH_TOKEN is set (otherwise print
create-PR URLs).`,
)}

--force ......... Skip the ticket prompt. Requires --ticket.
Expand All @@ -51,9 +53,9 @@ requests when GITHUB_OAUTH_TOKEN is set (otherwise print create-PR URLs).`,

${chalk.bold('Examples')}

oneblink-release audit-fix
oneblink-release audit-fix --ticket ON-4323
oneblink-release audit-fix --force --ticket ON-4323
oneblink-release fix-vulnerabilities
oneblink-release fix-vulnerabilities --ticket ON-4323
oneblink-release fix-vulnerabilities --force --ticket ON-4323

${chalk.bold.blue(
'oneblink-release repository [next-version] [--no-git] [--name] [--no-name] [--cwd path] [--update-dependents] [--force] [--force-update-dependency] [--force-publish-intermediate-dependency] [--ticket]',
Expand Down Expand Up @@ -236,8 +238,8 @@ async function run(): Promise<void> {
})
break
}
case 'audit-fix': {
await startAuditFix({
case 'fix-vulnerabilities': {
await startFixVulnerabilities({
ticket: cli.flags.ticket,
force: cli.flags.force,
})
Expand Down
56 changes: 55 additions & 1 deletion src/repositories-plugins/NpmPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import fs from 'fs/promises'
import path from 'path'
import { readPackageUp } from 'read-package-up'
import { main as packageDiffSummary } from '../package-diff-summary/index.js'
import { SemVer } from 'semver'
import executeCommand from '../executeCommand.js'
import { RepositoryPlugin } from './RepositoryPlugin.js'
import {
hasFileChanges,
runCommandAllowingRemainingVulnerabilities,
} from './fix-vulnerabilities-helpers.js'
import {
FixVulnerabilitiesResult,
RepositoryPlugin,
} from './RepositoryPlugin.js'

export default class NpmPlugin implements RepositoryPlugin {
displayType = 'NPM'
Expand Down Expand Up @@ -59,4 +68,49 @@ export default class NpmPlugin implements RepositoryPlugin {
throw error
}
}

async fixVulnerabilities({
ticket,
repositoryName,
}: {
ticket: string
repositoryName: string
}): Promise<FixVulnerabilitiesResult | undefined> {
const packageLockPath = path.join(this.cwd, 'package-lock.json')
try {
await fs.stat(packageLockPath)
} catch {
console.log(
`Skipping "${repositoryName}" as it does not contain a package-lock.json file.`,
)
return
}

await runCommandAllowingRemainingVulnerabilities({
command: 'npm',
args: ['audit', 'fix', '--package-lock-only', '--audit-level=none'],
cwd: this.cwd,
hasChanges: () => hasFileChanges(this.cwd, 'package-lock.json'),
})

if (!(await hasFileChanges(this.cwd, 'package-lock.json'))) {
console.log(
`Skipping "${repositoryName}" as npm audit fix did not change package-lock.json.`,
)
return
}

const filesToStage = ['package-lock.json']

// npm audit fix can also update package.json when dependency ranges change
if (await hasFileChanges(this.cwd, 'package.json')) {
filesToStage.push('package.json')
}

return {
filesToStage,
commitMessage: `${ticket} # npm audit fix`,
pullRequestBody: 'Automated `npm audit fix`.',
}
}
}
45 changes: 44 additions & 1 deletion src/repositories-plugins/NugetPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { readFile, writeFile } from 'fs/promises'
import path from 'path'
import { SemVer } from 'semver'
import getPreRelease from '../getPreRelease.js'
import { RepositoryPlugin } from './RepositoryPlugin.js'
import {
getChangedFilesMatching,
runCommandAllowingRemainingVulnerabilities,
} from './fix-vulnerabilities-helpers.js'
import {
FixVulnerabilitiesResult,
RepositoryPlugin,
} from './RepositoryPlugin.js'

export default class NugetPlugin implements RepositoryPlugin {
isDeploymentRequired = false
Expand Down Expand Up @@ -49,4 +56,40 @@ export default class NugetPlugin implements RepositoryPlugin {
)
await writeFile(projectFile, newFileContents, 'utf-8')
}

async fixVulnerabilities({
ticket,
repositoryName,
}: {
ticket: string
repositoryName: string
}): Promise<FixVulnerabilitiesResult | undefined> {
await runCommandAllowingRemainingVulnerabilities({
command: 'dotnet',
args: [
'package',
'update',
'--vulnerable',
'--project',
this.relativeProjectFile,
],
cwd: this.cwd,
hasChanges: async () =>
(await getChangedFilesMatching(this.cwd, '*.csproj')).length > 0,
})

const filesToStage = await getChangedFilesMatching(this.cwd, '*.csproj')
if (!filesToStage.length) {
console.log(
`Skipping "${repositoryName}" as dotnet package update --vulnerable did not change csproj files.`,
)
return
}

return {
filesToStage,
commitMessage: `${ticket} # dotnet package update --vulnerable`,
pullRequestBody: 'Automated `dotnet package update --vulnerable`.',
}
}
}
11 changes: 11 additions & 0 deletions src/repositories-plugins/RepositoryPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { SemVer } from 'semver'

export type FixVulnerabilitiesResult = {
filesToStage: string[]
commitMessage: string
pullRequestBody: string
}

export interface RepositoryPlugin {
isDeploymentRequired: boolean
supportsDependencyUpdates: boolean
Expand All @@ -20,4 +26,9 @@ export interface RepositoryPlugin {
| { result: 'ENTRIES'; entries: string | undefined }
| { result: 'WARNING'; message: string }
>

fixVulnerabilities?: (options: {
ticket: string
repositoryName: string
}) => Promise<FixVulnerabilitiesResult | undefined>
}
82 changes: 82 additions & 0 deletions src/repositories-plugins/fix-vulnerabilities-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { execa } from 'execa'
import wrapWithLoading from '../wrapWithLoading.js'

export async function runCommandAllowingRemainingVulnerabilities({
command,
args,
cwd,
hasChanges,
}: {
command: string
args: string[]
cwd: string
hasChanges: () => Promise<boolean>
}) {
const log = `"${command} ${args.join(' ')}"`
return await wrapWithLoading(
{
startText: `Running ${log}`,
failText: `Failed to run ${log}`,
},
async (spinner) => {
// Both npm audit fix and dotnet package update --vulnerable can exit
// non-zero when vulnerabilities remain after applying available fixes
const result = await execa(command, args, {
cwd,
reject: false,
})

if (result.exitCode !== 0 && !(await hasChanges())) {
spinner.fail(`Failed to run ${log}`)
throw new Error(
result.stderr ||
result.stdout ||
`${command} ${args.join(' ')} failed with exit code ${result.exitCode}`,
)
}

spinner.succeed(`Ran ${log}`)
return result
},
)
}

export async function hasFileChanges(
cwd: string,
fileName: string,
): Promise<boolean> {
const { stdout } = await execa(
'git',
['status', '--porcelain', '--', fileName],
{
cwd,
},
)
return Boolean(stdout.trim())
}

export async function getChangedFilesMatching(
cwd: string,
...pathspecs: string[]
): Promise<string[]> {
const { stdout } = await execa(
'git',
['status', '--porcelain', '--', ...pathspecs],
{
cwd,
},
)

return stdout
.split('\n')
.filter(Boolean)
.map((line) => {
// Porcelain status is always 2 characters followed by a space
const pathPart = line.slice(3)
const renameSeparator = ' -> '
const renameIndex = pathPart.indexOf(renameSeparator)
return renameIndex === -1
? pathPart.trim()
: pathPart.slice(renameIndex + renameSeparator.length).trim()
})
}
Loading
Loading