Skip to content
Closed
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
91 changes: 64 additions & 27 deletions .agents/INSTRUCTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

## What this repository is

`pipx-install-action` is a GitHub Action (plain JavaScript/CommonJS, run on
Node.js) that installs Python command-line tools with
`pipx-install-action` is a GitHub Action (plain JavaScript/ESM, run on Node.js)
that installs Python command-line tools with
[pipx](https://github.com/pypa/pipx) inside a GitHub Actions workflow, with
GitHub Actions cache support so repeat runs skip reinstalling. It's published to
the GitHub Marketplace as `python-build-tools/pipx-install-action` and consumed
Expand All @@ -12,26 +12,31 @@ tagged releases of this repository.

## Stack summary

| Aspect | Detail |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Language | JavaScript, CommonJS (`require`/`module.exports`), no TypeScript |
| Package manager | npm (`package-lock.json` committed) |
| Build/package | [`@vercel/ncc`](https://github.com/vercel/ncc) bundles `src/index.js` into the single committed `dist/index.js` that GitHub Actions actually runs |
| Testing | Jest (`__tests__/*.test.js`), coverage badge generated to `badges/coverage.svg` |
| Infrastructure | None — runs entirely on GitHub-hosted runners via the `runs.using: node24` runtime in `action.yml` |
| Aspect | Detail |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Language | JavaScript, native ESM (`import`/`export`, `"type": "module"`), no TypeScript |
| Package manager | npm (`package-lock.json` committed) |
| Build/package | [`rollup`](https://rollupjs.org) bundles `src/index.js` into the single committed `dist/index.js` that GitHub Actions actually runs, with `rollup-plugin-license` emitting `dist/licenses.txt` |
| Testing | Jest (`__tests__/*.test.js`) in native ESM mode via `NODE_OPTIONS=--experimental-vm-modules`, configured in `jest.config.js`; module mocks live in `__fixtures__/`; coverage badge to `badges/coverage.svg` |
| Infrastructure | None — runs entirely on GitHub-hosted runners via the `runs.using: node24` runtime in `action.yml` |

## Repository map

```text
.
├── action.yml # Action metadata: inputs, runs.using (node24), main: dist/index.js
├── rollup.config.js # Bundler config. @rollup/plugin-json is required (@actions/cache
│ # imports its own package.json); sourcemaps deliberately off
├── src/
│ ├── index.js # Entrypoint, calls main.run()
│ ├── main.js # Reads inputs via @actions/core, calls pipxInstall
│ └── pipx-install.js # Core logic: reads pyproject.toml, caches/installs via pipx
├── dist/ # ncc-bundled output actually executed by GitHub Actions.
├── dist/ # rollup-bundled output actually executed by GitHub Actions.
│ # Exactly two files: index.js and licenses.txt.
│ # GENERATED — do not hand-edit. Regenerate with `npm run package`.
├── jest.config.js # Jest config (native ESM; transform disabled)
├── __tests__/ # Jest tests + fixtures (__tests__/data/*.toml, sample workflow)
├── __fixtures__/ # Mock modules for jest.unstable_mockModule (core, cache, exec, fs/promises)
├── badges/coverage.svg # GENERATED by `npm test` — do not hand-edit
├── .github/workflows/
│ ├── ci.yml # Unit tests + lint/format + end-to-end action smoke test
Expand All @@ -53,7 +58,7 @@ npm test # jest + regenerate badges/coverage.svg
npm run lint # eslint .
npm run format:check # prettier --check .
npm run format:write # prettier --write .
npm run package # ncc build src/index.js -> dist/index.js (required before commit)
npm run package # rollup src/index.js -> dist/index.js + dist/licenses.txt (required before commit)
npm run all # format:write + lint + test + package — run this before every commit
npm run act-test # exercise __tests__/data workflow fixtures via `act`, if installed
```
Expand Down Expand Up @@ -91,14 +96,25 @@ managed locally (e.g. via `fnm`), switch to 24 before running any of the above.
`package.json`, or `package-lock.json`. `dist/index.js` and
`badges/coverage.svg` are generated artifacts committed to the repository —
`check-dist.yml` enforces that `dist/` matches a fresh build.
- **Do not bump `@actions/core` past `^2.x`, `@actions/cache` past `^5.x`, or
`@actions/exec` past `^2.x`** without first migrating `src/` and `__tests__/`
off CommonJS. Those packages went **ESM-only** at `@actions/core@3.0.0`,
`@actions/cache@6.0.0`, and `@actions/exec@3.0.0` respectively (`require()` of
them throws at runtime). Dependabot and `npm audit fix --force` do not know
this and will cheerfully propose the breaking major — check each package's
`RELEASES.md` on GitHub before accepting a major-version bump for anything
under `@actions/*`.
- **This repository is native ESM** (`"type": "module"`). Never reintroduce
`require()`, `module.exports`, or `__dirname` into `src/` or `__tests__/`.
Relative imports need explicit `.js` extensions (`./main.js`, not `./main`),
Node builtins use `node:` prefixes, and the `__dirname` replacement is
`path.dirname(fileURLToPath(import.meta.url))` — not `import.meta.dirname`,
which Jest's `import.meta` does not reliably populate. `@actions/core`,
`@actions/cache`, and `@actions/exec` are ESM-only from `3.x`, `6.x`, and
`3.x` onward, so this is load-bearing rather than stylistic.
- **Mock modules in tests with `jest.unstable_mockModule` and a fixture in
`__fixtures__/`**, never `jest.spyOn(someModule, 'export')` — ESM module
namespace objects are frozen, so `spyOn` throws on them. Two rules for
fixtures: register the mock _before_ the module under test is imported (so the
import must be a dynamic `await import(...)`), and if any other dependency
also imports the module you are mocking, the fixture must `export *` the real
module and override only the functions under assertion. `__fixtures__/core.js`
does exactly this because `@actions/cache` imports named exports from
`@actions/core` (`setSecret` among them) and a narrower mock breaks it.
`__fixtures__/fs-promises.js` does the same to keep `readFile` real while
mocking `symlink` and `stat`.
- Don't add a dependency, or a `require()`/`import` of one, without actually
using it. This repository previously carried an unused `@actions/github`
import (dead since the original template scaffold) and an unused
Expand All @@ -114,17 +130,38 @@ managed locally (e.g. via `fnm`), switch to 24 before running any of the above.

## Common pitfalls

1. **Hand-editing `dist/index.js`.** It's generated by `npm run package`. Any
manual edit is silently discarded the next time someone runs the build, and
the diff will fail `check-dist.yml` review in the meantime.
1. **Hand-editing `dist/index.js` or `dist/licenses.txt`.** Both are generated
by `npm run package` (rollup, plus `rollup-plugin-license`). Any manual edit
is silently discarded the next time someone runs the build, and the diff will
fail `check-dist.yml` review in the meantime.
1. **Forgetting to rebuild `dist/` before committing.** This is the single most
common reason a PR fails CI here — including Dependabot's own PRs, since
Dependabot never runs `npm run package` after bumping a dependency.
1. **Accepting a Dependabot major-version bump for an `@actions/*` toolkit
package without checking for an ESM-only breaking change.** See Constraints
above — this silently breaks the action at runtime (`ERR_REQUIRE_ESM`), which
the test suite may not catch if the module is mocked in tests rather than
exercised for real.
1. **Reintroducing CommonJS.** `require()` in `src/` throws
`ReferenceError: require is not defined in ES module scope` the moment the
bundle runs, and the `@actions/*` toolkit packages are ESM-only at their
current majors. The fastest way to prove a bundle still loads is to run it
directly:
`env 'INPUT_INSTALL-CONFIG-FILE=__tests__/data/pyproject.empty.toml' node dist/index.js`,
which should print `Nothing to install.` and exit 0.
1. **Changing the rollup `commonjs()` options, especially dropping
`ignoreTryCatch: false`.** Bundled dependencies probe for optional modules
with `require()` inside a try/catch (minimatch resolves `path` that way;
undici probes `node:http2` and `node:crypto`). In an ESM bundle `require` is
undefined, so without that option the throw is swallowed and the dependency
silently takes a degraded fallback — no error, no failed build. That is what
made minimatch use `sep: '/'`, which stops Windows paths from matching and
broke `@actions/cache`'s `saveCache` on `windows-latest` only, while Ubuntu
passed. `__tests__/dist.test.js` guards against it; don't delete that test.
1. **Assuming a green `GitHub Actions Test` job proves the cache logic works.**
Both OS jobs restore from cache when a matching key exists, which skips
`saveCache` entirely — so a run can pass without ever exercising the save
path. The cache key includes `ImageVersion`, so misses (and therefore saves)
only happen after a runner-image roll. To test the save path deliberately,
temporarily add a salt to `systemHashInput` in `pipx-install.js`, rebuild
`dist/`, and push to a throwaway branch with a PR (`ci.yml` only triggers on
`pull_request` and pushes to `main`). Expect the unit tests to fail while the
salt is present — they assert exact cache-key hashes.
1. **Running against an older local Node version.** `engines.node` requires
`>=24`; older versions may pass tests locally but don't match the `node24`
runtime GitHub Actions uses to execute `dist/index.js`.
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,6 @@ __tests__/runner/*
.idea
.vscode
*.code-workspace

# Superpowers plans are ephemeral working docs; only specs are preserved in git.
docs/superpowers/plans/
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,18 @@ see [.agents/INSTRUCTIONS.md](.agents/INSTRUCTIONS.md).
`dist/index.js` is the file GitHub Actions actually executes, and
`check-dist.yml` CI fails if the committed `dist/` doesn't match a fresh
build.
- Do not bump `@actions/core`, `@actions/cache`, or `@actions/exec` past their
last CommonJS-compatible major (`^2.x`, `^5.x`, `^2.x` respectively) — later
majors are ESM-only and this repository's `src/` uses `require()`. See
INSTRUCTIONS.md before accepting any Dependabot PR proposing these bumps.
- This repository is **native ESM** (`"type": "module"` in `package.json`).
Never reintroduce `require()`, `module.exports`, or `__dirname` into `src/` or
`__tests__/` — use `import`/`export` and
`path.dirname(fileURLToPath(import.meta.url))`. Relative imports need explicit
`.js` extensions. `@actions/core`, `@actions/cache`, and `@actions/exec` are
ESM-only from `3.x`, `6.x`, and `3.x` onward, which is why this is
load-bearing.
- Mock modules in tests with `jest.unstable_mockModule` plus a fixture in
`__fixtures__/`, never `jest.spyOn(module, 'export')` — ESM module namespaces
are frozen. Fixtures for modules that other dependencies also import must
`export *` the real module and override only what the test asserts on. See
INSTRUCTIONS.md.
- Don't add or `require()` a dependency without using it. Check the GitHub
Advisory Database / `npm audit` before adding or upgrading dependencies.
- No secrets, tokens, or credentials in code, tests, or workflow files.
Expand Down
7 changes: 7 additions & 0 deletions __fixtures__/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Mock module for `@actions/cache`, substituted via jest.unstable_mockModule.
*/
import { jest } from '@jest/globals'

export const saveCache = jest.fn()
export const restoreCache = jest.fn()
21 changes: 21 additions & 0 deletions __fixtures__/core.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Mock module for `@actions/core`, substituted via jest.unstable_mockModule.
*
* This is a *partial* mock. `@actions/cache` also imports named exports from
* `@actions/core` (`setSecret` among them), so a mock that exported only the
* functions this action calls would break any test that loads the real cache
* module. Re-exporting everything and overriding only what we assert on keeps
* those consumers satisfied — explicit local exports take precedence over
* `export *`.
*/
import { jest } from '@jest/globals'

export * from '@actions/core'

export const debug = jest.fn()
export const error = jest.fn()
export const info = jest.fn()
export const warning = jest.fn()
export const getInput = jest.fn()
export const setOutput = jest.fn()
export const setFailed = jest.fn()
7 changes: 7 additions & 0 deletions __fixtures__/exec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Mock module for `@actions/exec`, substituted via jest.unstable_mockModule.
*/
import { jest } from '@jest/globals'

export const exec = jest.fn()
export const getExecOutput = jest.fn()
15 changes: 15 additions & 0 deletions __fixtures__/fs-promises.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Partial mock module for `node:fs/promises`.
*
* `pipx-install.js` imports the default export and needs a real `readFile` to
* load the TOML fixtures in `__tests__/data/`, so only `symlink` and `stat` are
* replaced with mocks.
*/
import { jest } from '@jest/globals'

const actual = await import('node:fs/promises')

export const symlink = jest.fn()
export const stat = jest.fn()

export default { ...actual.default, symlink, stat }
52 changes: 52 additions & 0 deletions __tests__/dist.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Guards against a silent class of bundling bug in the committed dist/ bundle.
*
* `dist/index.js` is ESM, where `require` does not exist. Several bundled
* dependencies probe for optional modules with `require()` inside a try/catch
* and fall back when it throws — minimatch resolves `path` that way, for
* example. @rollup/plugin-commonjs leaves those requires untouched unless
* `ignoreTryCatch: false` is set, so they degrade silently instead of failing
* loudly: minimatch fell back to `sep: '/'`, which stopped Windows paths from
* matching and broke @actions/cache's saveCache on windows-latest only.
*
* check-dist.yml guarantees the committed bundle matches a fresh build, so
* asserting against the committed file is equivalent to asserting on the build.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

const dirname = path.dirname(fileURLToPath(import.meta.url))
const distFile = path.join(dirname, '..', 'dist', 'index.js')

describe('dist bundle', () => {
const bundle = fs.readFileSync(distFile, 'utf8')

it('contains no executable bare require() calls', () => {
// Match a bare `require(` identifier, excluding property accesses like
// `foo.require(` and `createRequire(`.
const bareRequire = /(?<![\w$.])require\(/

const offenders = bundle
.split('\n')
.map((line, index) => ({ line, lineNumber: index + 1 }))
.filter(({ line }) => {
const match = bareRequire.exec(line)
if (!match) return false

// Ignore mentions inside a line comment.
const commentIndex = line.indexOf('//')
if (commentIndex !== -1 && commentIndex < match.index) return false

// @iarna/toml deliberately hides one behind eval() so bundlers skip it,
// and degrades gracefully when it throws — it only affects util.inspect
// formatting of TOML parse errors.
if (line.includes('eval(')) return false

return true
})
.map(({ line, lineNumber }) => `${lineNumber}: ${line.trim()}`)

expect(offenders).toEqual([])
})
})
11 changes: 5 additions & 6 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
/**
* Unit tests for the action's entrypoint, src/index.js
*/
import { jest } from '@jest/globals'

const { run } = require('../src/main')
const run = jest.fn()

// Mock the action's entrypoint
jest.mock('../src/main', () => ({
run: jest.fn()
}))
// Mocks must be registered before the module under test is imported.
jest.unstable_mockModule('../src/main.js', () => ({ run }))

describe('index', () => {
it('calls run when imported', async () => {
require('../src/index')
await import('../src/index.js')

expect(run).toHaveBeenCalled()
})
Expand Down
41 changes: 18 additions & 23 deletions __tests__/main.test.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,22 @@
/**
* Unit tests for the action's main functionality, src/main.js
*/
const path = require('path')
const fs = require('fs')
const core = require('@actions/core')
const yaml = require('js-yaml')
const main = require('../src/main')

// Mock the GitHub Actions core library
const infoMock = jest.spyOn(core, 'info').mockImplementation()
const getInputMock = jest.spyOn(core, 'getInput').mockImplementation()
const setFailedMock = jest.spyOn(core, 'setFailed').mockImplementation()
const setOutputMock = jest.spyOn(core, 'setOutput').mockImplementation()

// Mock the action's main function
const runMock = jest.spyOn(main, 'run')

const testDataDir = path.join(__dirname, 'data')
const emptyPyprojectFile = path.join(testDataDir, 'pyproject.empty.toml')
import { jest } from '@jest/globals'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import yaml from 'js-yaml'

import * as core from '../__fixtures__/core.js'

jest.unstable_mockModule('@actions/core', () => core)

const actionYmlFile = path.join(__dirname, '..', 'action.yml')
const main = await import('../src/main.js')

const dirname = path.dirname(fileURLToPath(import.meta.url))
const testDataDir = path.join(dirname, 'data')
const emptyPyprojectFile = path.join(testDataDir, 'pyproject.empty.toml')
const actionYmlFile = path.join(dirname, '..', 'action.yml')

describe('action', () => {
const inputsDefaults = {}
Expand All @@ -38,25 +35,23 @@ describe('action', () => {
}

// Mock the action's inputs
getInputMock.mockImplementation((name) => {
core.getInput.mockImplementation((name) => {
return inputs[name]
})
})

it('logs if nothing to do', async () => {
await main.run()

expect(runMock).toHaveReturned()
expect(infoMock).toHaveBeenCalledWith('Nothing to install.')
expect(core.info).toHaveBeenCalledWith('Nothing to install.')
})

it('sets a failed status', async () => {
inputs['install-config-file'] = 'failfail.fail'

await main.run()

expect(runMock).toHaveReturned()
expect(setFailedMock).toHaveBeenCalledWith(
expect(core.setFailed).toHaveBeenCalledWith(
"ENOENT: no such file or directory, open 'failfail.fail'"
)
})
Expand Down
Loading
Loading