diff --git a/.changeset/interactive-update-github-actions-opt-in.md b/.changeset/interactive-update-github-actions-opt-in.md new file mode 100644 index 0000000000..d7024fab28 --- /dev/null +++ b/.changeset/interactive-update-github-actions-opt-in.md @@ -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. diff --git a/pnpm/crates/cli/src/cli_args/outdated.rs b/pnpm/crates/cli/src/cli_args/outdated.rs index a2f758d682..c1add826e8 100644 --- a/pnpm/crates/cli/src/cli_args/outdated.rs +++ b/pnpm/crates/cli/src/cli_args/outdated.rs @@ -384,6 +384,10 @@ pub struct OutdatedArgs { #[clap(long, value_enum)] pub sort_by: Option, + /// 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::( 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::( diff --git a/pnpm/crates/cli/src/cli_args/update.rs b/pnpm/crates/cli/src/cli_args/update.rs index bd38c365f5..5354d900a6 100644 --- a/pnpm/crates/cli/src/cli_args/update.rs +++ b/pnpm/crates/cli/src/cli_args/update.rs @@ -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) } } diff --git a/pnpm/crates/cli/src/cli_args/update/tests.rs b/pnpm/crates/cli/src/cli_args/update/tests.rs index 39e5128db9..2af649f48b 100644 --- a/pnpm/crates/cli/src/cli_args/update/tests.rs +++ b/pnpm/crates/cli/src/cli_args/update/tests.rs @@ -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), diff --git a/pnpm/crates/cli/src/github_actions.rs b/pnpm/crates/cli/src/github_actions.rs index 246c6923d9..9cea0e9688 100644 --- a/pnpm/crates/cli/src/github_actions.rs +++ b/pnpm/crates/cli/src/github_actions.rs @@ -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( root: &Path, compatible: bool, diff --git a/pnpm/crates/cli/src/github_actions/tests.rs b/pnpm/crates/cli/src/github_actions/tests.rs index 2391a0b9ad..edb8732452 100644 --- a/pnpm/crates/cli/src/github_actions/tests.rs +++ b/pnpm/crates/cli/src/github_actions/tests.rs @@ -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!( diff --git a/pnpm/crates/config/src/workspace_yaml.rs b/pnpm/crates/config/src/workspace_yaml.rs index af6b8767d2..e052c47f80 100644 --- a/pnpm/crates/config/src/workspace_yaml.rs +++ b/pnpm/crates/config/src/workspace_yaml.rs @@ -537,10 +537,10 @@ pub struct UpdateSettings { #[serde(skip_serializing_if = "Option::is_none")] pub changeset: Option, - /// 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, @@ -569,10 +569,10 @@ pub struct UpdateConfig { #[serde(skip_serializing_if = "Option::is_none")] pub ignore_dependencies: Option>, - /// 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, diff --git a/pnpm11/core/types/src/package.ts b/pnpm11/core/types/src/package.ts index 20fc9b162f..3f8fd04ec5 100644 --- a/pnpm11/core/types/src/package.ts +++ b/pnpm11/core/types/src/package.ts @@ -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 /** diff --git a/pnpm11/deps/github-actions/src/index.ts b/pnpm11/deps/github-actions/src/index.ts index f1316809aa..a4d6c7dc26 100644 --- a/pnpm11/deps/github-actions/src/index.ts +++ b/pnpm11/deps/github-actions/src/index.ts @@ -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('/') diff --git a/pnpm11/deps/inspection/commands/src/outdated/outdated.ts b/pnpm11/deps/inspection/commands/src/outdated/outdated.ts index 616ebd3861..e87aaf4a8c 100644 --- a/pnpm11/deps/inspection/commands/src/outdated/outdated.ts +++ b/pnpm11/deps/inspection/commands/src/outdated/outdated.ts @@ -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 { export const cliOptionsTypes = (): Record => ({ ...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 ', }, + { + 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, diff --git a/pnpm11/deps/inspection/commands/src/outdated/recursive.ts b/pnpm11/deps/inspection/commands/src/outdated/recursive.ts index b429d1ff5d..33832e1420 100644 --- a/pnpm11/deps/inspection/commands/src/outdated/recursive.ts +++ b/pnpm11/deps/inspection/commands/src/outdated/recursive.ts @@ -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, diff --git a/pnpm11/deps/inspection/commands/test/outdated/githubActions.ts b/pnpm11/deps/inspection/commands/test/outdated/githubActions.ts new file mode 100644 index 0000000000..60579325be --- /dev/null +++ b/pnpm11/deps/inspection/commands/test/outdated/githubActions.ts @@ -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 +`) +} diff --git a/pnpm11/installing/commands/src/update/index.ts b/pnpm11/installing/commands/src/update/index.ts index e15cf084fa..544550d439 100644 --- a/pnpm11/installing/commands/src/update/index.ts +++ b/pnpm11/installing/commands/src/update/index.ts @@ -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 + return update(updatePkgNames, opts, rebuildHandler) as Promise } async function update ( @@ -340,10 +334,7 @@ async function update ( rebuildHandler?: CommandHandler ): Promise { 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 diff --git a/pnpm11/installing/commands/test/update/githubActions.ts b/pnpm11/installing/commands/test/update/githubActions.ts new file mode 100644 index 0000000000..2bc72d9ab5 --- /dev/null +++ b/pnpm11/installing/commands/test/update/githubActions.ts @@ -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() })) +})