fix: make GitHub Actions checks opt-in for outdated and update (#13259)

Checking GitHub Actions for updates spawns `git ls-remote` against every
repository referenced by the workflow files. That reaches the network in a
way pnpm cannot validate up front, and it fails wherever GitHub is not
reachable the way pnpm assumes: a GitHub Enterprise Server hosting the
actions (pnpm/pnpm#13220), a container with a custom certificate authority
(pnpm/pnpm#13254), an offline or proxied CI network.

Both reports came within days of the feature shipping, and neither is
fixable from pnpm's side, so the check no longer runs unless it is asked
for. `pnpm outdated` and `pnpm update` — interactive or not, recursive or
not — now read workflow files only with `--include-github-actions` or with
`update.githubActions` set to `true`.

`update.githubActions: false` keeps working; it is now equivalent to
leaving the setting unset, so anyone who added it to silence the warnings
needs no change.

The opt-in rule lives in one place per stack (`shouldCheckGitHubActions`
and `github_actions::opted_in`) and is shared by every call site, so the
commands cannot drift apart again. `pnpm outdated` gained the
`--include-github-actions` flag it was missing.

Closes pnpm/pnpm#13254
This commit is contained in:
Zoltan Kochan authored and GitHub committed 2026-07-24 01:06:35 +02:00
1 parent f4ebdb9de8
commit 83d04aa232
14 files changed
+251 -40

No files matched your search

@@ -0,0 +1,12 @@
---
"@pnpm/deps.github-actions": patch
"@pnpm/deps.inspection.commands": patch
"@pnpm/installing.commands": patch
"@pnpm/types": patch
"pacquet": patch
"pnpm": patch
---
Checking GitHub Actions dependencies for updates is now opt-in for every command. Neither `pnpm outdated` nor `pnpm update` reads the workflow files unless `--include-github-actions` is passed or `update.githubActions` is set to `true` in `pnpm-workspace.yaml`. Reading them runs `git ls-remote` against every referenced repository, which fails in environments where GitHub is not reachable the way pnpm assumes (a GitHub Enterprise Server, a custom certificate authority, or an offline network) [#13254](https://github.com/pnpm/pnpm/issues/13254).
`pnpm outdated` accepts the `--include-github-actions` option too.
+6 -2
View File
@@ -384,6 +384,10 @@ pub struct OutdatedArgs {
#[clap(long, value_enum)]
pub sort_by: Option<SortBy>,
/// Also check GitHub Actions dependencies in workflow and action files.
#[clap(long = "include-github-actions")]
pub include_github_actions: bool,
/// Check globally installed packages.
#[clap(short = 'g', long)]
pub global: bool,
@@ -485,7 +489,7 @@ impl OutdatedArgs {
Vec::new()
};
if include.contains(&DependencyGroup::Dev)
&& config.update_config.github_actions != Some(false)
&& github_actions::opted_in(self.include_github_actions, config)
{
let actions = github_actions::find_outdated::<Reporter>(
root,
@@ -622,7 +626,7 @@ impl OutdatedArgs {
}
if include.contains(&DependencyGroup::Dev)
&& config.update_config.github_actions != Some(false)
&& github_actions::opted_in(self.include_github_actions, config)
{
let action_matcher = github_actions::selector_matcher(&self.packages);
let actions = github_actions::find_outdated::<Reporter>(
+1 -3
View File
@@ -395,9 +395,7 @@ impl UpdateArgs {
include_direct.contains(&DependencyGroup::Dev)
&& !self.no_save
&& !self.lockfile_only
&& ((self.interactive && config.update_config.github_actions != Some(false))
|| self.include_github_actions
|| config.update_config.github_actions == Some(true))
&& github_actions::opted_in(self.include_github_actions, config)
}
}
+9 -4
View File
@@ -56,25 +56,30 @@ fn prod_with_no_optional_drops_optional() {
}
#[test]
fn github_actions_are_opt_in_except_for_interactive_updates() {
fn github_actions_are_opt_in_for_every_update() {
let include_direct = vec![DependencyGroup::Prod, DependencyGroup::Dev];
let mut config = Config::new();
assert!(!update_args(&[]).should_update_github_actions(&config, &include_direct));
assert!(
!update_args(&["--interactive"]).should_update_github_actions(&config, &include_direct),
);
assert!(
update_args(&["--include-github-actions"])
.should_update_github_actions(&config, &include_direct),
);
assert!(update_args(&["--interactive"]).should_update_github_actions(&config, &include_direct));
assert!(
update_args(&["--interactive", "--include-github-actions"])
.should_update_github_actions(&config, &include_direct),
);
config.update_config.github_actions = Some(true);
assert!(update_args(&[]).should_update_github_actions(&config, &include_direct));
assert!(update_args(&["--interactive"]).should_update_github_actions(&config, &include_direct));
assert!(
!update_args(&["--prod"]).should_update_github_actions(&config, &[DependencyGroup::Prod],),
);
// An explicit `false` opts interactive updates out of GitHub Actions,
// but never overrides the explicit `--include-github-actions` flag.
config.update_config.github_actions = Some(false);
assert!(
!update_args(&["--interactive"]).should_update_github_actions(&config, &include_direct),
+12 -1
View File
@@ -1,6 +1,9 @@
use futures_util::{StreamExt, stream};
use node_semver::{Range as SemverRange, Version};
use pacquet_config::matcher::{Matcher, create_matcher};
use pacquet_config::{
Config,
matcher::{Matcher, create_matcher},
};
use pacquet_network::redact_and_sanitize;
use pacquet_reporter::{GlobalLog, LogEvent, LogLevel, Reporter};
use pacquet_resolving_git_resolver::{GitCommandRunner, RealGitRunner, get_repo_refs};
@@ -73,6 +76,14 @@ struct PlannedUpdate {
const GIT_CONCURRENCY: usize = 8;
/// GitHub Actions dependencies are opt-in. Reading them means running `git
/// ls-remote` against every referenced repository, so `pnpm outdated` and
/// `pnpm update` only look at workflow files when asked to, either with
/// `--include-github-actions` or with `update.githubActions: true`.
pub fn opted_in(include_github_actions: bool, config: &Config) -> bool {
include_github_actions || config.update_config.github_actions == Some(true)
}
pub async fn find_outdated<Reporter: self::Reporter>(
root: &Path,
compatible: bool,
+16 -1
View File
@@ -1,9 +1,10 @@
use super::{
ActionReference, RepoVersion, find_current, find_outdated_with_runner, is_selector,
normalize_selector, render_target_ref, render_target_value,
normalize_selector, opted_in, render_target_ref, render_target_value,
repo_versions as versions_from_refs, selector_matcher, split_uses_value, update_with_runner,
};
use node_semver::Version;
use pacquet_config::Config;
use pacquet_reporter::{GlobalLog, LogEvent, LogLevel, Reporter, SilentReporter};
use pacquet_resolving_git_resolver::{GitCommandRunner, GitRunError};
use std::{collections::HashMap, fs, future::Future, path::PathBuf, pin::Pin, sync::Mutex};
@@ -77,6 +78,20 @@ impl GitCommandRunner for PreOneGitRunner {
}
}
#[test]
fn workflow_files_are_read_only_when_opted_in() {
let mut config = Config::new();
assert!(!opted_in(false, &config));
assert!(opted_in(true, &config));
config.update_config.github_actions = Some(false);
assert!(!opted_in(false, &config));
assert!(opted_in(true, &config));
config.update_config.github_actions = Some(true);
assert!(opted_in(false, &config));
}
#[test]
fn distinguishes_action_selectors_from_package_selectors() {
assert_eq!(
+8 -8
View File
@@ -537,10 +537,10 @@ pub struct UpdateSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub changeset: Option<bool>,
/// Whether `pnpm update` should also update GitHub Actions
/// dependencies. When explicitly set to `false`, `pnpm outdated`
/// and the interactive `pnpm update` skip GitHub Actions
/// dependencies as well.
/// Whether `pnpm outdated` and `pnpm update` should also look at
/// the GitHub Actions referenced by the workflow files. Opt-in:
/// neither command reads them unless this is set to `true` or
/// `--include-github-actions` is passed.
#[serde(skip_serializing_if = "Option::is_none")]
pub github_actions: Option<bool>,
@@ -569,10 +569,10 @@ pub struct UpdateConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore_dependencies: Option<Vec<String>>,
/// Whether `pnpm update` should also update GitHub Actions
/// dependencies. When explicitly set to `false`, `pnpm outdated`
/// and the interactive `pnpm update` skip GitHub Actions
/// dependencies as well.
/// Whether `pnpm outdated` and `pnpm update` should also look at
/// the GitHub Actions referenced by the workflow files. Opt-in:
/// neither command reads them unless this is set to `true` or
/// `--include-github-actions` is passed.
#[serde(skip_serializing_if = "Option::is_none")]
pub github_actions: Option<bool>,
+3 -3
View File
@@ -208,9 +208,9 @@ export interface UpdateSettings {
*/
changeset?: boolean
/**
* Whether `pnpm update` should also update GitHub Actions dependencies.
* When explicitly set to `false`, `pnpm outdated` and the interactive
* `pnpm update` skip GitHub Actions dependencies as well.
* Whether `pnpm outdated` and `pnpm update` should also look at the GitHub
* Actions referenced by the workflow files. Opt-in: neither command reads
* them unless this is set to `true` or `--include-github-actions` is passed.
*/
githubActions?: boolean
/**
+15
View File
@@ -72,6 +72,21 @@ interface PlannedUpdate {
const SHA_PATTERN = /^[0-9a-f]{40}$/
const limitRepoReads = pLimit(8)
export interface GitHubActionsOptInOptions {
includeGithubActions?: boolean
updateConfig?: { githubActions?: boolean }
}
/**
* GitHub Actions dependencies are opt-in. Reading them means running
* `git ls-remote` against every referenced repository, so `pnpm outdated` and
* `pnpm update` only look at workflow files when asked to, either with
* `--include-github-actions` or with `update.githubActions: true`.
*/
export function shouldCheckGitHubActions (opts: GitHubActionsOptInOptions): boolean {
return opts.includeGithubActions === true || opts.updateConfig?.githubActions === true
}
export function isGitHubActionSelector (selector: string): boolean {
const pattern = selector.startsWith('!') ? selector.slice(1) : selector
return !pattern.startsWith('@') && pattern.includes('/')
@@ -11,7 +11,7 @@ import {
import { colorizeSemverDiff } from '@pnpm/colorize-semver-diff'
import { createMatcher } from '@pnpm/config.matcher'
import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader'
import { findOutdatedGitHubActions, isGitHubActionSelector, normalizeGitHubActionSelector } from '@pnpm/deps.github-actions'
import { findOutdatedGitHubActions, isGitHubActionSelector, normalizeGitHubActionSelector, shouldCheckGitHubActions } from '@pnpm/deps.github-actions'
import {
outdatedDepsOfProjects,
type OutdatedPackage,
@@ -51,6 +51,7 @@ export function rcOptionsTypes (): Record<string, unknown> {
export const cliOptionsTypes = (): Record<string, unknown> => ({
...rcOptionsTypes(),
'include-github-actions': Boolean,
recursive: Boolean,
})
@@ -66,7 +67,7 @@ export const commandNames = ['outdated']
export function help (): string {
return renderHelp({
description: `Check for outdated package and GitHub Actions dependencies. The check can be limited to a subset of dependencies by providing arguments (patterns are supported).
description: `Check for outdated package dependencies. GitHub Actions dependencies can be included with --include-github-actions. The check can be limited to a subset of dependencies by providing arguments (patterns are supported).
Examples:
pnpm outdated
@@ -115,6 +116,10 @@ For options that may be used with `-r`, see "pnpm help recursive"',
description: 'Prints the outdated dependencies in the given format. Default is "table". Supported options: "table, list, json"',
name: '--format <format>',
},
{
description: 'Also check GitHub Actions dependencies in workflow and action files',
name: '--include-github-actions',
},
{
description: 'Specify the sorting method. Currently only `name` is supported.',
name: '--sort-by',
@@ -136,6 +141,7 @@ export const completion: CompletionFunc = async (cliOpts) => {
export type OutdatedCommandOptions = {
compatible?: boolean
includeGithubActions?: boolean
long?: boolean
recursive?: boolean
format?: 'table' | 'list' | 'json'
@@ -228,7 +234,7 @@ export async function handler (
timeout: opts.fetchTimeout,
})
: [],
opts.global || !include.devDependencies || opts.updateConfig?.githubActions === false
opts.global || !include.devDependencies || !shouldCheckGitHubActions(opts)
? []
: findOutdatedGitHubActions({
compatible: opts.compatible,
@@ -1,6 +1,6 @@
import { TABLE_OPTIONS } from '@pnpm/cli.utils'
import { createMatcher } from '@pnpm/config.matcher'
import { findOutdatedGitHubActions, isGitHubActionSelector, normalizeGitHubActionSelector } from '@pnpm/deps.github-actions'
import { findOutdatedGitHubActions, isGitHubActionSelector, normalizeGitHubActionSelector, shouldCheckGitHubActions } from '@pnpm/deps.github-actions'
import {
outdatedDepsOfProjects,
} from '@pnpm/deps.inspection.outdated'
@@ -83,7 +83,7 @@ export async function outdatedRecursive (
outdatedMap[key].dependentPkgs.push({ location: rootDir, manifest })
}
}
if (opts.include.devDependencies && opts.updateConfig?.githubActions !== false) {
if (opts.include.devDependencies && shouldCheckGitHubActions(opts)) {
const outdatedActions = await findOutdatedGitHubActions({
compatible: opts.compatible,
dir: opts.workspaceDir ?? opts.lockfileDir ?? opts.dir,
@@ -0,0 +1,86 @@
import fs from 'node:fs'
import path from 'node:path'
import { beforeEach, expect, jest, test } from '@jest/globals'
import { prepare, preparePackages } from '@pnpm/prepare'
import { filterProjectsBySelectorObjectsFromDir } from '@pnpm/workspace.projects-filter'
import { DEFAULT_OUTDATED_OPTS } from './utils/index.js'
const originalModule = await import('@pnpm/deps.github-actions')
jest.unstable_mockModule('@pnpm/deps.github-actions', () => {
return {
...originalModule,
findOutdatedGitHubActions: jest.fn(async () => []),
}
})
const { findOutdatedGitHubActions } = await import('@pnpm/deps.github-actions')
const { handler } = await import('../../src/outdated/outdated.js')
beforeEach(() => {
jest.mocked(findOutdatedGitHubActions).mockClear()
})
test('outdated does not look at GitHub Actions by default', async () => {
prepare({})
writeWorkflow()
await handler({ ...DEFAULT_OUTDATED_OPTS, dir: process.cwd() })
expect(findOutdatedGitHubActions).not.toHaveBeenCalled()
})
test('outdated looks at GitHub Actions with --include-github-actions', async () => {
prepare({})
writeWorkflow()
await handler({ ...DEFAULT_OUTDATED_OPTS, dir: process.cwd(), includeGithubActions: true })
expect(findOutdatedGitHubActions).toHaveBeenCalledWith(expect.objectContaining({ dir: process.cwd() }))
})
test('outdated looks at GitHub Actions when update.githubActions is true', async () => {
prepare({})
writeWorkflow()
await handler({ ...DEFAULT_OUTDATED_OPTS, dir: process.cwd(), updateConfig: { githubActions: true } })
expect(findOutdatedGitHubActions).toHaveBeenCalledWith(expect.objectContaining({ dir: process.cwd() }))
})
test('recursive outdated does not look at GitHub Actions by default', async () => {
preparePackages([
{
name: 'project-1',
version: '1.0.0',
},
])
writeWorkflow()
const { allProjects, selectedProjectsGraph } = await filterProjectsBySelectorObjectsFromDir(process.cwd(), [])
const opts = {
...DEFAULT_OUTDATED_OPTS,
allProjects,
dir: process.cwd(),
recursive: true,
selectedProjectsGraph,
workspaceDir: process.cwd(),
}
await handler(opts)
expect(findOutdatedGitHubActions).not.toHaveBeenCalled()
await handler({ ...opts, includeGithubActions: true })
expect(findOutdatedGitHubActions).toHaveBeenCalledWith(expect.objectContaining({ dir: process.cwd() }))
})
function writeWorkflow (): void {
fs.mkdirSync(path.join('.github', 'workflows'), { recursive: true })
fs.writeFileSync(path.join('.github', 'workflows', 'ci.yml'), `jobs:
test:
steps:
- uses: actions/checkout@v4.1.0
`)
}
+11 -13
View File
@@ -9,7 +9,7 @@ import {
} from '@pnpm/cli.utils'
import { createMatcher } from '@pnpm/config.matcher'
import { types as allTypes } from '@pnpm/config.reader'
import { findOutdatedGitHubActions, isGitHubActionSelector, normalizeGitHubActionSelector, updateGitHubActions } from '@pnpm/deps.github-actions'
import { findOutdatedGitHubActions, isGitHubActionSelector, normalizeGitHubActionSelector, shouldCheckGitHubActions, updateGitHubActions } from '@pnpm/deps.github-actions'
import { outdatedDepsOfProjects } from '@pnpm/deps.inspection.outdated'
import { PnpmError } from '@pnpm/error'
import { handleGlobalUpdate } from '@pnpm/global.commands'
@@ -243,8 +243,7 @@ async function interactiveUpdate (
timeout: opts.fetchTimeout,
})
: projects.map(() => []),
include.devDependencies && opts.save !== false && !opts.lockfileOnly &&
(opts.updateConfig?.githubActions !== false || opts.includeGithubActions === true)
shouldUpdateGitHubActions(opts, include)
? findOutdatedGitHubActions({
compatible: opts.latest !== true,
dir: opts.workspaceDir ?? opts.lockfileDir ?? opts.dir,
@@ -326,12 +325,7 @@ async function interactiveUpdate (
throw err
}
// An explicit `update.githubActions: false` must survive into the update
// phase — only the `--include-github-actions` flag may override it.
return update(updatePkgNames, {
...opts,
includeGithubActions: opts.includeGithubActions === true || opts.updateConfig?.githubActions !== false,
}, rebuildHandler) as Promise<undefined>
return update(updatePkgNames, opts, rebuildHandler) as Promise<undefined>
}
async function update (
@@ -340,10 +334,7 @@ async function update (
rebuildHandler?: CommandHandler
): Promise<void> {
const includeDirect = makeIncludeDependenciesFromCLI(opts.cliOptions)
const updateActions = includeDirect.devDependencies &&
opts.save !== false &&
!opts.lockfileOnly &&
(opts.includeGithubActions === true || opts.updateConfig?.githubActions === true)
const updateActions = shouldUpdateGitHubActions(opts, includeDirect)
if (opts.latest) {
const dependenciesWithTags = dependencies.filter((name) =>
(!updateActions || !isGitHubActionSelector(name)) && parseUpdateParam(name).versionSpec != null)
@@ -407,6 +398,13 @@ async function update (
}
}
function shouldUpdateGitHubActions (opts: UpdateCommandOptions, include: IncludedDependencies): boolean {
return include.devDependencies &&
opts.save !== false &&
!opts.lockfileOnly &&
shouldCheckGitHubActions(opts)
}
function makeIncludeDependenciesFromCLI (opts: {
production?: boolean
dev?: boolean
@@ -0,0 +1,61 @@
import fs from 'node:fs'
import path from 'node:path'
import { beforeEach, expect, jest, test } from '@jest/globals'
import { prepare } from '@pnpm/prepare'
import { DEFAULT_OPTS } from '../utils/index.js'
const originalModule = await import('@pnpm/deps.github-actions')
jest.unstable_mockModule('@pnpm/deps.github-actions', () => {
return {
...originalModule,
findOutdatedGitHubActions: jest.fn(async () => []),
}
})
const { findOutdatedGitHubActions } = await import('@pnpm/deps.github-actions')
const { handler } = await import('../../src/update/index.js')
beforeEach(() => {
jest.mocked(findOutdatedGitHubActions).mockClear()
prepare({})
fs.mkdirSync(path.join('.github', 'workflows'), { recursive: true })
fs.writeFileSync(path.join('.github', 'workflows', 'ci.yml'), `jobs:
test:
steps:
- uses: actions/checkout@v4.1.0
`)
})
test('update --interactive does not look for GitHub Actions updates by default', async () => {
await handler({
...DEFAULT_OPTS,
dir: process.cwd(),
interactive: true,
})
expect(findOutdatedGitHubActions).not.toHaveBeenCalled()
})
test('update --interactive looks for GitHub Actions updates with --include-github-actions', async () => {
await handler({
...DEFAULT_OPTS,
dir: process.cwd(),
includeGithubActions: true,
interactive: true,
})
expect(findOutdatedGitHubActions).toHaveBeenCalledWith(expect.objectContaining({ dir: process.cwd() }))
})
test('update --interactive looks for GitHub Actions updates when update.githubActions is true', async () => {
await handler({
...DEFAULT_OPTS,
dir: process.cwd(),
interactive: true,
updateConfig: { githubActions: true },
})
expect(findOutdatedGitHubActions).toHaveBeenCalledWith(expect.objectContaining({ dir: process.cwd() }))
})