diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..304f98f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + unit: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: ./ + with: + node-version: 22.x + + - name: Run unit tests + run: npm test + + fixture-lookup: + name: Fixture Lookup (${{ matrix.package-manager }}) + needs: unit + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package-manager: npm + fixture: fixtures/npm-basic + install-command: npm ci + lockfile-name: package-lock.json + - package-manager: pnpm + fixture: fixtures/pnpm-basic + install-command: pnpm install --frozen-lockfile + lockfile-name: pnpm-lock.yaml + - package-manager: yarn + fixture: fixtures/yarn-basic + install-command: yarn install --immutable + lockfile-name: yarn.lock + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + action.yml + scripts + ${{ matrix.fixture }} + sparse-checkout-cone-mode: false + + - name: Configure Node.js + id: configure-nodejs + uses: ./ + with: + working-directory: ${{ matrix.fixture }} + cache-key-suffix: fixture-tests + lookup-only: "true" + + - name: Assert resolved action outputs + shell: bash + run: | + test "${{ steps.configure-nodejs.outputs.package-manager }}" = "${{ matrix.package-manager }}" + test "${{ steps.configure-nodejs.outputs.install-command }}" = "${{ matrix.install-command }}" + test "${{ steps.configure-nodejs.outputs.working-directory }}" = "${{ matrix.fixture }}" + test "${{ steps.configure-nodejs.outputs.lockfile-name }}" = "${{ matrix.lockfile-name }}" + + - name: Assert lookup-only behavior + working-directory: ${{ matrix.fixture }} + shell: bash + run: | + if [ "${{ steps.configure-nodejs.outputs.cache-hit }}" = "true" ]; then + test ! -d node_modules + else + test -d node_modules + node check.mjs + fi + + fixture-validate: + name: Fixture Validate (${{ matrix.package-manager }}) + needs: fixture-lookup + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - package-manager: npm + fixture: fixtures/npm-basic + install-command: npm ci + lockfile-name: package-lock.json + - package-manager: pnpm + fixture: fixtures/pnpm-basic + install-command: pnpm install --frozen-lockfile + lockfile-name: pnpm-lock.yaml + - package-manager: yarn + fixture: fixtures/yarn-basic + install-command: yarn install --immutable + lockfile-name: yarn.lock + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: | + action.yml + scripts + ${{ matrix.fixture }} + sparse-checkout-cone-mode: false + + - name: Configure Node.js + id: configure-nodejs + uses: ./ + with: + working-directory: ${{ matrix.fixture }} + cache-key-suffix: fixture-tests + + - name: Assert restore behavior + shell: bash + run: | + test "${{ steps.configure-nodejs.outputs.package-manager }}" = "${{ matrix.package-manager }}" + test "${{ steps.configure-nodejs.outputs.install-command }}" = "${{ matrix.install-command }}" + test "${{ steps.configure-nodejs.outputs.working-directory }}" = "${{ matrix.fixture }}" + test "${{ steps.configure-nodejs.outputs.lockfile-name }}" = "${{ matrix.lockfile-name }}" + test "${{ steps.configure-nodejs.outputs.cache-hit }}" = "true" + + - name: Validate installed dependency + working-directory: ${{ matrix.fixture }} + run: node check.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4383507 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Update major version tag + uses: actions/github-script@v8 + env: + VERSION_TAG: ${{ github.ref_name }} + with: + script: | + const versionTag = process.env.VERSION_TAG; + const match = versionTag.match(/^v(\d+)\.\d+\.\d+(?:[-+].+)?$/); + + if (!match) { + core.setFailed(`Expected a semantic version tag like v1.2.3, got ${versionTag}`); + return; + } + + const majorTag = `v${match[1]}`; + const owner = context.repo.owner; + const repo = context.repo.repo; + const sha = context.sha; + + try { + await github.rest.git.getRef({ + owner, + repo, + ref: `tags/${majorTag}`, + }); + + await github.rest.git.updateRef({ + owner, + repo, + ref: `tags/${majorTag}`, + sha, + force: true, + }); + } catch (error) { + if (error.status !== 404) { + throw error; + } + + await github.rest.git.createRef({ + owner, + repo, + ref: `refs/tags/${majorTag}`, + sha, + }); + } + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + prerelease: ${{ contains(github.ref_name, '-') }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4f6654 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.DS_Store + +fixtures/*/node_modules/ +fixtures/*/.pnp.* +fixtures/*/.yarn/install-state.gz +fixtures/*/.yarn/cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd4e8dd --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# configure-nodejs + +`configure-nodejs` is a composite GitHub Action for repositories that want one step to: + +- install Node.js +- detect npm, pnpm, or Yarn from `package.json` and lockfiles +- enable Corepack when the package manager needs it +- restore a `node_modules`-oriented dependency cache +- install dependencies on cache miss + +The action is designed to be shared as `pwrdrvr/configure-nodejs@v1` and to work for both repository-root projects and subdirectory projects in monorepos. + +## Usage + +### Root project + +```yaml +steps: + - uses: actions/checkout@v6 + - uses: pwrdrvr/configure-nodejs@v1 + with: + node-version: 22.x +``` + +### Subdirectory project + +```yaml +steps: + - uses: actions/checkout@v6 + - uses: pwrdrvr/configure-nodejs@v1 + with: + node-version: 22.x + working-directory: apps/web +``` + +### Cache lookup without restore + +```yaml +steps: + - uses: actions/checkout@v6 + - id: configure-nodejs + uses: pwrdrvr/configure-nodejs@v1 + with: + lookup-only: "true" + + - if: steps.configure-nodejs.outputs.cache-hit == 'true' + run: echo "Dependency cache is available" +``` + +## Inputs + +| Input | Default | Description | +| --- | --- | --- | +| `node-version` | `22.x` | Node.js version to install with `actions/setup-node` | +| `package-manager` | `""` | Explicit override for `npm`, `pnpm`, or `yarn` | +| `working-directory` | `"."` | Repository-relative directory containing `package.json` and the lockfile | +| `cache-key-suffix` | `""` | Optional suffix appended to the dependency cache key when you want to namespace cache entries | +| `lookup-only` | `"false"` | When `true`, only checks whether the cache exists and skips downloading it | + +## Outputs + +| Output | Description | +| --- | --- | +| `package-manager` | Resolved package manager | +| `package-manager-version` | Version from `package.json#packageManager` when available | +| `manager-cache-key` | Manager-specific cache-key segment | +| `lockfile-name` | Detected lockfile name | +| `lockfile-path` | Lockfile path relative to the working directory | +| `lockfile-sha` | SHA256 hash of the lockfile | +| `install-command` | Install command used on cache miss | +| `working-directory` | Normalized working directory | +| `working-directory-key` | Cache-key-safe working-directory identifier | +| `cache-hit` | `true` when the dependency cache entry exists for the computed key | + +## Package-manager detection + +Resolution order: + +1. explicit `package-manager` input +2. `package.json#packageManager` +3. supported lockfiles in the working directory + +The action fails fast when it finds multiple supported lockfiles in the same working directory. + +## Caching behavior + +The cache key includes: + +- `node-version` +- runner OS and architecture +- normalized working directory +- resolved package manager and version +- lockfile SHA + +The cache paths are scoped to the configured `working-directory`, which keeps fixture directories and subdirectory apps isolated from each other. + +## Yarn support boundary + +The first release targets Yarn repositories that install into `node_modules`. + +- Yarn 2+ uses `yarn install --immutable` +- Yarn 1 uses `yarn install --frozen-lockfile` +- the fixture coverage in this repository uses Yarn 4 with `nodeLinker: node-modules` + +Plug'n'Play-specific caching is intentionally out of scope for `v1`. + +## Development + +This repository includes three end-to-end fixtures under `fixtures/`: + +- `fixtures/npm-basic` +- `fixtures/pnpm-basic` +- `fixtures/yarn-basic` + +The CI workflow runs local unit tests plus end-to-end matrix coverage for those fixtures, followed by cache-lookup verification in a second matrix job. + +## Releases + +Tag a semantic version such as `v1.0.0` to trigger the release workflow. The workflow creates or updates the floating major tag like `v1` and publishes a GitHub release with generated notes. + +## License + +[MIT](LICENSE) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..2a72ad4 --- /dev/null +++ b/action.yml @@ -0,0 +1,97 @@ +name: "Configure Node.js" +description: "Install Node.js, resolve the package manager, restore dependency cache, and install dependencies on cache miss" + +inputs: + node-version: + description: "Node.js version to install" + default: "22.x" + package-manager: + description: "Explicit package manager override (npm, yarn, pnpm)" + default: "" + working-directory: + description: "Repository-relative directory containing package.json and lockfile" + default: "." + cache-key-suffix: + description: "Optional suffix appended to the dependency cache key for namespacing" + default: "" + lookup-only: + description: "If true, only checks if the dependency cache exists and skips download. Does not change save cache behavior" + default: "false" + +outputs: + package-manager: + description: "Resolved package manager" + value: ${{ steps.resolve-package-manager.outputs.packageManager }} + package-manager-version: + description: "Resolved package manager version from package.json when available" + value: ${{ steps.resolve-package-manager.outputs.packageManagerVersion }} + manager-cache-key: + description: "Package-manager-specific cache key segment" + value: ${{ steps.resolve-package-manager.outputs.managerCacheKey }} + lockfile-name: + description: "Detected lockfile name" + value: ${{ steps.resolve-package-manager.outputs.lockfileName }} + lockfile-path: + description: "Detected lockfile path relative to the working directory" + value: ${{ steps.resolve-package-manager.outputs.lockfilePath }} + lockfile-sha: + description: "SHA256 hash of the detected lockfile" + value: ${{ steps.resolve-package-manager.outputs.lockfileSha }} + install-command: + description: "Install command used on cache miss" + value: ${{ steps.resolve-package-manager.outputs.installCommand }} + working-directory: + description: "Normalized repository-relative working directory" + value: ${{ steps.resolve-cache-paths.outputs.workingDirectory }} + working-directory-key: + description: "Cache-key-safe identifier for the normalized working directory" + value: ${{ steps.resolve-cache-paths.outputs.workingDirectoryKey }} + cache-hit: + description: "Whether actions/cache restored a dependency cache entry" + value: ${{ steps.cache-node-modules.outputs.cache-hit }} + +runs: + using: "composite" + steps: + - uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + package-manager-cache: false + + - name: Resolve working directory and cache paths + id: resolve-cache-paths + shell: bash + run: | + node "$GITHUB_ACTION_PATH/scripts/resolve-cache-paths.mjs" \ + --cwd "$GITHUB_WORKSPACE" \ + --working-directory "${{ inputs.working-directory }}" \ + --cache-key-suffix "${{ inputs.cache-key-suffix }}" \ + --github-output "$GITHUB_OUTPUT" + + - name: Resolve package manager + id: resolve-package-manager + shell: bash + run: | + node "$GITHUB_ACTION_PATH/scripts/resolve-manager.mjs" \ + --cwd "${{ steps.resolve-cache-paths.outputs.absoluteWorkingDirectory }}" \ + --package-manager "${{ inputs.package-manager }}" \ + --github-output "$GITHUB_OUTPUT" + + - name: Enable Corepack + if: steps.resolve-package-manager.outputs.needsCorepack == 'true' + shell: bash + run: corepack enable + + - name: Restore dependencies from cache + id: cache-node-modules + uses: actions/cache@v5 + with: + path: ${{ steps.resolve-cache-paths.outputs.cachePaths }} + key: node-modules-${{ inputs.node-version }}-${{ runner.os }}-${{ runner.arch }}-${{ steps.resolve-cache-paths.outputs.workingDirectoryKey }}-${{ steps.resolve-package-manager.outputs.managerCacheKey }}-${{ steps.resolve-package-manager.outputs.lockfileSha }}${{ steps.resolve-cache-paths.outputs.cacheKeySuffixSegment }} + lookup-only: ${{ inputs.lookup-only }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + shell: bash + working-directory: ${{ steps.resolve-cache-paths.outputs.workingDirectory }} + run: ${{ steps.resolve-package-manager.outputs.installCommand }} diff --git a/docs/plans/2026-04-10-001-feat-shareable-configure-nodejs-action-plan.md b/docs/plans/2026-04-10-001-feat-shareable-configure-nodejs-action-plan.md new file mode 100644 index 0000000..374c289 --- /dev/null +++ b/docs/plans/2026-04-10-001-feat-shareable-configure-nodejs-action-plan.md @@ -0,0 +1,304 @@ +--- +title: feat: Publish shareable configure-nodejs GitHub Action +type: feat +status: completed +date: 2026-04-10 +--- + +# feat: Publish shareable configure-nodejs GitHub Action + +## Overview + +Extract the existing `configure-nodejs` composite action from `microapps-core` into this standalone repository so it can be consumed as `pwrdrvr/configure-nodejs@v1`. The new repo should preserve the current install-and-cache workflow, add the minimum portability needed for same-repo multi-fixture testing, and ship with CI coverage that proves npm, pnpm, and Yarn installs each work in isolation. + +## Problem Frame + +The current action is useful, but it is coupled to `microapps-core` in ways that make it hard to publish directly: + +- the composite action depends on a helper script outside the action directory +- it assumes the install root is `GITHUB_WORKSPACE` +- its cache paths are tuned for the monorepo layout +- there is no standalone repo contract, versioning story, or fixture-based CI proving it works across package managers + +The goal is to create a small, trustworthy action repo that keeps the current ergonomics while making the action reusable by other repositories and straightforward to validate before tagging a shared `v1`. + +## Requirements Trace + +- R1. Preserve the current public behavior of installing Node.js, resolving the package manager, enabling Corepack when needed, restoring dependency cache, and installing dependencies on cache miss. +- R2. Publish the action from the repo root so callers can use `pwrdrvr/configure-nodejs@v1` without extra path suffixes. +- R3. Keep npm, pnpm, and Yarn support in one repository and verify each manager in CI with isolated fixtures. +- R4. Make fixture isolation a first-class part of the action design so tests do not rely on fragile subtree or sparse checkout tricks. +- R5. Add enough documentation, outputs, and release notes guidance that the repo is usable as a maintained shared action rather than just a code dump. + +## Scope Boundaries + +- Initial extraction only; this plan does not include migrating `microapps-core` to consume the published action yet. +- No separate Marketplace branding or marketing work beyond standard action metadata and README usage docs. +- No attempt to support every Yarn mode on day one. The first release should support Yarn installs in `node_modules` mode; full Yarn PnP-aware caching can follow later if it is worth the complexity. +- No subtree-checkout-heavy test harness unless the simpler `working-directory` design proves insufficient during implementation. + +## Context & Research + +### Relevant Code and Patterns + +- Source behavior to preserve comes from the current in-repo composite action in `microapps-core` at `.github/actions/configure-nodejs/action.yml`. +- Package manager detection, lockfile hashing, and install command selection come from `microapps-core` `scripts/package-manager/resolve-manager.mjs`. +- Existing workflow usage in `microapps-core` shows the main contract is `node-version`, `package-manager`, and `lookup-only`, with a common pattern of using an install-only cache priming job before build/test jobs. + +### Institutional Learnings + +- No relevant `docs/solutions/` entries were present for this exact extraction. + +### External References + +- GitHub Docs: creating a composite action confirms same-repo local-action testing with `uses: ./` and the expectation that action assets live beside `action.yml`. +- GitHub Docs: matrix jobs support one workflow covering all package-manager fixtures without duplicating workflow logic. +- `actions/checkout` README documents `path` and `sparse-checkout`, which are available as escape hatches but not required for the primary fixture strategy. +- GitHub dependency caching reference confirms `cache-hit` as the important observable cache signal and reinforces treating cached paths as repository-readable data. + +## Key Technical Decisions + +- Publish the action from the repository root with `action.yml` at the top level. + Rationale: this gives the cleanest consumer syntax, keeps the repo single-purpose, and avoids forcing callers to append a subpath. + +- Vendor the helper logic into this repo and reference it through `github.action_path`. + Rationale: a published action must be self-contained; relying on a sibling repo path would make the action non-portable. + +- Add a `working-directory` input and treat it as the action's install root. + Rationale: this is the simplest way to support isolated npm/pnpm/Yarn fixtures in one repo and also makes the published action more useful for monorepos and subdirectory apps. + +- Keep same-repo fixtures in dedicated directories instead of using subtree checkouts as the default isolation mechanism. + Rationale: isolated fixture directories are easier to understand, faster to iterate on, and directly validate the action's subdirectory behavior. `actions/checkout` path shaping remains available if implementation reveals a real need for it. + +- Expose action outputs for the resolved manager, lockfile path, install command, working directory, and cache hit result. + Rationale: outputs make CI assertions easier, improve debuggability for consumers, and let workflows branch on cache behavior without reaching into composite-internal step IDs. + +- Make Yarn support version-aware and document the supported mode explicitly. + Rationale: the current source logic already parses `packageManager` versions; using that information to select modern Yarn install semantics prevents the shared action from hard-coding a stale assumption. For v1, tests should target a Yarn fixture configured for `node_modules` installs so cache behavior matches the action's design. + +## Open Questions + +### Resolved During Planning + +- How should same-repo isolation work for npm/pnpm/Yarn fixtures? + Resolution: use one repo with fixture directories plus a `working-directory` input on the action, not subtree checkouts as the primary mechanism. + +- Where should the published action live? + Resolution: place `action.yml` at the repo root so the repository itself is the action. + +- How should the first release handle Yarn complexity? + Resolution: support and test Yarn in `node_modules` mode for v1, and defer PnP-aware cache semantics. + +### Deferred to Implementation + +- Whether the action should compute cache paths in a dedicated helper script or inline inside `action.yml`. + Why deferred: both approaches are viable; the better choice depends on how much path normalization logic is needed once the working-directory changes are wired in. + +- Whether `microapps-core` should adopt the published action immediately after this repo lands. + Why deferred: that is a follow-on rollout decision, not required to ship the standalone action repo. + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.* + +```mermaid +flowchart TB + A["Workflow job"] --> B["actions/checkout"] + B --> C["uses: ./ or pwrdrvr/configure-nodejs@v1"] + C --> D["setup-node"] + C --> E["resolve manager + lockfile + install root"] + E --> F["enable Corepack when manager needs it"] + E --> G["resolve cache paths relative to working-directory"] + F --> H["actions/cache restore/save"] + G --> H + H --> I["install dependencies on cache miss"] + I --> J["fixture validation step"] +``` + +## Implementation Units + +- [x] **Unit 1: Scaffold the standalone action contract** + +**Goal:** Create a self-contained root-level composite action that preserves the current behavior while decoupling it from `microapps-core`. + +**Requirements:** R1, R2 + +**Dependencies:** None + +**Files:** +- Create: `action.yml` +- Create: `scripts/resolve-manager.mjs` +- Create: `package.json` +- Create: `.gitignore` + +**Approach:** +- Copy the current composite action behavior into `action.yml` at the repo root. +- Copy and adapt the manager-resolution helper so it no longer depends on a sibling repository path. +- Add `working-directory` as an input with a default of `.` and normalize it against `GITHUB_WORKSPACE`. +- Keep the existing inputs (`node-version`, `package-manager`, `lookup-only`) for compatibility with the source action's contract. +- Add formal outputs so workflows and fixture tests can assert what the action resolved. + +**Patterns to follow:** +- `microapps-core` `.github/actions/configure-nodejs/action.yml` +- `microapps-core` `scripts/package-manager/resolve-manager.mjs` + +**Test scenarios:** +- Happy path: explicit `package-manager: npm` resolves npm, finds `package-lock.json`, and emits `npm ci`. +- Happy path: `packageManager` field `pnpm@...` resolves pnpm without an explicit override and emits `pnpm install --frozen-lockfile`. +- Happy path: Yarn fixture with `packageManager: yarn@...` resolves Yarn and emits the documented Yarn install command for the supported mode. +- Edge case: `working-directory` pointing at a nested fixture resolves paths relative to that fixture instead of repository root. +- Error path: unsupported manager input fails with a clear error naming the supported managers. +- Error path: multiple lockfiles in one install root fail early and explain how to disambiguate. +- Error path: no supported lockfile and no explicit manager fail with an actionable message. + +**Verification:** +- A workflow can call the root action and inspect stable outputs without relying on implementation-internal step IDs. + +- [x] **Unit 2: Generalize cache and install-root handling for subdirectory consumers** + +**Goal:** Make the action safe and predictable when the target project lives below repository root, which is required both for fixture testing and real monorepo use. + +**Requirements:** R1, R3, R4 + +**Dependencies:** Unit 1 + +**Files:** +- Modify: `action.yml` +- Create: `scripts/resolve-cache-paths.mjs` +- Test: `test/resolve-cache-paths.test.mjs` +- Test: `test/resolve-manager.test.mjs` + +**Approach:** +- Resolve cache paths relative to `working-directory` so fixtures do not collide and consumers can target a subdirectory app intentionally. +- Include the resolved working directory in the cache key so npm, pnpm, and Yarn fixtures cannot poison each other's caches. +- Preserve the current `lookup-only` behavior while surfacing the resulting `cache-hit` status as an action output. +- Keep the default cache strategy centered on `node_modules`-style installs, and document the corresponding Yarn support boundary. + +**Technical design:** *(directional guidance, not implementation specification)* +- Compute an absolute install root from `GITHUB_WORKSPACE` plus `working-directory`. +- Emit cache paths as workspace-relative paths so `actions/cache` receives stable, explicit targets. +- Feed the resolved path metadata into both the cache key and the public outputs. + +**Patterns to follow:** +- Existing cache-key composition pattern from `microapps-core` `action.yml` +- GitHub dependency caching pattern centered on `cache-hit` as the observable branch point + +**Test scenarios:** +- Happy path: npm and pnpm fixtures produce distinct cache keys even when they share the same runner and node version. +- Happy path: repeated runs against one fixture restore only that fixture's dependency tree. +- Edge case: `working-directory: .` preserves root-project behavior and cache paths. +- Edge case: nested fixture with a package manager override still uses the nested lockfile and nested cache paths. +- Error path: invalid `working-directory` fails before attempting install or cache restore. +- Integration: fixture workflow can branch on the action's `cache-hit` output without reaching into composite internals. + +**Verification:** +- Cache keys and outputs are stable enough that fixture jobs can prove isolation by manager and directory. + +- [x] **Unit 3: Add isolated npm, pnpm, and Yarn fixtures plus CI matrix coverage** + +**Goal:** Prove the action works end-to-end for the three supported package managers inside one repository without cross-fixture leakage. + +**Requirements:** R3, R4 + +**Dependencies:** Unit 1, Unit 2 + +**Files:** +- Create: `fixtures/npm-basic/package.json` +- Create: `fixtures/npm-basic/package-lock.json` +- Create: `fixtures/npm-basic/check.mjs` +- Create: `fixtures/pnpm-basic/package.json` +- Create: `fixtures/pnpm-basic/pnpm-lock.yaml` +- Create: `fixtures/pnpm-basic/check.mjs` +- Create: `fixtures/yarn-basic/package.json` +- Create: `fixtures/yarn-basic/yarn.lock` +- Create: `fixtures/yarn-basic/.yarnrc.yml` +- Create: `fixtures/yarn-basic/check.mjs` +- Create: `.github/workflows/ci.yml` + +**Approach:** +- Keep each fixture self-contained with its own lockfile, tiny dependency set, and one validation script that proves the install actually happened. +- Use a matrix workflow where each job checks out the repo, runs the local action with `working-directory` set to the fixture path, and then runs the fixture's validation script. +- Prefer direct same-repo testing with `uses: ./`; only introduce checkout path shaping if implementation exposes a real limitation. +- Include at least one assertion per fixture against the action outputs so CI verifies both install behavior and manager detection behavior. + +**Execution note:** Start with a failing end-to-end fixture workflow for one manager, then expand the same pattern to the other two managers. + +**Patterns to follow:** +- GitHub matrix-job pattern from the Actions docs +- Local-action invocation pattern described in GitHub's composite-action documentation + +**Test scenarios:** +- Happy path: npm fixture installs dependencies and `check.mjs` succeeds after the action runs. +- Happy path: pnpm fixture installs dependencies and `check.mjs` succeeds after the action runs. +- Happy path: Yarn fixture installs dependencies in the documented supported mode and `check.mjs` succeeds after the action runs. +- Edge case: each fixture reports the expected resolved package manager through action outputs. +- Error path: a deliberately malformed test fixture or negative script-level test proves CI fails clearly when lockfile detection is broken. +- Integration: the matrix workflow validates `uses: ./` against the root action definition, not a copied path or test-only wrapper. + +**Verification:** +- One CI workflow run shows green coverage for npm, pnpm, and Yarn using the exact repository structure that will ship to consumers. + +- [x] **Unit 4: Document usage, support boundaries, and release/versioning** + +**Goal:** Make the repository understandable and publishable as a maintained shared action. + +**Requirements:** R2, R5 + +**Dependencies:** Unit 1, Unit 2, Unit 3 + +**Files:** +- Create: `README.md` +- Modify: `action.yml` +- Create: `.github/workflows/release.yml` + +**Approach:** +- Document the action inputs, outputs, caching behavior, supported package-manager detection modes, and the `working-directory` contract. +- Include README examples for a root-level project and a subdirectory project. +- Document the first-release support boundary for Yarn so consumers know the action is designed around `node_modules` caching. +- Add a minimal release workflow or release instructions that support tagging and maintaining a floating `v1` major tag after the first stable release. + +**Patterns to follow:** +- Standard shared-action README structure: purpose, inputs, outputs, examples, support boundaries, release notes +- GitHub custom-action versioning guidance using immutable tags plus a maintained major tag + +**Test scenarios:** +- Happy path: README examples match the actual input and output names exposed by `action.yml`. +- Edge case: subdirectory usage example matches the implemented `working-directory` semantics. +- Integration: release process updates the major tag and repository docs without requiring callers to change usage syntax. + +**Verification:** +- A new consumer can copy a README example, point it at `pwrdrvr/configure-nodejs@v1`, and understand the supported behavior without reading the source. + +## System-Wide Impact + +- **Interaction graph:** The published repository becomes a shared CI dependency for downstream repos through `uses: pwrdrvr/configure-nodejs@v1`, while the repo's own CI exercises the same action locally through `uses: ./`. +- **Error propagation:** Package manager ambiguity, missing lockfiles, invalid working directories, and install failures should terminate the action with explicit messages before downstream build steps run. +- **State lifecycle risks:** Cache pollution is the main state risk; include manager, version, install root, runner OS/arch, and lockfile hash in the cache identity so fixtures and consumers do not accidentally reuse incompatible dependency trees. +- **API surface parity:** The action metadata, README, CI assertions, and release process all need to stay aligned on input names, output names, and supported manager modes. +- **Integration coverage:** End-to-end fixture jobs are required because unit-style helper tests alone will not prove composite-action wiring, cache key composition, or `working-directory` behavior. +- **Unchanged invariants:** The action should still center on `setup-node`, Corepack for non-npm managers, dependency caching, and install-on-miss semantics; extraction should not silently change the action into a package-manager-cache-only helper. + +## Risks & Dependencies + +| Risk | Mitigation | +|------|------------| +| Yarn behavior diverges by major version or linker mode | Make Yarn handling version-aware, test one documented supported mode in CI, and document unsupported modes explicitly. | +| Cache keys become too monorepo-specific or too generic | Add install-root awareness to cache identity and validate fixture isolation in CI before release. | +| The action contract drifts from the source implementation during extraction | Preserve the current inputs by default, port the existing resolver logic first, and use outputs plus tests to catch accidental behavior changes. | +| Local tests pass but published usage breaks | Keep the action at repo root, test it locally with `uses: ./`, and only cut `v1` after the release/tagging path is documented and rehearsed. | + +## Documentation / Operational Notes + +- Keep the repository MIT-licensed to match the user's default preference and existing license file. +- Add release notes that call out the support boundary for Yarn and the new `working-directory` input so early adopters understand the initial contract. +- After the repo is green, create an annotated first release and move the `v1` tag to the release commit as the stable consumption target. +- Treat `microapps-core` adoption as a follow-up validation step after this repo proves stable in its own CI. + +## Sources & References + +- Source repo reference: `microapps-core` `.github/actions/configure-nodejs/action.yml` +- Source repo reference: `microapps-core` `scripts/package-manager/resolve-manager.mjs` +- External docs: https://docs.github.com/en/actions/tutorials/create-actions/create-a-composite-action +- External docs: https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs +- External docs: https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching +- External docs: https://github.com/actions/checkout diff --git a/fixtures/npm-basic/check.mjs b/fixtures/npm-basic/check.mjs new file mode 100644 index 0000000..129ef92 --- /dev/null +++ b/fixtures/npm-basic/check.mjs @@ -0,0 +1,7 @@ +import pc from 'picocolors'; + +if (typeof pc.green !== 'function') { + throw new Error('Expected picocolors.green to be available after npm install.'); +} + +console.log(pc.green('npm fixture dependency loaded')); diff --git a/fixtures/npm-basic/package-lock.json b/fixtures/npm-basic/package-lock.json new file mode 100644 index 0000000..475bb1e --- /dev/null +++ b/fixtures/npm-basic/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "npm-basic-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "npm-basic-fixture", + "dependencies": { + "picocolors": "1.1.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + } + } +} diff --git a/fixtures/npm-basic/package.json b/fixtures/npm-basic/package.json new file mode 100644 index 0000000..b6d1cd8 --- /dev/null +++ b/fixtures/npm-basic/package.json @@ -0,0 +1,8 @@ +{ + "name": "npm-basic-fixture", + "private": true, + "type": "module", + "dependencies": { + "picocolors": "1.1.1" + } +} diff --git a/fixtures/pnpm-basic/check.mjs b/fixtures/pnpm-basic/check.mjs new file mode 100644 index 0000000..5e0fd88 --- /dev/null +++ b/fixtures/pnpm-basic/check.mjs @@ -0,0 +1,7 @@ +import pc from 'picocolors'; + +if (typeof pc.green !== 'function') { + throw new Error('Expected picocolors.green to be available after pnpm install.'); +} + +console.log(pc.green('pnpm fixture dependency loaded')); diff --git a/fixtures/pnpm-basic/package.json b/fixtures/pnpm-basic/package.json new file mode 100644 index 0000000..7e4d060 --- /dev/null +++ b/fixtures/pnpm-basic/package.json @@ -0,0 +1,9 @@ +{ + "name": "pnpm-basic-fixture", + "private": true, + "type": "module", + "packageManager": "pnpm@10.12.1", + "dependencies": { + "picocolors": "1.1.1" + } +} diff --git a/fixtures/pnpm-basic/pnpm-lock.yaml b/fixtures/pnpm-basic/pnpm-lock.yaml new file mode 100644 index 0000000..8a296ba --- /dev/null +++ b/fixtures/pnpm-basic/pnpm-lock.yaml @@ -0,0 +1,22 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + picocolors: + specifier: 1.1.1 + version: 1.1.1 + +packages: + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + +snapshots: + + picocolors@1.1.1: {} diff --git a/fixtures/yarn-basic/.yarnrc.yml b/fixtures/yarn-basic/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/fixtures/yarn-basic/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/fixtures/yarn-basic/check.mjs b/fixtures/yarn-basic/check.mjs new file mode 100644 index 0000000..663fc86 --- /dev/null +++ b/fixtures/yarn-basic/check.mjs @@ -0,0 +1,7 @@ +import pc from 'picocolors'; + +if (typeof pc.green !== 'function') { + throw new Error('Expected picocolors.green to be available after yarn install.'); +} + +console.log(pc.green('yarn fixture dependency loaded')); diff --git a/fixtures/yarn-basic/package.json b/fixtures/yarn-basic/package.json new file mode 100644 index 0000000..94bd3bc --- /dev/null +++ b/fixtures/yarn-basic/package.json @@ -0,0 +1,9 @@ +{ + "name": "yarn-basic-fixture", + "private": true, + "type": "module", + "packageManager": "yarn@4.6.0", + "dependencies": { + "picocolors": "1.1.1" + } +} diff --git a/fixtures/yarn-basic/yarn.lock b/fixtures/yarn-basic/yarn.lock new file mode 100644 index 0000000..b5df1ae --- /dev/null +++ b/fixtures/yarn-basic/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"picocolors@npm:1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"yarn-basic-fixture@workspace:.": + version: 0.0.0-use.local + resolution: "yarn-basic-fixture@workspace:." + dependencies: + picocolors: "npm:1.1.1" + languageName: unknown + linkType: soft diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7fb649d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "@pwrdrvr/configure-nodejs-action", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@pwrdrvr/configure-nodejs-action" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b8fefa9 --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "@pwrdrvr/configure-nodejs-action", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + } +} diff --git a/scripts/resolve-cache-paths.mjs b/scripts/resolve-cache-paths.mjs new file mode 100644 index 0000000..2092fdd --- /dev/null +++ b/scripts/resolve-cache-paths.mjs @@ -0,0 +1,160 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { pathToFileURL } from 'url'; + +export function parseArgs(argv) { + const args = { + cwd: process.cwd(), + workingDirectory: '.', + cacheKeySuffix: '', + githubOutput: process.env.GITHUB_OUTPUT ?? '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--cwd') { + args.cwd = argv[index + 1]; + index += 1; + } else if (arg === '--working-directory') { + args.workingDirectory = argv[index + 1] ?? '.'; + index += 1; + } else if (arg === '--cache-key-suffix') { + args.cacheKeySuffix = argv[index + 1] ?? ''; + index += 1; + } else if (arg === '--github-output') { + args.githubOutput = argv[index + 1] ?? ''; + index += 1; + } + } + + return args; +} + +export function normalizeRelativePath(relativePath) { + const normalized = relativePath === '' ? '.' : relativePath; + return normalized.split(path.sep).join('/'); +} + +export function resolveWorkingDirectory(cwd, workingDirectory = '.') { + const absoluteCwd = path.resolve(cwd); + const absoluteWorkingDirectory = path.resolve(absoluteCwd, workingDirectory); + const relativeWorkingDirectory = path.relative(absoluteCwd, absoluteWorkingDirectory); + const normalizedWorkingDirectory = normalizeRelativePath(relativeWorkingDirectory); + + if ( + normalizedWorkingDirectory.startsWith('../') || + normalizedWorkingDirectory === '..' + ) { + throw new Error( + `Working directory "${workingDirectory}" resolves outside the repository root.`, + ); + } + + if (!fs.existsSync(absoluteWorkingDirectory)) { + throw new Error( + `Working directory "${normalizedWorkingDirectory}" does not exist.`, + ); + } + + if (!fs.statSync(absoluteWorkingDirectory).isDirectory()) { + throw new Error( + `Working directory "${normalizedWorkingDirectory}" is not a directory.`, + ); + } + + return { + absoluteWorkingDirectory, + workingDirectory: normalizedWorkingDirectory, + }; +} + +export function buildWorkingDirectoryKey(workingDirectory) { + if (workingDirectory === '.') { + return 'root'; + } + + const slug = workingDirectory.replace(/[\\/]+/g, '__').replace(/[^A-Za-z0-9_.-]/g, '-'); + const digest = crypto.createHash('sha1').update(workingDirectory).digest('hex').slice(0, 8); + return `${slug}-${digest}`; +} + +export function normalizeCacheKeySuffix(cacheKeySuffix) { + const normalized = cacheKeySuffix.trim(); + if (normalized === '') { + return ''; + } + + return normalized.replace(/[^A-Za-z0-9_.-]+/g, '-'); +} + +export function buildCachePaths(workingDirectory) { + const base = workingDirectory === '.' ? '' : `${workingDirectory}/`; + return [ + `${base}node_modules`, + `${base}**/node_modules`, + `!${base}node_modules/.cache`, + `!${base}**/node_modules/.cache`, + ]; +} + +export function buildResult({ cwd, workingDirectory, cacheKeySuffix = '' }) { + const resolvedWorkingDirectory = resolveWorkingDirectory(cwd, workingDirectory); + const normalizedCacheKeySuffix = normalizeCacheKeySuffix(cacheKeySuffix); + + return { + absoluteWorkingDirectory: resolvedWorkingDirectory.absoluteWorkingDirectory, + workingDirectory: resolvedWorkingDirectory.workingDirectory, + workingDirectoryKey: buildWorkingDirectoryKey( + resolvedWorkingDirectory.workingDirectory, + ), + cachePaths: buildCachePaths(resolvedWorkingDirectory.workingDirectory), + cacheKeySuffix: normalizedCacheKeySuffix, + cacheKeySuffixSegment: + normalizedCacheKeySuffix === '' ? '' : `-${normalizedCacheKeySuffix}`, + }; +} + +export function writeGithubOutput(githubOutputPath, result) { + if (!githubOutputPath) { + return; + } + + for (const [key, value] of Object.entries(result)) { + if (Array.isArray(value)) { + fs.appendFileSync( + githubOutputPath, + `${key}<<__CONFIGURE_NODEJS__\n${value.join('\n')}\n__CONFIGURE_NODEJS__\n`, + ); + continue; + } + + fs.appendFileSync(githubOutputPath, `${key}=${value}\n`); + } +} + +export function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + const result = buildResult({ + cwd: args.cwd, + workingDirectory: args.workingDirectory, + cacheKeySuffix: args.cacheKeySuffix, + }); + + writeGithubOutput(args.githubOutput, result); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +const entrypoint = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : null; + +if (entrypoint === import.meta.url) { + try { + main(); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); + } +} diff --git a/scripts/resolve-manager.mjs b/scripts/resolve-manager.mjs new file mode 100644 index 0000000..ef39888 --- /dev/null +++ b/scripts/resolve-manager.mjs @@ -0,0 +1,230 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { pathToFileURL } from 'url'; + +export const SUPPORTED_MANAGERS = new Set(['npm', 'pnpm', 'yarn']); +export const LOCKFILES = { + npm: ['package-lock.json', 'npm-shrinkwrap.json'], + pnpm: ['pnpm-lock.yaml'], + yarn: ['yarn.lock'], +}; + +export function parseArgs(argv) { + const args = { + cwd: process.cwd(), + packageManager: '', + githubOutput: process.env.GITHUB_OUTPUT ?? '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--cwd') { + args.cwd = argv[index + 1]; + index += 1; + } else if (arg === '--package-manager') { + args.packageManager = argv[index + 1] ?? ''; + index += 1; + } else if (arg === '--github-output') { + args.githubOutput = argv[index + 1] ?? ''; + index += 1; + } + } + + return args; +} + +export function normalizeManager(manager) { + const normalized = manager.trim().toLowerCase(); + if (normalized === '') { + return ''; + } + + if (!SUPPORTED_MANAGERS.has(normalized)) { + throw new Error( + `Unsupported package manager "${manager}". Expected one of npm, yarn, pnpm.`, + ); + } + + return normalized; +} + +export function readPackageJson(cwd) { + const packageJsonPath = path.join(cwd, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + return null; + } + + return { + path: packageJsonPath, + json: JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')), + }; +} + +export function getManagerFromPackageJson(packageJson) { + const packageManager = packageJson?.json?.packageManager; + if (typeof packageManager !== 'string' || packageManager.trim() === '') { + return { manager: '', version: '' }; + } + + const atIndex = packageManager.indexOf('@'); + if (atIndex === -1) { + return { + manager: normalizeManager(packageManager), + version: '', + }; + } + + return { + manager: normalizeManager(packageManager.slice(0, atIndex)), + version: packageManager.slice(atIndex + 1), + }; +} + +export function detectLockfile(cwd) { + const matches = []; + + for (const [manager, candidates] of Object.entries(LOCKFILES)) { + for (const candidate of candidates) { + const lockfilePath = path.join(cwd, candidate); + if (fs.existsSync(lockfilePath)) { + matches.push({ + manager, + lockfileName: candidate, + lockfilePath, + }); + break; + } + } + } + + if (matches.length === 0) { + return null; + } + + if (matches.length > 1) { + throw new Error( + `Found multiple lockfiles (${matches + .map((match) => match.lockfileName) + .join(', ')}). Set packageManager in package.json or pass --package-manager explicitly.`, + ); + } + + return matches[0]; +} + +export function getLockfileForManager(cwd, manager) { + for (const candidate of LOCKFILES[manager]) { + const lockfilePath = path.join(cwd, candidate); + if (fs.existsSync(lockfilePath)) { + return { + manager, + lockfileName: candidate, + lockfilePath, + }; + } + } + + throw new Error( + `Could not find a ${manager} lockfile in ${cwd}. Expected one of: ${LOCKFILES[ + manager + ].join(', ')}`, + ); +} + +export function getYarnInstallCommand(packageManagerVersion, cwd) { + const majorVersion = Number.parseInt(packageManagerVersion.split('.')[0] ?? '', 10); + if (Number.isInteger(majorVersion) && majorVersion >= 2) { + return 'yarn install --immutable'; + } + + if (Number.isInteger(majorVersion) && majorVersion === 1) { + return 'yarn install --frozen-lockfile'; + } + + if (fs.existsSync(path.join(cwd, '.yarnrc.yml'))) { + return 'yarn install --immutable'; + } + + return 'yarn install --frozen-lockfile'; +} + +export function buildResult({ cwd, explicitManager }) { + const packageJson = readPackageJson(cwd); + const packageJsonManager = getManagerFromPackageJson(packageJson); + const manager = explicitManager || packageJsonManager.manager; + const detectedLockfile = manager ? null : detectLockfile(cwd); + const resolvedManager = manager || detectedLockfile?.manager; + + if (!resolvedManager) { + throw new Error( + 'Could not determine package manager. Pass --package-manager, set packageManager in package.json, or add a supported lockfile.', + ); + } + + const lockfile = manager ? getLockfileForManager(cwd, resolvedManager) : detectedLockfile; + + const lockfileSha = crypto + .createHash('sha256') + .update(fs.readFileSync(lockfile.lockfilePath)) + .digest('hex'); + + const packageManagerVersion = + resolvedManager === packageJsonManager.manager ? packageJsonManager.version : ''; + const normalizedPackageManagerVersion = packageManagerVersion || 'unspecified'; + + let installCommand; + if (resolvedManager === 'npm') { + installCommand = 'npm ci'; + } else if (resolvedManager === 'pnpm') { + installCommand = 'pnpm install --frozen-lockfile'; + } else { + installCommand = getYarnInstallCommand(packageManagerVersion, cwd); + } + + return { + packageManager: resolvedManager, + packageManagerVersion, + managerCacheKey: `${resolvedManager}-${normalizedPackageManagerVersion}`, + lockfileName: lockfile.lockfileName, + lockfilePath: path.relative(cwd, lockfile.lockfilePath), + lockfileSha, + installCommand, + needsCorepack: resolvedManager === 'npm' ? 'false' : 'true', + }; +} + +export function writeGithubOutput(githubOutputPath, result) { + if (!githubOutputPath) { + return; + } + + const lines = Object.entries(result).map(([key, value]) => `${key}=${value}`); + fs.appendFileSync(githubOutputPath, `${lines.join('\n')}\n`); +} + +export function main(argv = process.argv.slice(2)) { + const args = parseArgs(argv); + const explicitManager = normalizeManager(args.packageManager); + const result = buildResult({ + cwd: path.resolve(args.cwd), + explicitManager, + }); + + writeGithubOutput(args.githubOutput, result); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +const entrypoint = process.argv[1] + ? pathToFileURL(path.resolve(process.argv[1])).href + : null; + +if (entrypoint === import.meta.url) { + try { + main(); + } catch (error) { + process.stderr.write(`${error.message}\n`); + process.exit(1); + } +} diff --git a/test/resolve-cache-paths.test.mjs b/test/resolve-cache-paths.test.mjs new file mode 100644 index 0000000..261a672 --- /dev/null +++ b/test/resolve-cache-paths.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { + buildCachePaths, + buildResult, + buildWorkingDirectoryKey, + normalizeCacheKeySuffix, + resolveWorkingDirectory, +} from '../scripts/resolve-cache-paths.mjs'; + +function withTempDir(callback) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'configure-nodejs-paths-')); + + try { + callback(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test('resolveWorkingDirectory preserves root-relative usage', () => { + withTempDir((tempDir) => { + const result = resolveWorkingDirectory(tempDir, '.'); + + assert.equal(result.workingDirectory, '.'); + assert.equal(result.absoluteWorkingDirectory, tempDir); + }); +}); + +test('resolveWorkingDirectory resolves nested directories relative to the repository root', () => { + withTempDir((tempDir) => { + fs.mkdirSync(path.join(tempDir, 'fixtures', 'npm-basic'), { recursive: true }); + + const result = resolveWorkingDirectory(tempDir, 'fixtures/npm-basic'); + + assert.equal(result.workingDirectory, 'fixtures/npm-basic'); + assert.match(result.absoluteWorkingDirectory, /fixtures\/npm-basic$/); + }); +}); + +test('resolveWorkingDirectory rejects paths that escape the repository root', () => { + withTempDir((tempDir) => { + assert.throws( + () => resolveWorkingDirectory(tempDir, '../outside'), + /resolves outside the repository root/, + ); + }); +}); + +test('resolveWorkingDirectory rejects missing paths', () => { + withTempDir((tempDir) => { + assert.throws( + () => resolveWorkingDirectory(tempDir, 'missing'), + /does not exist/, + ); + }); +}); + +test('buildWorkingDirectoryKey generates a stable root key and nested slug', () => { + assert.equal(buildWorkingDirectoryKey('.'), 'root'); + assert.match( + buildWorkingDirectoryKey('fixtures/yarn-basic'), + /^fixtures__yarn-basic-[a-f0-9]{8}$/, + ); +}); + +test('normalizeCacheKeySuffix preserves safe values and normalizes separators', () => { + assert.equal(normalizeCacheKeySuffix('fixture-tests'), 'fixture-tests'); + assert.equal(normalizeCacheKeySuffix(' fixture tests / ci '), 'fixture-tests-ci'); + assert.equal(normalizeCacheKeySuffix(' '), ''); +}); + +test('buildCachePaths scopes cache globs to the resolved working directory', () => { + assert.deepEqual(buildCachePaths('.'), [ + 'node_modules', + '**/node_modules', + '!node_modules/.cache', + '!**/node_modules/.cache', + ]); + + assert.deepEqual(buildCachePaths('fixtures/pnpm-basic'), [ + 'fixtures/pnpm-basic/node_modules', + 'fixtures/pnpm-basic/**/node_modules', + '!fixtures/pnpm-basic/node_modules/.cache', + '!fixtures/pnpm-basic/**/node_modules/.cache', + ]); +}); + +test('buildResult combines normalized working-directory and cache metadata', () => { + withTempDir((tempDir) => { + fs.mkdirSync(path.join(tempDir, 'fixtures', 'npm-basic'), { recursive: true }); + + const result = buildResult({ + cwd: tempDir, + workingDirectory: 'fixtures/npm-basic', + cacheKeySuffix: 'fixture tests', + }); + + assert.equal(result.workingDirectory, 'fixtures/npm-basic'); + assert.match(result.workingDirectoryKey, /^fixtures__npm-basic-[a-f0-9]{8}$/); + assert.equal(result.cachePaths[0], 'fixtures/npm-basic/node_modules'); + assert.equal(result.cacheKeySuffix, 'fixture-tests'); + assert.equal(result.cacheKeySuffixSegment, '-fixture-tests'); + }); +}); diff --git a/test/resolve-manager.test.mjs b/test/resolve-manager.test.mjs new file mode 100644 index 0000000..b37748f --- /dev/null +++ b/test/resolve-manager.test.mjs @@ -0,0 +1,144 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { buildResult, normalizeManager } from '../scripts/resolve-manager.mjs'; + +function withTempDir(callback) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'configure-nodejs-manager-')); + + try { + callback(tempDir); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +test('normalizeManager validates the supported values', () => { + assert.equal(normalizeManager(' PNPM '), 'pnpm'); + assert.throws( + () => normalizeManager('bun'), + /Unsupported package manager "bun"/, + ); +}); + +test('buildResult resolves npm from an explicit package-manager override', () => { + withTempDir((tempDir) => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0' }), + ); + fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}\n'); + + const result = buildResult({ cwd: tempDir, explicitManager: 'npm' }); + + assert.equal(result.packageManager, 'npm'); + assert.equal(result.installCommand, 'npm ci'); + assert.equal(result.lockfileName, 'package-lock.json'); + }); +}); + +test('buildResult resolves pnpm from packageManager metadata', () => { + withTempDir((tempDir) => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'fixture', + version: '1.0.0', + packageManager: 'pnpm@10.12.1', + }), + ); + fs.writeFileSync(path.join(tempDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + + const result = buildResult({ cwd: tempDir, explicitManager: '' }); + + assert.equal(result.packageManager, 'pnpm'); + assert.equal(result.packageManagerVersion, '10.12.1'); + assert.equal(result.installCommand, 'pnpm install --frozen-lockfile'); + assert.equal(result.managerCacheKey, 'pnpm-10.12.1'); + }); +}); + +test('buildResult resolves modern Yarn installs with --immutable', () => { + withTempDir((tempDir) => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ + name: 'fixture', + version: '1.0.0', + packageManager: 'yarn@4.6.0', + }), + ); + fs.writeFileSync(path.join(tempDir, 'yarn.lock'), '# yarn lockfile\n'); + fs.writeFileSync(path.join(tempDir, '.yarnrc.yml'), 'nodeLinker: node-modules\n'); + + const result = buildResult({ cwd: tempDir, explicitManager: '' }); + + assert.equal(result.packageManager, 'yarn'); + assert.equal(result.installCommand, 'yarn install --immutable'); + }); +}); + +test('buildResult falls back to classic Yarn install flags when no Berry signal exists', () => { + withTempDir((tempDir) => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0' }), + ); + fs.writeFileSync(path.join(tempDir, 'yarn.lock'), '# yarn classic lockfile\n'); + + const result = buildResult({ cwd: tempDir, explicitManager: 'yarn' }); + + assert.equal(result.packageManager, 'yarn'); + assert.equal(result.installCommand, 'yarn install --frozen-lockfile'); + }); +}); + +test('buildResult rejects multiple lockfiles in one working directory', () => { + withTempDir((tempDir) => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0' }), + ); + fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}\n'); + fs.writeFileSync(path.join(tempDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + + assert.throws( + () => buildResult({ cwd: tempDir, explicitManager: '' }), + /Found multiple lockfiles/, + ); + }); +}); + +test('buildResult honors an explicit manager when multiple lockfiles are present', () => { + withTempDir((tempDir) => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0' }), + ); + fs.writeFileSync(path.join(tempDir, 'package-lock.json'), '{}\n'); + fs.writeFileSync(path.join(tempDir, 'pnpm-lock.yaml'), 'lockfileVersion: 9.0\n'); + + const result = buildResult({ cwd: tempDir, explicitManager: 'npm' }); + + assert.equal(result.packageManager, 'npm'); + assert.equal(result.lockfileName, 'package-lock.json'); + assert.equal(result.installCommand, 'npm ci'); + }); +}); + +test('buildResult rejects missing lockfiles when the manager cannot be resolved', () => { + withTempDir((tempDir) => { + fs.writeFileSync( + path.join(tempDir, 'package.json'), + JSON.stringify({ name: 'fixture', version: '1.0.0' }), + ); + + assert.throws( + () => buildResult({ cwd: tempDir, explicitManager: '' }), + /Could not determine package manager/, + ); + }); +});