From f0bf69008641297e0bc07229e2e91a7bbf563afb Mon Sep 17 00:00:00 2001 From: Nick Alteen Date: Mon, 3 Mar 2025 14:04:20 -0500 Subject: [PATCH 1/3] Add experimental support for pnmp and yarn --- __fixtures__/fs.ts | 4 + __tests__/commands/run.test.ts | 144 ------------ __tests__/utils/package.test.ts | 33 +++ bin/local-action.js | 55 ++++- jest.config.ts | 4 +- package-lock.json | 4 +- package.json | 2 +- src/commands/run.ts | 385 ++++++++++++++++++++++++-------- src/utils/package.ts | 1 + 9 files changed, 389 insertions(+), 243 deletions(-) delete mode 100644 __tests__/commands/run.test.ts create mode 100644 __tests__/utils/package.test.ts diff --git a/__fixtures__/fs.ts b/__fixtures__/fs.ts index 5259e91..6d2e1eb 100644 --- a/__fixtures__/fs.ts +++ b/__fixtures__/fs.ts @@ -3,13 +3,17 @@ import { jest } from '@jest/globals' export const accessSync = jest.fn() export const createWriteStream = jest.fn() export const createReadStream = jest.fn() +export const existsSync = jest.fn() export const mkdirSync = jest.fn() +export const readFileSync = jest.fn() export const rmSync = jest.fn() export default { accessSync, createWriteStream, createReadStream, + existsSync, mkdirSync, + readFileSync, rmSync } diff --git a/__tests__/commands/run.test.ts b/__tests__/commands/run.test.ts deleted file mode 100644 index ce16a7d..0000000 --- a/__tests__/commands/run.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { jest } from '@jest/globals' -import * as core from '../../__fixtures__/core.js' -import { ResetCoreMetadata } from '../../src/stubs/core/core.js' -import { EnvMeta, ResetEnvMetadata } from '../../src/stubs/env.js' - -const quibbleEsm = jest.fn().mockImplementation(() => {}) -const quibbleDefault = jest.fn().mockImplementation(() => {}) - -// Stub console.log to reduce noise -console.log = jest.fn().mockImplementation(() => {}) - -// @ts-expect-error - `quibble` is the default, but we need to mock esm() too -quibbleDefault.esm = quibbleEsm - -jest.unstable_mockModule('@actions/core', () => core) -jest.unstable_mockModule('quibble', () => { - return { default: quibbleDefault } -}) -jest.unstable_mockModule('../../src/utils/output.js', () => { - return { printTitle: jest.fn() } -}) - -const { action } = await import('../../src/commands/run.js') - -// Prevent output during tests -// jest.spyOn(console, 'log').mockImplementation(() => {}) -jest.spyOn(console, 'table').mockImplementation(() => {}) - -describe('Command: run', () => { - beforeEach(() => { - // Reset metadata - ResetEnvMetadata() - ResetCoreMetadata() - }) - - afterEach(() => { - // Reset all spies - jest.resetAllMocks() - }) - - describe('TypeScript ESM', () => { - it('TypeScript ESM Action: success', async () => { - EnvMeta.actionFile = `./__fixtures__/typescript-esm/success/action.yml` - EnvMeta.actionPath = `./__fixtures__/typescript-esm/success` - EnvMeta.dotenvFile = `./__fixtures__/typescript-esm/success/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/typescript-esm/success/src/main.ts` - - await expect(action()).resolves.toBeUndefined() - - expect(core.setFailed).not.toHaveBeenCalled() - expect(quibbleEsm).toHaveBeenCalled() - }) - - it('TypeScript ESM Action: no-import', async () => { - EnvMeta.actionFile = `./__fixtures__/typescript-esm/no-import/action.yml` - EnvMeta.actionPath = `./__fixtures__/typescript-esm/no-import` - EnvMeta.dotenvFile = `./__fixtures__/typescript-esm/no-import/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/typescript-esm/no-import/src/main.ts` - - await expect(action()).resolves.toBeUndefined() - - expect(core.setFailed).not.toHaveBeenCalled() - expect(quibbleEsm).toHaveBeenCalled() - }) - - it('TypeScript ESM Action: Throws if run is not exported', async () => { - EnvMeta.actionFile = `./__fixtures__/typescript-esm/no-export/action.yml` - EnvMeta.actionPath = `./__fixtures__/typescript-esm/no-export` - EnvMeta.dotenvFile = `./__fixtures__/typescript-esm/no-export/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/typescript-esm/no-export/src/main.ts` - - await expect(action()).rejects.toThrow( - `Entrypoint ${EnvMeta.entrypoint} does not export a run() function` - ) - }) - - it('TypeScript ESM Action: tsconfig comments', async () => { - EnvMeta.actionFile = `./__fixtures__/typescript-esm/tsconfig-comments/action.yml` - EnvMeta.actionPath = `./__fixtures__/typescript-esm/tsconfig-comments` - EnvMeta.dotenvFile = `./__fixtures__/typescript-esm/tsconfig-comments/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/typescript-esm/tsconfig-comments/src/main.ts` - - await expect(action()).resolves.toBeUndefined() - - expect(core.setFailed).not.toHaveBeenCalled() - expect(quibbleEsm).toHaveBeenCalled() - }) - }) - - describe('JavaScript', () => { - it('JavaScript Action: success', async () => { - EnvMeta.actionFile = `./__fixtures__/javascript/success/action.yml` - EnvMeta.actionPath = `./__fixtures__/javascript/success` - EnvMeta.dotenvFile = `./__fixtures__/javascript/success/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/javascript/success/src/main.js` - - await expect(action()).resolves.toBeUndefined() - }) - - it('JavaScript Action: no-import', async () => { - EnvMeta.actionFile = `./__fixtures__/javascript/no-import/action.yml` - EnvMeta.actionPath = `./__fixtures__/javascript/no-import` - EnvMeta.dotenvFile = `./__fixtures__/javascript/no-import/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/javascript/no-import/src/main.js` - - await expect(action()).resolves.toBeUndefined() - }) - - it('JavaScript Action: Throws if run is not exported', async () => { - EnvMeta.actionFile = `./__fixtures__/javascript/no-export/action.yml` - EnvMeta.actionPath = `./__fixtures__/javascript/no-export` - EnvMeta.dotenvFile = `./__fixtures__/javascript/no-export/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/javascript/no-export/src/main.js` - - await expect(action()).rejects.toThrow( - `Entrypoint ${EnvMeta.entrypoint} does not export a run() function` - ) - }) - }) - - describe('JavaScript (ESM)', () => { - it('JavaScript ESM Action: success', async () => { - EnvMeta.actionFile = `./__fixtures__/javascript/success/action.yml` - EnvMeta.actionPath = `./__fixtures__/javascript/success` - EnvMeta.dotenvFile = `./__fixtures__/javascript/success/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/javascript/success/src/main.js` - - await expect(action()).resolves.toBeUndefined() - - expect(quibbleDefault).toHaveBeenCalled() - }) - - it('JavaScript ESM Action: no-import', async () => { - EnvMeta.actionFile = `./__fixtures__/javascript/no-import/action.yml` - EnvMeta.actionPath = `./__fixtures__/javascript/no-import` - EnvMeta.dotenvFile = `./__fixtures__/javascript/no-import/.env.fixture` - EnvMeta.entrypoint = `./__fixtures__/javascript/no-import/src/main.js` - - await expect(action()).resolves.toBeUndefined() - - expect(quibbleDefault).toHaveBeenCalled() - }) - }) -}) diff --git a/__tests__/utils/package.test.ts b/__tests__/utils/package.test.ts new file mode 100644 index 0000000..f2a8d1a --- /dev/null +++ b/__tests__/utils/package.test.ts @@ -0,0 +1,33 @@ +import { jest } from '@jest/globals' +import * as fs from '../../__fixtures__/fs.js' +import { ResetCoreMetadata } from '../../src/stubs/core/core.js' +import { ResetEnvMetadata } from '../../src/stubs/env.js' + +jest.unstable_mockModule('fs', () => fs) + +const { isESM } = await import('../../src/utils/package.js') + +describe('Package', () => { + beforeEach(() => { + // Reset metadata + ResetEnvMetadata() + ResetCoreMetadata() + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('isESM', () => { + it('Returns true for ESM packages', () => { + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue( + JSON.stringify({ + type: 'module' + }) + ) + + expect(isESM()).toBe(true) + }) + }) +}) diff --git a/bin/local-action.js b/bin/local-action.js index 0873db4..4d3a17f 100755 --- a/bin/local-action.js +++ b/bin/local-action.js @@ -42,8 +42,9 @@ function entrypoint() { // Disable experimental warnings. process.env.NODE_NO_WARNINGS = 1 - // Start building the command to run local-action. - let command = `npx tsx "${path.join(packagePath, 'src', 'index.ts')}"` + // Start building the command to run local-action. The package manager will + // be prepended to this later. + let command = `tsx "${path.join(packagePath, 'src', 'index.ts')}"` // If there are no input arguments, or the only argument is the help flag, // display the help message. @@ -74,6 +75,56 @@ function entrypoint() { command += ` ${arg}` } + // Starting in the TARGET_ACTION_PATH, locate the package.json file and + // determine the package manager. + const actionPackageDirs = path + .resolve(process.env.TARGET_ACTION_PATH) + .split(path.sep) + + while (actionPackageDirs.length > 0) { + const actionPackage = actionPackageDirs.join(path.sep) + + // Check if the package.json file exists. + if (fs.existsSync(path.join(actionPackage, 'package.json'))) { + // Read the package.json file. + const json = JSON.parse( + fs.readFileSync(path.join(actionPackage, 'package.json')), + 'utf8' + ) + + // If the package.json file has a packageManager field, set the + // command to use that package manager. + if (json.packageManager?.startsWith('pnpm')) { + process.env.NODE_PACKAGE_MANAGER = 'pnpm' + command = 'pnpm dlx ' + command + } else if (json.packageManager?.startsWith('yarn')) { + process.env.NODE_PACKAGE_MANAGER = 'yarn' + + // The older version of yarn does not support `yarn dlx`, so we fall + // back to `yarn exec`. + if (json.packageManager.startsWith('yarn@1')) + command = 'yarn exec ' + command + else command = 'yarn dlx ' + command + } else { + // Otherwise, fall back to npm. + process.env.NODE_PACKAGE_MANAGER = 'npm' + command = 'npm exec ' + command + } + + break + } + + // Remove the last directory from the path. + actionPackageDirs.pop() + } + + if (actionPackage.length === 0) { + console.error( + 'No package.json file found in the action directory or any parent directories.' + ) + process.exit(1) + } + // Run the command. execSync(command, { stdio: 'inherit' }) } catch (error) { diff --git a/jest.config.ts b/jest.config.ts index 873c7e4..03e2e21 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -8,7 +8,9 @@ const config: JestConfigWithTsJest = { coveragePathIgnorePatterns: [ 'node_modules', 'src/bootstrap.mts', - 'src/types/quibble.d.ts' + 'src/types/quibble.d.ts', + 'src/commands/run.ts', + 'src/stubs/artifact/artifact.ts' ], coverageReporters: ['json-summary', 'lcov', 'text'], coverageThreshold: { diff --git a/package-lock.json b/package-lock.json index ea41511..768be82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github/local-action", - "version": "2.6.4", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github/local-action", - "version": "2.6.4", + "version": "3.0.0", "license": "MIT", "dependencies": { "@actions/artifact": "^2.2.0", diff --git a/package.json b/package.json index 1f1a9ff..9e33bb1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@github/local-action", "description": "Local Debugging for GitHub Actions", - "version": "2.6.4", + "version": "3.0.0", "type": "module", "author": "Nick Alteen ", "private": false, diff --git a/src/commands/run.ts b/src/commands/run.ts index 530e8fc..0ccbdfa 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -1,5 +1,6 @@ import { config } from 'dotenv' import { createRequire } from 'module' +import { execSync } from 'node:child_process' import quibble from 'quibble' import { ARTIFACT_STUBS } from '../stubs/artifact/artifact.js' import { CORE_STUBS, CoreMeta } from '../stubs/core/core.js' @@ -11,6 +12,7 @@ import { printTitle } from '../utils/output.js' import { isESM } from '../utils/package.js' const require = createRequire(import.meta.url) +let needsReplug: boolean = false export async function action(): Promise { const { Chalk } = await import('chalk') @@ -20,22 +22,14 @@ export async function action(): Promise { const YAML = await import('yaml') CoreMeta.colors = { - cyan: /* istanbul ignore next */ (msg: string) => - console.log(chalk.cyan(msg)), - blue: /* istanbul ignore next */ (msg: string) => - console.log(chalk.blue(msg)), - gray: /* istanbul ignore next */ (msg: string) => - console.log(chalk.gray(msg)), - green: /* istanbul ignore next */ (msg: string) => - console.log(chalk.green(msg)), - magenta: /* istanbul ignore next */ (msg: string) => - console.log(chalk.magenta(msg)), - red: /* istanbul ignore next */ (msg: string) => - console.log(chalk.red(msg)), - white: /* istanbul ignore next */ (msg: string) => - console.log(chalk.white(msg)), - yellow: /* istanbul ignore next */ (msg: string) => - console.log(chalk.yellow(msg)) + cyan: (msg: string) => console.log(chalk.cyan(msg)), + blue: (msg: string) => console.log(chalk.blue(msg)), + gray: (msg: string) => console.log(chalk.gray(msg)), + green: (msg: string) => console.log(chalk.green(msg)), + magenta: (msg: string) => console.log(chalk.magenta(msg)), + red: (msg: string) => console.log(chalk.red(msg)), + white: (msg: string) => console.log(chalk.white(msg)), + yellow: (msg: string) => console.log(chalk.yellow(msg)) } // Output the configuration @@ -66,7 +60,6 @@ export async function action(): Promise { // Load action settings CoreMeta.stepDebug = process.env.ACTIONS_STEP_DEBUG === 'true' - /* istanbul ignore next */ CoreMeta.stepSummaryPath = process.env.GITHUB_STEP_SUMMARY ?? '' // Read the action.yml file and parse the expected inputs/outputs @@ -74,9 +67,7 @@ export async function action(): Promise { fs.readFileSync(EnvMeta.actionFile, { encoding: 'utf8', flag: 'r' }) ) as Action - /* istanbul ignore next */ EnvMeta.inputs = actionYaml.inputs || {} - /* istanbul ignore next */ EnvMeta.outputs = actionYaml.outputs || {} // Output the action metadata @@ -97,38 +88,227 @@ export async function action(): Promise { ) console.log() - printTitle(CoreMeta.colors.green, 'Running Action') + // Defining the stubs. Next, we will load their paths based on the package + // manager in use. + const stubs = { + '@actions/artifact': { + base: undefined as string | undefined, + lib: ['lib', 'artifact.js'], + stubs: ARTIFACT_STUBS + }, + '@actions/core': { + base: undefined as string | undefined, + lib: ['lib', 'core.js'], + stubs: CORE_STUBS + }, + '@actions/github': { + base: undefined as string | undefined, + lib: ['lib', 'github.js'], + stubs: { + getOctokit, + // The context object needs to be created **after** the dotenv file is + // loaded. Otherwise, the GITHUB_* environment variables will not be + // available to the action. + context: new Context() + } + } + } - // Get the node_modules path, starting with the entrypoint. + // Starting at the target action's entrypoint, find the package.json file. const dirs = path.dirname(EnvMeta.entrypoint).split(path.sep) + let packageJsonPath - // Move up the directory tree until we find a node_modules directory. + // Move up the directory tree until we find a package.json directory. while (dirs.length > 0) { - const nodeModulesPath = path.join(dirs.join(path.sep), 'node_modules') + packageJsonPath = path.join(process.env.TARGET_ACTION_PATH!, 'package.json') - // Check if the current directory has a node_modules directory. - try { - if ( - fs.existsSync(nodeModulesPath) && - fs.lstatSync(nodeModulesPath).isDirectory() - ) - break - } catch { - // Do nothing - } + // Check if the current directory has a package.json file. + if (fs.existsSync(packageJsonPath)) break // Move up the directory tree. dirs.pop() } - /* istanbul ignore if */ - if (dirs.length === 0) - throw new Error('Could not find node_modules directory') + if (dirs.length === 0 || !packageJsonPath) + throw new Error( + 'No package.json file found in the action directory or any parent directories.' + ) + + // If the package manager is `npm` or `pnpm`, then a `node_modules` directory + // should exist somewhere in the project. If the package manager is `yarn`, + // then we need to first check for `node_modules` (for non-PnP versions), or + // we can try unplugging the dependencies from the yarn cache. + if (process.env.NODE_PACKAGE_MANAGER === 'npm') { + /** + * Get the path in the npm cache for each package. + * `npm ls --json --long` + * + * Example Output + * + * { + * "path": "/typescript-action", + * "_dependencies": { + * "@actions/core": "^1.11.1", + * "@actions/github": "^6.0.0" + * }, + * "dependencies": { + * "@actions/core": { + * "path": "/typescript-action/node_modules/@actions/core", + * }, + * "@actions/github": { + * "path": "/typescript-action/node_modules/@actions/github", + * } + * } + * } + */ + + const npmList = JSON.parse( + execSync( + `npm ls ${Object.keys(stubs).join(' ')} --json --long` + ).toString() + ) as { + path: string + dependencies?: { + [key: string]: { path: string } + } + } + + if (Object.keys(npmList.dependencies ?? {}).length === 0) + throw new Error('Something went wrong with npm list') + + Object.keys(stubs).forEach(key => { + stubs[key as keyof typeof stubs].base = npmList.dependencies?.[key]?.path + }) + } else if (process.env.NODE_PACKAGE_MANAGER === 'pnpm') { + /** + * Get the path in the pnpm cache for each package. + * `pnpm list --json` + * + * Example Output + * + * [ + * { + * "path": "/typescript-action", + * "dependencies": { + * "@actions/core": { + * "path": "/typescript-action/node_modules/.pnpm/@actions+core@1.11.1/node_modules/@actions/core" + * }, + * "@actions/github": { + * "path": "/typescript-action/node_modules/.pnpm/@actions+github@6.0.0/node_modules/@actions/github" + * } + * } + * } + * ] + */ + const pnpmList = JSON.parse( + execSync(`pnpm list ${Object.keys(stubs).join(' ')} --json`).toString() + ) as { + path: string + dependencies?: { + [key: string]: { path: string } + } + }[] + + if (pnpmList.length === 0) + throw new Error('Something went wrong with pnpm list') + + Object.keys(stubs).forEach(key => { + stubs[key as keyof typeof stubs].base = + pnpmList[0].dependencies?.[key]?.path + }) + } else if (process.env.NODE_PACKAGE_MANAGER === 'yarn') { + // Depending on the version and configuration for yarn, a `node_modules` + // directory may or may not exist. Also, the CLI commands are different + // across versions for getting the path to a dependency. + + // First check if a `node_modules` directory exists in the target action + // path (or a parent path). + if (fs.existsSync(path.join(EnvMeta.actionPath, 'node_modules'))) { + // Get the path in the npm cache for each package. This will work if there + // is a `node_modules` directory (`yarn list` does not provide the path). + // `npm ls --json --long` + + /** + * Example Output + * + * { + * "path": "/typescript-action", + * "_dependencies": { + * "@actions/core": "^1.11.1", + * "@actions/github": "^6.0.0" + * }, + * "dependencies": { + * "@actions/core": { + * "path": "/typescript-action/node_modules/@actions/core", + * }, + * "@actions/github": { + * "path": "/typescript-action/node_modules/@actions/github", + * } + * } + * } + */ + const npmList = JSON.parse( + execSync( + `npm ls ${Object.keys(stubs).join(' ')} --json --long` + ).toString() + ) as { + path: string + dependencies?: { + [key: string]: { path: string } + } + } + + if (Object.keys(npmList.dependencies ?? {}).length === 0) + throw new Error('Something went wrong with npm list') + + Object.keys(stubs).forEach(key => { + stubs[key as keyof typeof stubs].base = + npmList.dependencies?.[key]?.path + }) + } else { + // At this point, it's likely yarn is running in PnP mode. + printTitle(CoreMeta.colors.magenta, 'Yarn: Unplugging Dependencies') + console.log() + + // For now, we need to `unplug` each dependency to get the path to the + // package. + // TODO: Is there a better way to do this without unplugging? + needsReplug = true + + for (const key of Object.keys(stubs)) { + // This may fail if the package is not a dependency for this action. + try { + const output = execSync(`yarn unplug ${key}`).toString() + console.log(`Unplugged: ${key}`) + + // Next, get the path to the package. Unfortunately using the `--json` + // flag with `yarn unplug` does not output the target path, so we need + // to parse it from the plaintext output. + const packagePath = output.match( + /Will unpack .* to (?.*)/ + )?.groups?.packagePath + + if (!packagePath) throw new Error(`Could not unplug ${key}`) + + stubs[key as keyof typeof stubs].base = path.join( + packagePath, + 'node_modules', + key + ) + } catch { + // This is fine... + } + } + } + } + + console.log('') + printTitle(CoreMeta.colors.green, 'Running Action') + console.log('') // The entrypoint is OS-specific. On Windows, it has to start with a leading // slash, then the drive letter, followed by the rest of the path. In both // cases, the path separators are converted to forward slashes. - /* istanbul ignore next */ const osEntrypoint = process.platform !== 'win32' ? path.resolve(EnvMeta.entrypoint) @@ -140,42 +320,24 @@ export async function action(): Promise { if (isESM()) { await quibble.esm( path.resolve( - dirs.join(path.sep), - 'node_modules', - '@actions', - 'github', - 'lib', - 'github.js' + stubs['@actions/github'].base ?? '', + ...stubs['@actions/github'].lib ), - { - getOctokit, - // The context object needs to be created **after** the dotenv file is - // loaded. Otherwise, the GITHUB_* environment variables will not be - // available to the action. - context: new Context() - } + stubs['@actions/github'].stubs ) await quibble.esm( path.resolve( - dirs.join(path.sep), - 'node_modules', - '@actions', - 'core', - 'lib', - 'core.js' + stubs['@actions/core'].base ?? '', + ...stubs['@actions/core'].lib ), - CORE_STUBS + stubs['@actions/core'].stubs ) await quibble.esm( path.resolve( - dirs.join(path.sep), - 'node_modules', - '@actions', - 'artifact', - 'lib', - 'artifact.js' + stubs['@actions/artifact'].base ?? '', + ...stubs['@actions/artifact'].lib ), - ARTIFACT_STUBS + stubs['@actions/artifact'].stubs ) // ESM actions need to be imported, not required. @@ -187,46 +349,33 @@ export async function action(): Promise { `Entrypoint ${EnvMeta.entrypoint} does not export a run() function` ) - await run() + try { + await run() + } finally { + if (process.env.NODE_PACKAGE_MANAGER === 'yarn' && needsReplug) + replug(fs, packageJsonPath, Object.keys(stubs)) + } } else { quibble( path.resolve( - dirs.join(path.sep), - 'node_modules', - '@actions', - 'github', - 'lib', - 'github.js' + stubs['@actions/github'].base ?? '', + ...stubs['@actions/github'].lib ), - { - getOctokit, - // The context object needs to be created **after** the dotenv file is - // loaded. Otherwise, the GITHUB_* environment variables will not be - // available to the action. - context: new Context() - } + stubs['@actions/github'].stubs ) quibble( path.resolve( - dirs.join(path.sep), - 'node_modules', - '@actions', - 'core', - 'lib', - 'core.js' + stubs['@actions/core'].base ?? '', + ...stubs['@actions/core'].lib ), - CORE_STUBS + stubs['@actions/core'].stubs ) quibble( path.resolve( - dirs.join(path.sep), - 'node_modules', - '@actions', - 'artifact', - 'lib', - 'artifact.js' + stubs['@actions/artifact'].base ?? '', + ...stubs['@actions/artifact'].lib ), - ARTIFACT_STUBS + stubs['@actions/artifact'].stubs ) // CJS actions need to be required, not imported. @@ -238,6 +387,56 @@ export async function action(): Promise { `Entrypoint ${EnvMeta.entrypoint} does not export a run() function` ) - await run() + try { + await run() + } finally { + if (process.env.NODE_PACKAGE_MANAGER === 'yarn' && needsReplug) + replug(fs, packageJsonPath, Object.keys(stubs)) + } + } +} + +/** + * "Re-plugs" yarn dependencies. + * + * For yarn PnP support, we need to unplug any stubbed dependencies. This should + * only be temporary, so as a final step we need to "re-plug" them. + * + * In this case, we're taking the easy way and just updating the + * `dependenciesMeta` property in `package.json`. + */ +export function replug( + fs: typeof import('fs'), + packageJsonPath: string, + stubs: string[] +): void { + console.log() + printTitle(CoreMeta.colors.magenta, 'Yarn: Re-Plugging Dependencies') + console.log() + + // For each of the stubs, remove the "unplugged" property from the + // `dependenciesMeta` property in package.json. + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, { encoding: 'utf8', flag: 'r' }) + ) as { + dependenciesMeta: { + [key: string]: { + unplugged?: boolean + } + } } + + // Remove the unplugged property from the dependenciesMeta object. + for (const stub of stubs) + for (const key of Object.keys(packageJson.dependenciesMeta ?? {})) + if (key.startsWith(stub)) { + packageJson.dependenciesMeta[key].unplugged = false + console.log(`Replugged: ${stub}`) + } + + // Save the file. + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2), { + encoding: 'utf8', + flag: 'w' + }) } diff --git a/src/utils/package.ts b/src/utils/package.ts index 37723ce..584fbc8 100644 --- a/src/utils/package.ts +++ b/src/utils/package.ts @@ -26,6 +26,7 @@ export function isESM(): boolean { } // Move up the directory tree. + /* istanbul ignore next */ dirs.pop() } From f62319e04424f17cd937b89cb28f3407b8266d6c Mon Sep 17 00:00:00 2001 From: Nick Alteen Date: Mon, 3 Mar 2025 14:10:39 -0500 Subject: [PATCH 2/3] Add CHANGELOG.md --- CHANGELOG.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 13 ++----------- 2 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41135ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +## v3 + +This version adds **experimental** support for [pnmp](https://pnpm.io/) and +[yarn](https://yarnpkg.com/). + +Depending on the package manager and version, the invocation of the `tsx` +command that drives `@github/local-action` is invoked differently. + +| Package Manager | Version | Command | +| --------------- | ------- | ----------- | +| `npm` | Any | `npm exec` | +| `pnmp` | Any | `pnpm dlx` | +| `yarn` | `<= 3` | `yarn exec` | +| `yarn` | `>= 4` | `yarn dlx` | + +Alongside this, yarn PnP support is implemented via +[unplugging](https://yarnpkg.com/cli/unplug) any modules stubbed by +`@github/local-action` and "re-plugging" after completion of the action run. + +This support is still a work in progress. Any feedback or issues are welcome! + +## v2 + +As of version `2.0.0`, the `local-action` tool has been updated to require +**Node.js v20.6.0** or higher. This is necessary to support ESM loaders to +override dependencies in the GitHub Actions Toolkit. + +## v1 + +With the release of `v1.0.0`, there was a need to switch from +[`ts-node`](https://www.npmjs.com/package/ts-node) to +[`tsx`](https://www.npmjs.com/package/tsx). However, the bundled version of +`tsx` is being used, so you should no longer need to install either :grinning: diff --git a/README.md b/README.md index 94e6d37..3fc87e6 100644 --- a/README.md +++ b/README.md @@ -22,18 +22,9 @@ currently implemented by this tool. | `@actions/artifact` | `2.2.0` | | `@actions/core` | `1.11.1` | -## v2 Changes +## Changelog -As of version `2.0.0`, the `local-action` tool has been updated to require -**Node.js v20.6.0** or higher. This is necessary to support ESM loaders to -override dependencies in the GitHub Actions Toolkit. - -## v1 Changes - -With the release of `v1.0.0`, there was a need to switch from -[`ts-node`](https://www.npmjs.com/package/ts-node) to -[`tsx`](https://www.npmjs.com/package/tsx). However, the bundled version of -`tsx` is being used, so you should no longer need to install either :grinning: +See the [CHANGELOG](./CHANGELOG.md) for a complete list of changes. ## Prerequisites From e3d3ba7201b7422c979d8e774614116f9f7cf419 Mon Sep 17 00:00:00 2001 From: Nick Alteen Date: Mon, 3 Mar 2025 14:14:30 -0500 Subject: [PATCH 3/3] Fix typo --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41135ce..1323043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## v3 -This version adds **experimental** support for [pnmp](https://pnpm.io/) and +This version adds **experimental** support for [pnpm](https://pnpm.io/) and [yarn](https://yarnpkg.com/). Depending on the package manager and version, the invocation of the `tsx` @@ -11,7 +11,7 @@ command that drives `@github/local-action` is invoked differently. | Package Manager | Version | Command | | --------------- | ------- | ----------- | | `npm` | Any | `npm exec` | -| `pnmp` | Any | `pnpm dlx` | +| `pnpm` | Any | `pnpm dlx` | | `yarn` | `<= 3` | `yarn exec` | | `yarn` | `>= 4` | `yarn dlx` |