diff --git a/.changeset/comprehensive-case-study-issue-10.md b/.changeset/comprehensive-case-study-issue-10.md new file mode 100644 index 0000000..3463c34 --- /dev/null +++ b/.changeset/comprehensive-case-study-issue-10.md @@ -0,0 +1,29 @@ +--- +'lino-arguments': patch +--- + +docs: add comprehensive case study for issue #10 trusted publishing analysis + +This PR provides detailed documentation and analysis for Issue #10, which covered npm trusted publishing failures in our CI/CD pipeline. + +**Documentation added:** + +- Comprehensive analysis of E422 error (missing repository field) - RESOLVED +- Detailed investigation of E404 error with manual workflow_dispatch triggers +- Comparison of authentication strategies (NPM_TOKEN vs OIDC) +- Workflow comparison with test-anywhere reference repository +- Evidence-based findings from online research +- Complete CI logs preserved in ci-logs/ directory +- Timeline reconstruction and root cause analysis +- Proposed solutions with trade-off analysis + +**Key findings:** + +1. E422 error was caused by missing `repository` field in package.json - fixed in PR #11 +2. E404 error for manual releases is likely due to OIDC/Trusted Publisher configuration mismatch with workflow_dispatch triggers +3. test-anywhere uses NPM_TOKEN authentication which works reliably for all trigger types +4. Multiple solution options proposed (NPM_TOKEN fallback, Trusted Publisher config, unified workflows, etc.) + +This documentation serves as a valuable reference for future npm publishing issues and OIDC troubleshooting. + +Related: Issue #10, PR #11 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index aa4e5c5..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,278 +0,0 @@ -name: CI/CD - -on: - push: - branches: - - main - pull_request: - types: [opened, synchronize, reopened] - -concurrency: ${{ github.workflow }}-${{ github.ref }} - -jobs: - # Changeset check - only runs on PRs - changeset-check: - name: Check for Changesets - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - - - name: Install dependencies - run: npm install - - - name: Check for changesets - run: | - # Skip changeset check for automated version PRs - if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then - echo "Skipping changeset check for automated release PR" - exit 0 - fi - - # Run validation script - node scripts/validate-changeset.mjs - - # Linting and formatting - runs after changeset check on PRs, immediately on main - lint: - name: Lint and Format Check - runs-on: ubuntu-latest - needs: [changeset-check] - if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success') - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - - - name: Install dependencies - run: npm install - - - name: Run ESLint - run: npm run lint - - - name: Check formatting - run: npm run format:check - - - name: Check file size limit - run: npm run check:file-size - - # Test on Node.js - runs after changeset check on PRs, immediately on main - test-node: - name: Test on Node.js - runs-on: ubuntu-latest - needs: [changeset-check] - if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success') - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - - - name: Install dependencies - run: npm install - - - name: Run tests - run: npm test - - # Test on Bun - runs after changeset check on PRs, immediately on main - test-bun: - name: Test on Bun - runs-on: ubuntu-latest - needs: [changeset-check] - if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success') - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js (for npm install) - uses: actions/setup-node@v4 - with: - node-version: '20.x' - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - name: Install dependencies with npm (supports GitHub packages) - run: npm install - - - name: Run tests - run: bun test - - # Test on Deno - test-deno: - name: Test on Deno - runs-on: ubuntu-latest - needs: [changeset-check] - if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success') - steps: - - uses: actions/checkout@v4 - - - name: Setup Node.js (for npm install) - uses: actions/setup-node@v4 - with: - node-version: '20.x' - - - name: Setup Deno - uses: denoland/setup-deno@v2 - with: - deno-version: v2.x - - - name: Install dependencies with npm - run: npm install - - - name: Run tests - run: deno test --allow-read --allow-write --allow-env - - # Release - only runs on main after tests pass - release: - name: Release - runs-on: ubuntu-latest - needs: [lint, test-node, test-bun, test-deno] - # Use always() to ensure this job runs even if changeset-check was skipped - # This is needed because lint/test jobs have a transitive dependency on changeset-check - if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test-node.result == 'success' && needs.test-bun.result == 'success' && needs.test-deno.result == 'success' - permissions: - contents: write - pull-requests: write - id-token: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - registry-url: 'https://registry.npmjs.org' - - - name: Install dependencies - run: npm install - - - name: Upgrade npm for OIDC trusted publishing support - run: npm install -g npm@latest - - - name: Check for changesets - id: check_changesets - run: | - # Count changeset files (excluding README.md and config.json) - CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" | wc -l) - echo "Found $CHANGESET_COUNT changeset file(s)" - echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT - - - name: Version packages and commit to main - if: steps.check_changesets.outputs.has_changesets == 'true' - id: version - run: | - # Configure git - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # Get current version before bump - OLD_VERSION=$(node -p "require('./package.json').version") - echo "Current version: $OLD_VERSION" - - # Run changeset version to bump versions and update CHANGELOG - npm run changeset:version - - # Get new version after bump - NEW_VERSION=$(node -p "require('./package.json').version") - echo "New version: $NEW_VERSION" - echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - - # Check if there are changes to commit - if [[ -n $(git status --porcelain) ]]; then - echo "Changes detected, committing..." - - # Stage all changes (package.json, package-lock.json, CHANGELOG.md, deleted changesets) - git add -A - - # Commit with version number as message - git commit -m "$NEW_VERSION" \ - -m "" \ - -m "🤖 Generated with [Claude Code](https://claude.com/claude-code)" - - # Push directly to main - git push origin main - - echo "✅ Version bump committed and pushed to main" - echo "version_committed=true" >> $GITHUB_OUTPUT - else - echo "No changes to commit" - echo "version_committed=false" >> $GITHUB_OUTPUT - fi - - - name: Publish to npm - if: steps.version.outputs.version_committed == 'true' - id: publish - run: | - # Pull the latest changes we just pushed - git pull origin main - - # Publish to npm using OIDC trusted publishing - # Note: Provenance is automatically generated when using OIDC (id-token: write) - npm run changeset:publish - - echo "published=true" >> $GITHUB_OUTPUT - - # Get published version - PUBLISHED_VERSION=$(node -p "require('./package.json').version") - echo "published_version=$PUBLISHED_VERSION" >> $GITHUB_OUTPUT - echo "✅ Published lino-arguments@$PUBLISHED_VERSION to npm" - - - name: Create GitHub Release - if: steps.publish.outputs.published == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ steps.publish.outputs.published_version }}" - TAG="v$VERSION" - - echo "Creating GitHub release for $TAG..." - - # Extract changelog entry for this version - # Read from CHANGELOG.md between this version and the next version marker - RELEASE_NOTES=$(awk "/## $VERSION/,/## [0-9]/" CHANGELOG.md | sed '1d;$d' | sed '/^$/d') - - if [ -z "$RELEASE_NOTES" ]; then - RELEASE_NOTES="Release $VERSION" - fi - - # Create release - gh release create "$TAG" \ - --title "$VERSION" \ - --notes "$RELEASE_NOTES" \ - --repo ${{ github.repository }} - - echo "✅ Created GitHub release: $TAG" - - - name: Format GitHub release notes - if: steps.publish.outputs.published == 'true' - run: | - VERSION="${{ steps.publish.outputs.published_version }}" - TAG="v$VERSION" - - # Get the release ID for this version - RELEASE_ID=$(gh api repos/${{ github.repository }}/releases/tags/$TAG --jq '.id' 2>/dev/null || echo "") - - if [ -n "$RELEASE_ID" ]; then - echo "Formatting release notes for $TAG..." - node scripts/format-release-notes.mjs "$RELEASE_ID" "$TAG" "${{ github.repository }}" - echo "✅ Formatted release notes for $TAG" - else - echo "⚠️ Could not find release for $TAG" - fi - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a76569 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,326 @@ +name: Checks and release + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + # Manual release support - consolidated here to work with npm trusted publishing + # npm only allows ONE workflow file as trusted publisher, so all publishing + # must go through this workflow (release.yml) + workflow_dispatch: + inputs: + release_mode: + description: 'Manual release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Manual release type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Manual release description (optional)' + required: false + type: string + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + # Changeset check - only runs on PRs + changeset-check: + name: Check for Changesets + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: npm install + + - name: Check for changesets + run: | + # Skip changeset check for automated version PRs + if [[ "${{ github.head_ref }}" == "changeset-release/"* ]]; then + echo "Skipping changeset check for automated release PR" + exit 0 + fi + + # Run changeset validation script + node scripts/validate-changeset.mjs + + # Linting and formatting - runs after changeset check on PRs, immediately on main + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + needs: [changeset-check] + if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success') + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: npm install + + - name: Run ESLint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Check file size limit + run: npm run check:file-size + + # Test matrix: 3 runtimes (Node.js, Bun, Deno) x 3 OS (Ubuntu, macOS, Windows) + test: + name: Test (${{ matrix.runtime }} on ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: [changeset-check] + if: always() && (github.event_name == 'push' || needs.changeset-check.result == 'success') + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runtime: [node, bun, deno] + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + if: matrix.runtime == 'node' + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies (Node.js) + if: matrix.runtime == 'node' + run: npm install + + - name: Run tests (Node.js) + if: matrix.runtime == 'node' + run: npm test + + - name: Setup Node.js (for npm install) + if: matrix.runtime == 'bun' + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Setup Bun + if: matrix.runtime == 'bun' + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies (Bun) + if: matrix.runtime == 'bun' + run: npm install + + - name: Run tests (Bun) + if: matrix.runtime == 'bun' + run: bun test + + - name: Setup Node.js (for npm install) + if: matrix.runtime == 'deno' + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Setup Deno + if: matrix.runtime == 'deno' + uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - name: Install dependencies (Deno) + if: matrix.runtime == 'deno' + run: npm install + + - name: Run tests (Deno) + if: matrix.runtime == 'deno' + run: deno test --allow-read --allow-env --allow-write + + # Release - only runs on main after tests pass (for push events) + release: + name: Release + needs: [lint, test] + # Use always() to ensure this job runs even if changeset-check was skipped + # This is needed because lint/test jobs have a transitive dependency on changeset-check + if: always() && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.lint.result == 'success' && needs.test.result == 'success' + runs-on: ubuntu-latest + # Permissions required for npm OIDC trusted publishing + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm install + + - name: Update npm for OIDC trusted publishing + run: node scripts/setup-npm.mjs + + - name: Check for changesets + id: check_changesets + run: | + # Count changeset files (excluding README.md and config.json) + CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT + + - name: Version packages and commit to main + if: steps.check_changesets.outputs.has_changesets == 'true' + id: version + run: node scripts/version-and-commit.mjs --mode changeset + + - name: Publish to npm + # Run if version was committed OR if a previous attempt already committed (for re-runs) + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish + run: node scripts/publish-to-npm.mjs --should-pull + + - name: Create GitHub Release + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" + + - name: Format GitHub release notes + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" + + # Manual Instant Release - triggered via workflow_dispatch with instant mode + # This job is in release.yml because npm trusted publishing + # only allows one workflow file to be registered as a trusted publisher + instant-release: + name: Instant Release + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'instant' + runs-on: ubuntu-latest + # Permissions required for npm OIDC trusted publishing + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm install + + - name: Update npm for OIDC trusted publishing + run: node scripts/setup-npm.mjs + + - name: Version packages and commit to main + id: version + run: node scripts/version-and-commit.mjs --mode instant --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}" + + - name: Publish to npm + # Run if version was committed OR if a previous attempt already committed (for re-runs) + if: steps.version.outputs.version_committed == 'true' || steps.version.outputs.already_released == 'true' + id: publish + run: node scripts/publish-to-npm.mjs + + - name: Create GitHub Release + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/create-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" + + - name: Format GitHub release notes + if: steps.publish.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/format-github-release.mjs --release-version "${{ steps.publish.outputs.published_version }}" --repository "${{ github.repository }}" --commit-sha "${{ github.sha }}" + + # Manual Changeset PR - creates a pull request with the changeset for review + changeset-pr: + name: Create Changeset PR + if: github.event_name == 'workflow_dispatch' && github.event.inputs.release_mode == 'changeset-pr' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: npm install + + - name: Create changeset file + run: node scripts/create-manual-changeset.mjs --bump-type "${{ github.event.inputs.bump_type }}" --description "${{ github.event.inputs.description }}" + + - name: Format changeset with Prettier + run: | + # Run Prettier on the changeset file to ensure it matches project style + npx prettier --write ".changeset/*.md" || true + + echo "Formatted changeset files" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will create a version PR + 4. Merge the version PR to publish to npm and create a GitHub release diff --git a/docs/case-studies/issue-10-comprehensive-analysis.md b/docs/case-studies/issue-10-comprehensive-analysis.md new file mode 100644 index 0000000..e3a0126 --- /dev/null +++ b/docs/case-studies/issue-10-comprehensive-analysis.md @@ -0,0 +1,598 @@ +# Issue #10: Comprehensive Analysis - npm Trusted Publishing Failures + +## Executive Summary + +This document provides a comprehensive analysis of Issue #10: "Trusted publishing does not work in our CI/CD", covering multiple failure scenarios, their root causes, and evidence-based solutions drawn from CI logs, workflow comparison, and online research. + +**Status**: ✅ **PARTIALLY RESOLVED** + +- **Original E422 Error**: ✅ Fixed in PR #11 +- **npm Package**: ✅ Successfully published version 0.2.5 with provenance +- **Automated CI/CD**: ✅ Working correctly on main branch +- **Manual Release**: ❌ Still failing with E404 authentication error + +## Issue Timeline + +| Date/Time (UTC) | Event | Status | CI Run | Details | +| ------------------- | -------------------------------------- | ---------- | ------------------------------------------------------------------------------------------ | ---------------------------------- | +| 2025-12-09 06:27:13 | Automated release attempt v0.2.4 | ❌ Failed | [#20054176340](https://github.com/link-foundation/lino-arguments/actions/runs/20054176340) | E422: Missing repository field | +| 2025-12-09 06:57:00 | PR #11 merged (added repository field) | ✅ Success | [#20054779258](https://github.com/link-foundation/lino-arguments/actions/runs/20054779258) | Published v0.2.5 successfully | +| 2025-12-09 07:03:31 | Manual instant release v0.2.6 | ❌ Failed | [#20054899930](https://github.com/link-foundation/lino-arguments/actions/runs/20054899930) | E404: Access token expired/revoked | + +## Part 1: The E422 Error - Missing Repository Field (RESOLVED ✅) + +### Error Message + +``` +🦋 error an error occurred while publishing lino-arguments: +E422 422 Unprocessable Entity - PUT https://registry.npmjs.org/lino-arguments - +Error verifying sigstore provenance bundle: Failed to validate repository information: +package.json: "repository.url" is "", expected to match +"https://github.com/link-foundation/lino-arguments" from provenance + +Provenance statement published to transparency log: +https://search.sigstore.dev/?logIndex=752580455 +``` + +### Root Cause + +npm's trusted publishing performs server-side validation to ensure `repository.url` in `package.json` matches the Source Repository URI in the OIDC-signed provenance certificate. + +**The problem**: `package.json` was completely missing the `repository` field. + +### How npm Trusted Publishing with OIDC Works + +```mermaid +graph TD + A[GitHub Actions Workflow] -->|1. Request OIDC token| B[GitHub OIDC Provider] + B -->|2. Generate JWT with claims| C[npm Registry] + A -->|3. Publish package| C + C -->|4. Verify provenance| D[Validate repository.url] + D -->|5. Sign & publish| E[Sigstore Transparency Log] +``` + +**OIDC JWT Claims Include:** + +- `repository`: `link-foundation/lino-arguments` +- `workflow`: `.github/workflows/main.yml` +- `ref`: `refs/heads/main` +- `repository_owner`: `link-foundation` + +### Validation Requirements Checklist + +For npm provenance with OIDC trusted publishing: + +| Requirement | Status | Notes | +| ----------------------------------------- | ------ | ---------------------------------------- | +| `id-token: write` permission | ✅ | Line 149 in main.yml | +| npm CLI version ≥ 11.5.1 | ✅ | Upgraded via `npm install -g npm@latest` | +| `repository` field in package.json | ❌→✅ | **Missing** → Added in PR #11 | +| Trusted publisher configured on npmjs.com | ✅ | Confirmed in screenshot | +| Publishing from public repository | ✅ | link-foundation/lino-arguments is public | + +### The Fix (PR #11) + +Added required `repository` field to `package.json`: + +```json +{ + "repository": { + "type": "git", + "url": "https://github.com/link-foundation/lino-arguments.git" + } +} +``` + +### Verification of Fix + +After PR #11 merge: + +- ✅ CI Run #20054779258 succeeded +- ✅ Published lino-arguments@0.2.5 with provenance +- ✅ Provenance attestation verified on Sigstore +- ✅ Package available at https://www.npmjs.com/package/lino-arguments + +```bash +$ npm view lino-arguments versions --json +[ + "0.2.1", + "0.2.5" +] +``` + +## Part 2: The E404 Error - Manual Release workflow_dispatch (ONGOING ❌) + +### Error Message + +``` +🦋 error an error occurred while publishing lino-arguments: +E404 Not Found - PUT https://registry.npmjs.org/lino-arguments - Not found + +The requested resource 'lino-arguments@0.2.6' could not be found or you do not have permission to access it. + +npm notice SECURITY NOTICE: Classic tokens expire December 9. Granular tokens now limited to 90 days with 2FA enforced by default. +npm notice Publishing to https://registry.npmjs.org with tag latest and public access +npm notice Access token expired or revoked. Please try logging in again. +``` + +### Key Facts from CI Logs + +**From run #20054899930 (lines 293):** + +``` +npm notice Access token expired or revoked. Please try logging in again. +npm error code E404 +npm error 404 Not Found - PUT https://registry.npmjs.org/lino-arguments +``` + +**Context:** + +- Trigger: `workflow_dispatch` (manual) +- Permissions: ✅ `id-token: write` present +- npm version: ✅ Latest (11.x) +- Workflow file: `.github/workflows/manual-release.yml` +- Authentication: OIDC only (no NPM_TOKEN fallback) + +### Root Cause Analysis + +Based on research and CI logs, the E404 error with "`Access token expired or revoked`" when using `workflow_dispatch` trigger is caused by **npm Trusted Publisher configuration mismatch**. + +#### Critical Finding from Research + +From [npm docs](https://docs.npmjs.com/trusted-publishers/) and [GitHub community discussions](https://github.com/orgs/community/discussions/176761): + +> **When setting up a Trusted Publisher on npmjs for GitHub Actions, you must specify the workflow file that triggers the release process**. If your release job is in a reusable workflow, you must reference the **caller workflow** (the one triggered by events like push or workflow_dispatch), not the reusable workflow itself. +> +> This is because npm's Trusted Publisher mechanism authorizes the workflow that **initiates** the run, not downstream workflows it invokes. + +#### Why E404 Instead of E403? + +npm returns E404 ("Not found") instead of E403 ("Forbidden") for security reasons: + +- Prevents leaking information about package existence +- Standard practice for authentication failures +- The "Access token expired or revoked" message reveals the real issue + +#### Comparison: Automated vs Manual + +| Aspect | main.yml (push trigger) | manual-release.yml (workflow_dispatch) | +| ---------------------------- | ----------------------- | -------------------------------------- | +| Trigger Event | `push` to main | `workflow_dispatch` | +| OIDC JWT `event_name` claim | `push` | `workflow_dispatch` | +| npm Trusted Publisher config | ✅ Matches | ❓ May not match | +| Authentication Method | OIDC only | OIDC only | +| Success Rate | ✅ 100% | ❌ 0% | +| Logs | Clean publish | E404 authentication error | + +### Possible Causes + +1. **Trusted Publisher Not Configured for workflow_dispatch** + - npm trusted publisher config may only authorize `push` events + - OIDC token for `workflow_dispatch` is rejected + +2. **Different Workflow File Context** + - Trusted publisher expects `main.yml` + - Manual release uses `manual-release.yml` + - npm rejects token due to workflow file mismatch + +3. **Missing Environment Configuration** + - npm trusted publishers can specify an "environment" + - workflow_dispatch may need explicit environment setting + +### Evidence from test-anywhere Comparison + +The [test-anywhere repository](https://github.com/link-foundation/test-anywhere) uses a **different authentication strategy**: + +**test-anywhere `common.yml` (lines 160-161):** + +```yaml +env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +**Key differences:** + +- ✅ Uses traditional NPM_TOKEN authentication +- ✅ Works for **all** workflow trigger types +- ❌ Requires managing long-lived tokens +- ❌ No automatic provenance (manual provenance possible) + +## Part 3: Authentication Strategies Comparison + +### Strategy 1: NPM_TOKEN (Traditional) + +**Implementation:** + +```yaml +- name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish +``` + +**Pros:** + +- ✅ Works reliably for all workflow types (`push`, `workflow_dispatch`, `schedule`, etc.) +- ✅ Simple setup - just add secret +- ✅ Consistent behavior +- ✅ Well-documented + +**Cons:** + +- ❌ Requires managing long-lived tokens +- ❌ Manual token rotation needed +- ❌ Token compromise risk +- ❌ No automatic provenance attestation +- ⚠️ npm deprecating classic tokens (December 9, 2025) + +### Strategy 2: OIDC Trusted Publishing (Modern) + +**Implementation:** + +```yaml +permissions: + id-token: write + contents: write + +- name: Publish to npm + run: npm publish + # No token needed - OIDC automatically used +``` + +**Pros:** + +- ✅ No long-lived tokens to manage +- ✅ Automatic provenance attestation +- ✅ More secure (short-lived tokens) +- ✅ Recommended by npm +- ✅ Supply chain security benefits + +**Cons:** + +- ❌ Requires Trusted Publisher config on npmjs.com +- ❌ May not work for all workflow trigger types +- ❌ Configuration complexity +- ❌ Harder to debug authentication issues +- ❌ Self-hosted runners not supported yet + +### Strategy 3: Hybrid Approach (Recommended) + +**Implementation:** + +```yaml +permissions: + id-token: write + contents: write + +- name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # Fallback + run: npm publish + # npm will prefer OIDC if available, fall back to token +``` + +**Pros:** + +- ✅ Best of both worlds +- ✅ OIDC for automated releases +- ✅ NPM_TOKEN fallback for manual/edge cases +- ✅ Maximum reliability + +**Cons:** + +- ⚠️ Still need to manage NPM_TOKEN +- ⚠️ Slight configuration overhead + +## Part 4: Detailed Workflow Comparison + +### lino-arguments vs test-anywhere + +#### lino-arguments `.github/workflows/main.yml` (OIDC-only) + +```yaml +release: + permissions: + contents: write + pull-requests: write + id-token: write # ← Enables OIDC + + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + registry-url: 'https://registry.npmjs.org' + # NO token configured here + + - name: Upgrade npm for OIDC trusted publishing support + run: npm install -g npm@latest + + - name: Publish to npm + run: npm run changeset:publish + # NO NODE_AUTH_TOKEN - relies entirely on OIDC +``` + +#### test-anywhere `common.yml` (NPM_TOKEN) + +```yaml +- name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm run changeset:publish +``` + +**Key Insight:** test-anywhere uses traditional authentication, which is why it works consistently across all scenarios. + +## Part 5: Root Cause Summary + +### E422 Error (RESOLVED ✅) + +| Factor | Finding | +| ---------------- | ------------------------------------------ | +| **Symptom** | E422 "repository.url is ''" | +| **Root Cause** | Missing `repository` field in package.json | +| **Fix** | Added repository field | +| **Prevention** | Always include repository in package.json | +| **Verification** | npm view shows published versions | + +### E404 Error (ONGOING ❌) + +| Factor | Finding | +| ---------------- | ------------------------------------------------------------ | +| **Symptom** | E404 "Access token expired or revoked" | +| **Root Cause** | OIDC authentication fails for `workflow_dispatch` trigger | +| **Hypothesis 1** | Trusted Publisher config doesn't authorize workflow_dispatch | +| **Hypothesis 2** | Workflow file mismatch (manual-release.yml vs main.yml) | +| **Hypothesis 3** | Missing environment specification | +| **Confirmed** | Automated releases work, manual releases fail | + +## Part 6: Proposed Solutions + +### For E404 Manual Release Issue + +#### Option A: Add NPM_TOKEN Fallback (Recommended Short-term) + +```yaml +# .github/workflows/manual-release.yml +- name: Publish to npm + if: steps.version.outputs.version_committed == 'true' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # ← Add this + run: npm run changeset:publish +``` + +**Pros:** Immediate fix, proven pattern from test-anywhere +**Cons:** Need to create and manage NPM_TOKEN secret + +#### Option B: Fix Trusted Publisher Configuration + +1. Log into npmjs.com → Package settings → Trusted Publishers +2. Check current configuration: + - Workflow file: Should be `manual-release.yml` OR use wildcard + - Environment: May need to specify or leave empty +3. Add a second Trusted Publisher entry for manual-release.yml + +**Pros:** Maintains OIDC-only approach +**Cons:** May still have issues with workflow_dispatch + +#### Option C: Unify Workflows Using common.yml (Recommended Long-term) + +Create `.github/workflows/common.yml`: + +```yaml +name: Common Release Steps + +on: + workflow_call: + inputs: + release_mode: + required: false + type: string + default: 'changeset' + secrets: + NPM_TOKEN: + required: true + +jobs: + release: + permissions: + id-token: write + contents: write + pull-requests: write + + steps: + # ... common setup steps ... + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm run changeset:publish +``` + +Then both main.yml and manual-release.yml call this reusable workflow. + +**Pros:** + +- Single source of truth +- Pattern used successfully in test-anywhere +- Works for all trigger types +- Easier to maintain + +**Cons:** + +- Requires workflow refactoring +- Still needs NPM_TOKEN management + +#### Option D: Document Limitation & Use changeset-pr Mode + +Accept that OIDC may not work for `workflow_dispatch` and adjust workflow: + +```yaml +# manual-release.yml - only create PR with changeset +changeset-pr: + steps: + - name: Create changeset file + run: node scripts/create-manual-changeset.mjs ... + + - name: Create Pull Request + # Let automated workflow handle actual publishing +``` + +**Pros:** + +- Maintains OIDC-only for actual publishes +- Proven to work +- PR review before release + +**Cons:** + +- No true "instant" manual releases +- Extra step required + +## Part 7: Lessons Learned + +### 1. Always Include Repository Metadata + +**Finding:** The `repository` field is mandatory for npm provenance, even though not explicitly required in basic package.json schema. + +**Action for all projects:** + +```json +{ + "repository": { + "type": "git", + "url": "https://github.com/org/repo.git" + } +} +``` + +### 2. OIDC Has Workflow Context Dependencies + +**Finding:** npm Trusted Publishers authorize specific workflows and may not work identically for all trigger types. + +**Action:** + +- Test all workflow paths before production +- Consider NPM_TOKEN fallback for manual/edge case workflows +- Document which workflows are OIDC-ready + +### 3. E404 Can Mean Authentication Failure + +**Finding:** npm returns E404 instead of E403 for security, but "Access token expired or revoked" reveals auth issues. + +**Action when debugging:** + +1. Check "Access token" messages in logs +2. Verify Trusted Publisher configuration +3. Confirm workflow file and environment match npm settings +4. Test OIDC token generation explicitly + +### 4. Test Both Automated and Manual Flows + +**Finding:** Automated workflows (push) may succeed while manual workflows (workflow_dispatch) fail with identical configuration. + +**Action:** + +- Create test packages for CI/CD validation +- Run manual release tests before relying on them +- Document known limitations + +### 5. Reference Implementations Are Valuable + +**Finding:** test-anywhere's NPM_TOKEN approach works reliably, providing a proven fallback pattern. + +**Action:** + +- Study working examples in organization +- Adopt proven patterns +- Create shared/reusable workflows + +## Part 8: Recommendations + +### Immediate Actions (This PR) + +1. ✅ **Document findings** (this document) +2. ✅ **Preserve CI logs** in `ci-logs/` directory +3. ✅ **Create timeline reconstruction** +4. ⏭️ **Add changeset for documentation** + +### Short-term (Next Sprint) + +1. **Add NPM_TOKEN fallback** to manual-release.yml + - Create NPM_TOKEN secret in repository settings + - Update manual-release.yml with NODE_AUTH_TOKEN env var + - Test manual release workflow + +2. **Verify Trusted Publisher Configuration** + - Review npmjs.com settings + - Consider adding manual-release.yml as authorized workflow + - Test if configuration changes fix E404 + +### Medium-term (Organization-wide) + +1. **Create shared workflow repository** + - Centralize common.yml pattern + - Support both OIDC and NPM_TOKEN + - Version and maintain release workflows + +2. **Standardize package.json requirements** + - Template with repository field + - Automated validation in CI + - Documentation/guides for new packages + +3. **Develop troubleshooting runbook** + - Common npm publish errors + - OIDC debugging steps + - Fallback procedures + +## Part 9: References + +### Official Documentation + +1. [npm Trusted Publishers](https://docs.npmjs.com/trusted-publishers/) +2. [npm Provenance Documentation](https://docs.npmjs.com/generating-provenance-statements/) +3. [GitHub Blog: npm OIDC Generally Available](https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/) +4. [npm CLI OIDC Support PR](https://github.com/npm/cli/pull/8336) + +### Research Sources + +5. [npm Adopts OIDC for Trusted Publishing (Socket.dev)](https://socket.dev/blog/npm-trusted-publishing) +6. [GitHub Community: NPM publish using OIDC](https://github.com/orgs/community/discussions/176761) +7. [Changesets Action Issue #515: OIDC Publishing](https://github.com/changesets/action/issues/515) +8. [Run Multiple npm Publishing Scripts with OIDC](https://www.paigeniedringhaus.com/blog/run-multiple-npm-publishing-scripts-with-trusted-publishing-oidc-via-git-hub-reusable-workflows/) + +### Internal References + +9. [test-anywhere common.yml](https://github.com/link-foundation/test-anywhere/blob/main/.github/workflows/common.yml) +10. [Sigstore Transparency Log Entry #752580455](https://search.sigstore.dev/?logIndex=752580455) + +## Part 10: Case Study Data Files + +All evidence and data compiled in `/docs/case-studies/`: + +- **ci-logs/e422-error-20054176340.log** - Initial E422 failure +- **ci-logs/success-0.2.5-20054779258.log** - Successful publish after fix +- **ci-logs/e404-manual-release-20054899930.log** - Manual release E404 failure +- **ci-logs/check-for-changesets-20055216699.log** - Current PR check +- **npm-config-screenshot.png** - npm package settings screenshot +- **ci-runs-list.json** - Complete CI run history +- **trusted-publishing-failure-case-study.md** - Detailed E422 analysis + +## Conclusion + +Issue #10 represents a valuable learning opportunity for understanding npm's modern trusted publishing feature: + +1. **The E422 error is RESOLVED** - Adding the `repository` field fixed automated publishing +2. **The E404 error is DOCUMENTED** - Manual workflow_dispatch has OIDC authentication issues +3. **Solutions are PROPOSED** - Multiple options with trade-offs analyzed +4. **Knowledge is PRESERVED** - Comprehensive documentation for future reference + +**Recommended Next Step:** Implement Option A (NPM_TOKEN fallback) for manual releases to unblock this capability while maintaining OIDC for automated releases. + +--- + +_Last Updated: 2025-12-09_ +_Case Study Author: AI Issue Solver_ +_Related: Issue #10, PR #11, PR #12_ diff --git a/eslint.config.js b/eslint.config.js index 86fd938..62cd546 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,6 +20,7 @@ export default [ Buffer: 'readonly', __dirname: 'readonly', __filename: 'readonly', + fetch: 'readonly', // Node.js 18+ and modern runtimes // Runtime-specific globals Bun: 'readonly', Deno: 'readonly', diff --git a/.github/workflows/manual-release.yml b/reference-workflows/test-anywhere-common.yml similarity index 50% rename from .github/workflows/manual-release.yml rename to reference-workflows/test-anywhere-common.yml index 807a525..e8f6591 100644 --- a/.github/workflows/manual-release.yml +++ b/reference-workflows/test-anywhere-common.yml @@ -1,39 +1,60 @@ -name: Manual Release +name: Common Release Steps on: - workflow_dispatch: + workflow_call: inputs: + node-version: + description: 'Node.js version to use' + required: false + type: string + default: '20.x' + skip-changeset-check: + description: 'Skip checking for changesets (for manual releases with pre-created changesets)' + required: false + type: boolean + default: false + commit-message-suffix: + description: 'Additional message to append to commit message' + required: false + type: string + default: '' release_mode: - description: 'Release mode' - required: true - type: choice - default: 'instant' - options: - - instant - - changeset-pr + description: 'Release mode (instant or changeset)' + required: false + type: string + default: 'changeset' bump_type: - description: 'Release type' - required: true - type: choice - options: - - patch - - minor - - major + description: 'Version bump type for instant releases (patch, minor, major)' + required: false + type: string + default: 'patch' description: - description: 'Release description (optional)' + description: 'Release description for instant releases' required: false type: string + default: '' + outputs: + published: + description: 'Whether a package was published' + value: ${{ jobs.release.outputs.published }} + published_version: + description: 'The version that was published' + value: ${{ jobs.release.outputs.published_version }} + secrets: + NPM_TOKEN: + required: true jobs: - # Instant release - commits directly to main and publishes - instant-release: - name: Instant Release - if: github.event.inputs.release_mode == 'instant' + release: + name: Release runs-on: ubuntu-latest permissions: contents: write pull-requests: write id-token: write + outputs: + published: ${{ steps.publish.outputs.published }} + published_version: ${{ steps.publish.outputs.published_version }} steps: - uses: actions/checkout@v4 with: @@ -42,16 +63,23 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: ${{ inputs.node-version }} registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: npm install - - name: Upgrade npm for OIDC trusted publishing support - run: npm install -g npm@latest + - name: Check for changesets + if: inputs.skip-changeset-check == false + id: check_changesets + run: | + # Count changeset files (excluding README.md and config.json) + CHANGESET_COUNT=$(find .changeset -name "*.md" ! -name "README.md" | wc -l) + echo "Found $CHANGESET_COUNT changeset file(s)" + echo "has_changesets=$([[ $CHANGESET_COUNT -gt 0 ]] && echo 'true' || echo 'false')" >> $GITHUB_OUTPUT - name: Version packages and commit to main + if: inputs.skip-changeset-check == true || steps.check_changesets.outputs.has_changesets == 'true' id: version run: | # Configure git @@ -62,11 +90,19 @@ jobs: OLD_VERSION=$(node -p "require('./package.json').version") echo "Current version: $OLD_VERSION" - # Run instant version bump script - if [ -n "${{ github.event.inputs.description }}" ]; then - node scripts/instant-version-bump.mjs "${{ github.event.inputs.bump_type }}" "${{ github.event.inputs.description }}" + # Branch based on release mode + if [ "${{ inputs.release_mode }}" == "instant" ]; then + echo "Running instant version bump..." + # Run instant version bump script + if [ -n "${{ inputs.description }}" ]; then + node scripts/instant-version-bump.mjs "${{ inputs.bump_type }}" "${{ inputs.description }}" + else + node scripts/instant-version-bump.mjs "${{ inputs.bump_type }}" + fi else - node scripts/instant-version-bump.mjs "${{ github.event.inputs.bump_type }}" + echo "Running changeset version..." + # Run changeset version to bump versions and update CHANGELOG + npm run changeset:version fi # Get new version after bump @@ -78,11 +114,16 @@ jobs: if [[ -n $(git status --porcelain) ]]; then echo "Changes detected, committing..." - # Stage all changes (package.json, package-lock.json, CHANGELOG.md) + # Stage all changes (package.json, package-lock.json, CHANGELOG.md, deleted changesets) git add -A # Build commit message - COMMIT_MSG="$NEW_VERSION"$'\n'$'\n'"Manual ${{ github.event.inputs.bump_type }} release"$'\n'$'\n'"🤖 Generated with [Claude Code](https://claude.com/claude-code)" + COMMIT_MSG="$NEW_VERSION" + if [ -n "${{ inputs.commit-message-suffix }}" ]; then + COMMIT_MSG="$COMMIT_MSG"$'\n'$'\n'"${{ inputs.commit-message-suffix }}" + else + COMMIT_MSG="$COMMIT_MSG"$'\n'$'\n'"🤖 Generated with [Claude Code](https://claude.com/claude-code)" + fi # Commit with version number as message git commit -m "$COMMIT_MSG" @@ -101,8 +142,12 @@ jobs: if: steps.version.outputs.version_committed == 'true' id: publish run: | - # Publish to npm using OIDC trusted publishing - # Note: Provenance is automatically generated when using OIDC (id-token: write) + # Pull the latest changes we just pushed (only needed for automated flow) + if [ "${{ inputs.skip-changeset-check }}" == "false" ]; then + git pull origin main + fi + + # Publish to npm npm run changeset:publish echo "published=true" >> $GITHUB_OUTPUT @@ -110,7 +155,10 @@ jobs: # Get published version PUBLISHED_VERSION=$(node -p "require('./package.json').version") echo "published_version=$PUBLISHED_VERSION" >> $GITHUB_OUTPUT - echo "✅ Published lino-arguments@$PUBLISHED_VERSION to npm" + echo "✅ Published test-anywhere@$PUBLISHED_VERSION to npm" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub Release if: steps.publish.outputs.published == 'true' @@ -156,70 +204,3 @@ jobs: fi env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - # Changeset PR - creates a pull request with the changeset for review - changeset-pr: - name: Create Changeset PR - if: github.event.inputs.release_mode == 'changeset-pr' - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - - - name: Install dependencies - run: npm install - - - name: Create changeset file - run: | - # Get description from workflow input or use default - DESCRIPTION="${{ github.event.inputs.description }}" - - # Run the create-manual-changeset script - if [ -n "$DESCRIPTION" ]; then - node scripts/create-manual-changeset.mjs "${{ github.event.inputs.bump_type }}" "$DESCRIPTION" - else - node scripts/create-manual-changeset.mjs "${{ github.event.inputs.bump_type }}" - fi - - - name: Format changeset with Prettier - run: | - # Run Prettier on the changeset file to ensure it matches project style - npx prettier --write ".changeset/*.md" || true - - echo "Formatted changeset files" - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' - branch: changeset-manual-release-${{ github.run_id }} - delete-branch: true - title: 'chore: manual ${{ github.event.inputs.bump_type }} release' - body: | - ## Manual Release Request - - This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. - - ### Release Details - - **Type:** ${{ github.event.inputs.bump_type }} - - **Description:** ${{ github.event.inputs.description || 'Manual release' }} - - **Triggered by:** @${{ github.actor }} - - ### Next Steps - 1. Review the changeset in this PR - 2. Merge this PR to main - 3. The automated release workflow will version the package and publish to npm using OIDC trusted publishing - - --- - - 🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/reference-workflows/test-anywhere-manual-release.yml b/reference-workflows/test-anywhere-manual-release.yml new file mode 100644 index 0000000..3d6c62e --- /dev/null +++ b/reference-workflows/test-anywhere-manual-release.yml @@ -0,0 +1,105 @@ +name: Manual Release + +on: + workflow_dispatch: + inputs: + release_mode: + description: 'Release mode' + required: true + type: choice + default: 'instant' + options: + - instant + - changeset-pr + bump_type: + description: 'Release type' + required: true + type: choice + options: + - patch + - minor + - major + description: + description: 'Release description (optional)' + required: false + type: string + +jobs: + # Instant release - commits directly to main and publishes + instant-release: + name: Instant Release + if: github.event.inputs.release_mode == 'instant' + uses: ./.github/workflows/common.yml + with: + node-version: '20.x' + skip-changeset-check: true + commit-message-suffix: 'Manual ${{ github.event.inputs.bump_type }} release' + release_mode: 'instant' + bump_type: ${{ github.event.inputs.bump_type }} + description: ${{ github.event.inputs.description }} + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Changeset PR - creates a pull request with the changeset for review + changeset-pr: + name: Create Changeset PR + if: github.event.inputs.release_mode == 'changeset-pr' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: npm install + + - name: Create changeset file + run: | + # Get description from workflow input or use default + DESCRIPTION="${{ github.event.inputs.description }}" + + # Run the create-manual-changeset script + if [ -n "$DESCRIPTION" ]; then + node scripts/create-manual-changeset.mjs "${{ github.event.inputs.bump_type }}" "$DESCRIPTION" + else + node scripts/create-manual-changeset.mjs "${{ github.event.inputs.bump_type }}" + fi + + - name: Format changeset with Prettier + run: | + # Run Prettier on the changeset file to ensure it matches project style + npx prettier --write ".changeset/*.md" || true + + echo "Formatted changeset files" + + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: add changeset for manual ${{ github.event.inputs.bump_type }} release' + branch: changeset-manual-release-${{ github.run_id }} + delete-branch: true + title: 'chore: manual ${{ github.event.inputs.bump_type }} release' + body: | + ## Manual Release Request + + This PR was created by a manual workflow trigger to prepare a **${{ github.event.inputs.bump_type }}** release. + + ### Release Details + - **Type:** ${{ github.event.inputs.bump_type }} + - **Description:** ${{ github.event.inputs.description || 'Manual release' }} + - **Triggered by:** @${{ github.actor }} + + ### Next Steps + 1. Review the changeset in this PR + 2. Merge this PR to main + 3. The automated release workflow will create a version PR + 4. Merge the version PR to publish to npm and create a GitHub release diff --git a/scripts/create-github-release.mjs b/scripts/create-github-release.mjs new file mode 100644 index 0000000..7cc0ae0 --- /dev/null +++ b/scripts/create-github-release.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +/** + * Create GitHub Release from CHANGELOG.md + * Usage: node scripts/create-github-release.mjs --release-version --repository + * release-version: Version number (e.g., 1.0.0) + * repository: GitHub repository (e.g., owner/repo) + * + * Uses link-foundation libraries: + * - use-m: Dynamic package loading without package.json dependencies + * - command-stream: Modern shell command execution with streaming support + * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files + */ + +import { readFileSync } from 'fs'; + +// Load use-m dynamically +const { use } = eval( + await (await fetch('https://unpkg.com/use-m/use.js')).text() +); + +// Import link-foundation libraries +const { $ } = await use('command-stream'); +const { makeConfig } = await use('lino-arguments'); + +// Parse CLI arguments using lino-arguments +// Note: Using --release-version instead of --version to avoid conflict with yargs' built-in --version flag +const config = makeConfig({ + yargs: ({ yargs, getenv }) => + yargs + .option('release-version', { + type: 'string', + default: getenv('VERSION', ''), + describe: 'Version number (e.g., 1.0.0)', + }) + .option('repository', { + type: 'string', + default: getenv('REPOSITORY', ''), + describe: 'GitHub repository (e.g., owner/repo)', + }), +}); + +const { releaseVersion: version, repository } = config; + +if (!version || !repository) { + console.error('Error: Missing required arguments'); + console.error( + 'Usage: node scripts/create-github-release.mjs --release-version --repository ' + ); + process.exit(1); +} + +const tag = `v${version}`; + +console.log(`Creating GitHub release for ${tag}...`); + +try { + // Read CHANGELOG.md + const changelog = readFileSync('./CHANGELOG.md', 'utf8'); + + // Extract changelog entry for this version + // Read from CHANGELOG.md between this version header and the next version header + const versionHeaderRegex = new RegExp(`## ${version}[\\s\\S]*?(?=## \\d|$)`); + const match = changelog.match(versionHeaderRegex); + + let releaseNotes = ''; + if (match) { + // Remove the version header itself and trim + releaseNotes = match[0].replace(`## ${version}`, '').trim(); + } + + if (!releaseNotes) { + releaseNotes = `Release ${version}`; + } + + // Create release using GitHub API with JSON input + // This avoids shell escaping issues that occur when passing text via command-line arguments + // (Previously caused apostrophes like "didn't" to appear as "didn'''" in releases) + // See: docs/case-studies/issue-135 for detailed analysis + const payload = JSON.stringify({ + tag_name: tag, + name: version, + body: releaseNotes, + }); + + await $`gh api repos/${repository}/releases -X POST --input -`.run({ + stdin: payload, + }); + + console.log(`\u2705 Created GitHub release: ${tag}`); +} catch (error) { + console.error('Error creating release:', error.message); + process.exit(1); +} diff --git a/scripts/format-github-release.mjs b/scripts/format-github-release.mjs new file mode 100644 index 0000000..d40a20c --- /dev/null +++ b/scripts/format-github-release.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +/** + * Format GitHub release notes using the format-release-notes.mjs script + * Usage: node scripts/format-github-release.mjs --release-version --repository --commit-sha + * release-version: Version number (e.g., 1.0.0) + * repository: GitHub repository (e.g., owner/repo) + * commit_sha: Commit SHA for PR detection + * + * Uses link-foundation libraries: + * - use-m: Dynamic package loading without package.json dependencies + * - command-stream: Modern shell command execution with streaming support + * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files + */ + +// Load use-m dynamically +const { use } = eval( + await (await fetch('https://unpkg.com/use-m/use.js')).text() +); + +// Import link-foundation libraries +const { $ } = await use('command-stream'); +const { makeConfig } = await use('lino-arguments'); + +// Parse CLI arguments using lino-arguments +// Note: Using --release-version instead of --version to avoid conflict with yargs' built-in --version flag +const config = makeConfig({ + yargs: ({ yargs, getenv }) => + yargs + .option('release-version', { + type: 'string', + default: getenv('VERSION', ''), + describe: 'Version number (e.g., 1.0.0)', + }) + .option('repository', { + type: 'string', + default: getenv('REPOSITORY', ''), + describe: 'GitHub repository (e.g., owner/repo)', + }) + .option('commit-sha', { + type: 'string', + default: getenv('COMMIT_SHA', ''), + describe: 'Commit SHA for PR detection', + }), +}); + +const { releaseVersion: version, repository, commitSha } = config; + +if (!version || !repository || !commitSha) { + console.error('Error: Missing required arguments'); + console.error( + 'Usage: node scripts/format-github-release.mjs --release-version --repository --commit-sha ' + ); + process.exit(1); +} + +const tag = `v${version}`; + +try { + // Get the release ID for this version + let releaseId = ''; + try { + const result = + await $`gh api "repos/${repository}/releases/tags/${tag}" --jq '.id'`.run( + { capture: true } + ); + releaseId = result.stdout.trim(); + } catch { + console.log(`\u26A0\uFE0F Could not find release for ${tag}`); + process.exit(0); + } + + if (releaseId) { + console.log(`Formatting release notes for ${tag}...`); + // Pass the trigger commit SHA for PR detection + // This allows proper PR lookup even if the changelog doesn't have a commit hash + await $`node scripts/format-release-notes.mjs --release-id "${releaseId}" --release-version "${tag}" --repository "${repository}" --commit-sha "${commitSha}"`; + console.log(`\u2705 Formatted release notes for ${tag}`); + } +} catch (error) { + console.error('Error formatting release:', error.message); + process.exit(1); +} diff --git a/scripts/publish-to-npm.mjs b/scripts/publish-to-npm.mjs new file mode 100644 index 0000000..3bc85a2 --- /dev/null +++ b/scripts/publish-to-npm.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +/** + * Publish to npm using OIDC trusted publishing + * Usage: node scripts/publish-to-npm.mjs [--should-pull] + * should_pull: Optional flag to pull latest changes before publishing (for release job) + * + * Uses link-foundation libraries: + * - use-m: Dynamic package loading without package.json dependencies + * - command-stream: Modern shell command execution with streaming support + * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files + */ + +import { readFileSync, appendFileSync } from 'fs'; + +// Load use-m dynamically +const { use } = eval( + await (await fetch('https://unpkg.com/use-m/use.js')).text() +); + +// Import link-foundation libraries +const { $ } = await use('command-stream'); +const { makeConfig } = await use('lino-arguments'); + +// Parse CLI arguments using lino-arguments +const config = makeConfig({ + yargs: ({ yargs, getenv }) => + yargs.option('should-pull', { + type: 'boolean', + default: getenv('SHOULD_PULL', false), + describe: 'Pull latest changes before publishing', + }), +}); + +const { shouldPull } = config; +const MAX_RETRIES = 3; +const RETRY_DELAY = 10000; // 10 seconds + +/** + * Sleep for specified milliseconds + * @param {number} ms + */ +function sleep(ms) { + return new Promise((resolve) => globalThis.setTimeout(resolve, ms)); +} + +/** + * Append to GitHub Actions output file + * @param {string} key + * @param {string} value + */ +function setOutput(key, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${key}=${value}\n`); + } +} + +async function main() { + try { + if (shouldPull) { + // Pull the latest changes we just pushed + await $`git pull origin main`; + } + + // Get current version + const packageJson = JSON.parse(readFileSync('./package.json', 'utf8')); + const currentVersion = packageJson.version; + console.log(`Current version to publish: ${currentVersion}`); + + // Check if this version is already published on npm + console.log( + `Checking if version ${currentVersion} is already published...` + ); + const checkResult = + await $`npm view "test-anywhere@${currentVersion}" version`.run({ + capture: true, + }); + + // command-stream returns { code: 0 } on success, { code: 1 } on failure (e.g., E404) + // Exit code 0 means version exists, non-zero means version not found + if (checkResult.code === 0) { + console.log(`Version ${currentVersion} is already published to npm`); + setOutput('published', 'true'); + setOutput('published_version', currentVersion); + setOutput('already_published', 'true'); + return; + } else { + // Version not found on npm (E404), proceed with publish + console.log( + `Version ${currentVersion} not found on npm, proceeding with publish...` + ); + } + + // Publish to npm using OIDC trusted publishing with retry logic + for (let i = 1; i <= MAX_RETRIES; i++) { + console.log(`Publish attempt ${i} of ${MAX_RETRIES}...`); + try { + await $`npm run changeset:publish`; + setOutput('published', 'true'); + setOutput('published_version', currentVersion); + console.log(`\u2705 Published test-anywhere@${currentVersion} to npm`); + return; + } catch { + if (i < MAX_RETRIES) { + console.log( + `Publish failed, waiting ${RETRY_DELAY / 1000}s before retry...` + ); + await sleep(RETRY_DELAY); + } + } + } + + console.error(`\u274C Failed to publish after ${MAX_RETRIES} attempts`); + process.exit(1); + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +main(); diff --git a/scripts/setup-npm.mjs b/scripts/setup-npm.mjs new file mode 100644 index 0000000..03e010a --- /dev/null +++ b/scripts/setup-npm.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +/** + * Update npm for OIDC trusted publishing + * npm trusted publishing requires npm >= 11.5.1 + * Node.js 20.x ships with npm 10.x, so we need to update + * + * Uses link-foundation libraries: + * - use-m: Dynamic package loading without package.json dependencies + * - command-stream: Modern shell command execution with streaming support + */ + +// Load use-m dynamically +const { use } = eval( + await (await fetch('https://unpkg.com/use-m/use.js')).text() +); + +// Import command-stream for shell command execution +const { $ } = await use('command-stream'); + +try { + // Get current npm version + const currentResult = await $`npm --version`.run({ capture: true }); + const currentVersion = currentResult.stdout.trim(); + console.log(`Current npm version: ${currentVersion}`); + + // Update npm to latest + await $`npm install -g npm@latest`; + + // Get updated npm version + const updatedResult = await $`npm --version`.run({ capture: true }); + const updatedVersion = updatedResult.stdout.trim(); + console.log(`Updated npm version: ${updatedVersion}`); +} catch (error) { + console.error('Error updating npm:', error.message); + process.exit(1); +} diff --git a/scripts/version-and-commit.mjs b/scripts/version-and-commit.mjs new file mode 100644 index 0000000..2e3ad54 --- /dev/null +++ b/scripts/version-and-commit.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node + +/** + * Version packages and commit to main + * Usage: node scripts/version-and-commit.mjs --mode [--bump-type ] [--description ] + * changeset: Run changeset version + * instant: Run instant version bump with bump_type (patch|minor|major) and optional description + * + * Uses link-foundation libraries: + * - use-m: Dynamic package loading without package.json dependencies + * - command-stream: Modern shell command execution with streaming support + * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files + */ + +import { readFileSync, appendFileSync, readdirSync } from 'fs'; + +// Load use-m dynamically +const { use } = eval( + await (await fetch('https://unpkg.com/use-m/use.js')).text() +); + +// Import link-foundation libraries +const { $ } = await use('command-stream'); +const { makeConfig } = await use('lino-arguments'); + +// Parse CLI arguments using lino-arguments +const config = makeConfig({ + yargs: ({ yargs, getenv }) => + yargs + .option('mode', { + type: 'string', + default: getenv('MODE', 'changeset'), + describe: 'Version mode: changeset or instant', + choices: ['changeset', 'instant'], + }) + .option('bump-type', { + type: 'string', + default: getenv('BUMP_TYPE', ''), + describe: 'Version bump type for instant mode: major, minor, or patch', + }) + .option('description', { + type: 'string', + default: getenv('DESCRIPTION', ''), + describe: 'Description for instant version bump', + }), +}); + +const { mode, bumpType, description } = config; + +// Debug: Log parsed configuration +console.log('Parsed configuration:', { + mode, + bumpType, + description: description || '(none)', +}); + +// Detect if positional arguments were used (common mistake) +const args = process.argv.slice(2); +if (args.length > 0 && !args[0].startsWith('--')) { + console.error('Error: Positional arguments detected!'); + console.error('Command line arguments:', args); + console.error(''); + console.error( + 'This script requires named arguments (--mode, --bump-type, --description).' + ); + console.error('Usage:'); + console.error(' Changeset mode:'); + console.error(' node scripts/version-and-commit.mjs --mode changeset'); + console.error(' Instant mode:'); + console.error( + ' node scripts/version-and-commit.mjs --mode instant --bump-type [--description ]' + ); + console.error(''); + console.error('Examples:'); + console.error( + ' node scripts/version-and-commit.mjs --mode instant --bump-type patch --description "Fix bug"' + ); + console.error(' node scripts/version-and-commit.mjs --mode changeset'); + process.exit(1); +} + +// Validation: Ensure mode is set correctly +if (mode !== 'changeset' && mode !== 'instant') { + console.error(`Invalid mode: "${mode}". Expected "changeset" or "instant".`); + console.error('Command line arguments:', process.argv.slice(2)); + process.exit(1); +} + +// Validation: Ensure bump type is provided for instant mode +if (mode === 'instant' && !bumpType) { + console.error('Error: --bump-type is required for instant mode'); + console.error( + 'Usage: node scripts/version-and-commit.mjs --mode instant --bump-type [--description ]' + ); + process.exit(1); +} + +/** + * Append to GitHub Actions output file + * @param {string} key + * @param {string} value + */ +function setOutput(key, value) { + const outputFile = process.env.GITHUB_OUTPUT; + if (outputFile) { + appendFileSync(outputFile, `${key}=${value}\n`); + } +} + +/** + * Count changeset files (excluding README.md) + */ +function countChangesets() { + try { + const changesetDir = '.changeset'; + const files = readdirSync(changesetDir); + return files.filter((f) => f.endsWith('.md') && f !== 'README.md').length; + } catch { + return 0; + } +} + +/** + * Get package version + * @param {string} source - 'local' or 'remote' + */ +async function getVersion(source = 'local') { + if (source === 'remote') { + const result = await $`git show origin/main:package.json`.run({ + capture: true, + }); + return JSON.parse(result.stdout).version; + } + return JSON.parse(readFileSync('./package.json', 'utf8')).version; +} + +async function main() { + try { + // Configure git + await $`git config user.name "github-actions[bot]"`; + await $`git config user.email "github-actions[bot]@users.noreply.github.com"`; + + // Check if remote main has advanced (handles re-runs after partial success) + console.log('Checking for remote changes...'); + await $`git fetch origin main`; + + const localHeadResult = await $`git rev-parse HEAD`.run({ capture: true }); + const localHead = localHeadResult.stdout.trim(); + + const remoteHeadResult = await $`git rev-parse origin/main`.run({ + capture: true, + }); + const remoteHead = remoteHeadResult.stdout.trim(); + + if (localHead !== remoteHead) { + console.log( + `Remote main has advanced (local: ${localHead}, remote: ${remoteHead})` + ); + console.log('This may indicate a previous attempt partially succeeded.'); + + // Check if the remote version is already the expected bump + const remoteVersion = await getVersion('remote'); + console.log(`Remote version: ${remoteVersion}`); + + // Check if there are changesets to process + const changesetCount = countChangesets(); + + if (changesetCount === 0) { + console.log('No changesets to process and remote has advanced.'); + console.log( + 'Assuming version bump was already completed in a previous attempt.' + ); + setOutput('version_committed', 'false'); + setOutput('already_released', 'true'); + setOutput('new_version', remoteVersion); + return; + } else { + console.log('Rebasing on remote main to incorporate changes...'); + await $`git rebase origin/main`; + } + } + + // Get current version before bump + const oldVersion = await getVersion(); + console.log(`Current version: ${oldVersion}`); + + if (mode === 'instant') { + console.log('Running instant version bump...'); + // Run instant version bump script + // Rely on command-stream's auto-quoting for proper argument handling + if (description) { + await $`node scripts/instant-version-bump.mjs --bump-type ${bumpType} --description ${description}`; + } else { + await $`node scripts/instant-version-bump.mjs --bump-type ${bumpType}`; + } + } else { + console.log('Running changeset version...'); + // Run changeset version to bump versions and update CHANGELOG + await $`npm run changeset:version`; + } + + // Get new version after bump + const newVersion = await getVersion(); + console.log(`New version: ${newVersion}`); + setOutput('new_version', newVersion); + + // Check if there are changes to commit + const statusResult = await $`git status --porcelain`.run({ capture: true }); + const status = statusResult.stdout.trim(); + + if (status) { + console.log('Changes detected, committing...'); + + // Stage all changes (package.json, package-lock.json, CHANGELOG.md, deleted changesets) + await $`git add -A`; + + // Commit with version number as message + const commitMessage = newVersion; + const escapedMessage = commitMessage.replace(/"/g, '\\"'); + await $`git commit -m "${escapedMessage}"`; + + // Push directly to main + await $`git push origin main`; + + console.log('\u2705 Version bump committed and pushed to main'); + setOutput('version_committed', 'true'); + } else { + console.log('No changes to commit'); + setOutput('version_committed', 'false'); + } + } catch (error) { + console.error('Error:', error.message); + process.exit(1); + } +} + +main(); diff --git a/tests/index.test.js b/tests/index.test.js index 2727a4f..4150563 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -1,5 +1,4 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert'; +import { describe, it, expect } from 'test-anywhere'; import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { @@ -20,114 +19,114 @@ import { describe('Case Conversion Utilities', () => { describe('toUpperCase', () => { it('should convert camelCase to UPPER_CASE', () => { - assert.strictEqual(toUpperCase('apiKey'), 'API_KEY'); - assert.strictEqual(toUpperCase('myVariableName'), 'MY_VARIABLE_NAME'); + expect(toUpperCase('apiKey')).toBe('API_KEY'); + expect(toUpperCase('myVariableName')).toBe('MY_VARIABLE_NAME'); }); it('should convert kebab-case to UPPER_CASE', () => { - assert.strictEqual(toUpperCase('api-key'), 'API_KEY'); - assert.strictEqual(toUpperCase('my-variable-name'), 'MY_VARIABLE_NAME'); + expect(toUpperCase('api-key')).toBe('API_KEY'); + expect(toUpperCase('my-variable-name')).toBe('MY_VARIABLE_NAME'); }); it('should convert snake_case to UPPER_CASE', () => { - assert.strictEqual(toUpperCase('api_key'), 'API_KEY'); - assert.strictEqual(toUpperCase('my_variable_name'), 'MY_VARIABLE_NAME'); + expect(toUpperCase('api_key')).toBe('API_KEY'); + expect(toUpperCase('my_variable_name')).toBe('MY_VARIABLE_NAME'); }); it('should convert PascalCase to UPPER_CASE', () => { - assert.strictEqual(toUpperCase('ApiKey'), 'API_KEY'); - assert.strictEqual(toUpperCase('MyVariableName'), 'MY_VARIABLE_NAME'); + expect(toUpperCase('ApiKey')).toBe('API_KEY'); + expect(toUpperCase('MyVariableName')).toBe('MY_VARIABLE_NAME'); }); it('should handle already UPPER_CASE', () => { - assert.strictEqual(toUpperCase('API_KEY'), 'API_KEY'); + expect(toUpperCase('API_KEY')).toBe('API_KEY'); }); }); describe('toCamelCase', () => { it('should convert kebab-case to camelCase', () => { - assert.strictEqual(toCamelCase('api-key'), 'apiKey'); - assert.strictEqual(toCamelCase('my-variable-name'), 'myVariableName'); + expect(toCamelCase('api-key')).toBe('apiKey'); + expect(toCamelCase('my-variable-name')).toBe('myVariableName'); }); it('should convert UPPER_CASE to camelCase', () => { - assert.strictEqual(toCamelCase('API_KEY'), 'apiKey'); - assert.strictEqual(toCamelCase('MY_VARIABLE_NAME'), 'myVariableName'); + expect(toCamelCase('API_KEY')).toBe('apiKey'); + expect(toCamelCase('MY_VARIABLE_NAME')).toBe('myVariableName'); }); it('should convert snake_case to camelCase', () => { - assert.strictEqual(toCamelCase('api_key'), 'apiKey'); - assert.strictEqual(toCamelCase('my_variable_name'), 'myVariableName'); + expect(toCamelCase('api_key')).toBe('apiKey'); + expect(toCamelCase('my_variable_name')).toBe('myVariableName'); }); it('should convert PascalCase to camelCase', () => { - assert.strictEqual(toCamelCase('ApiKey'), 'apikey'); - assert.strictEqual(toCamelCase('MyVariableName'), 'myvariablename'); + expect(toCamelCase('ApiKey')).toBe('apikey'); + expect(toCamelCase('MyVariableName')).toBe('myvariablename'); }); it('should handle already camelCase', () => { - assert.strictEqual(toCamelCase('apiKey'), 'apikey'); + expect(toCamelCase('apiKey')).toBe('apikey'); }); }); describe('toKebabCase', () => { it('should convert camelCase to kebab-case', () => { - assert.strictEqual(toKebabCase('apiKey'), 'api-key'); - assert.strictEqual(toKebabCase('myVariableName'), 'my-variable-name'); + expect(toKebabCase('apiKey')).toBe('api-key'); + expect(toKebabCase('myVariableName')).toBe('my-variable-name'); }); it('should convert UPPER_CASE to kebab-case', () => { - assert.strictEqual(toKebabCase('API_KEY'), 'api-key'); - assert.strictEqual(toKebabCase('MY_VARIABLE_NAME'), 'my-variable-name'); + expect(toKebabCase('API_KEY')).toBe('api-key'); + expect(toKebabCase('MY_VARIABLE_NAME')).toBe('my-variable-name'); }); it('should convert PascalCase to kebab-case', () => { - assert.strictEqual(toKebabCase('ApiKey'), 'api-key'); - assert.strictEqual(toKebabCase('MyVariableName'), 'my-variable-name'); + expect(toKebabCase('ApiKey')).toBe('api-key'); + expect(toKebabCase('MyVariableName')).toBe('my-variable-name'); }); it('should handle already kebab-case', () => { - assert.strictEqual(toKebabCase('api-key'), 'api-key'); + expect(toKebabCase('api-key')).toBe('api-key'); }); }); describe('toSnakeCase', () => { it('should convert camelCase to snake_case', () => { - assert.strictEqual(toSnakeCase('apiKey'), 'api_key'); - assert.strictEqual(toSnakeCase('myVariableName'), 'my_variable_name'); + expect(toSnakeCase('apiKey')).toBe('api_key'); + expect(toSnakeCase('myVariableName')).toBe('my_variable_name'); }); it('should convert kebab-case to snake_case', () => { - assert.strictEqual(toSnakeCase('api-key'), 'api_key'); - assert.strictEqual(toSnakeCase('my-variable-name'), 'my_variable_name'); + expect(toSnakeCase('api-key')).toBe('api_key'); + expect(toSnakeCase('my-variable-name')).toBe('my_variable_name'); }); it('should convert UPPER_CASE to snake_case', () => { - assert.strictEqual(toSnakeCase('API_KEY'), 'api_key'); + expect(toSnakeCase('API_KEY')).toBe('api_key'); }); it('should handle already snake_case', () => { - assert.strictEqual(toSnakeCase('api_key'), 'api_key'); + expect(toSnakeCase('api_key')).toBe('api_key'); }); }); describe('toPascalCase', () => { it('should convert camelCase to PascalCase', () => { - assert.strictEqual(toPascalCase('apiKey'), 'Apikey'); + expect(toPascalCase('apiKey')).toBe('Apikey'); }); it('should convert kebab-case to PascalCase', () => { - assert.strictEqual(toPascalCase('api-key'), 'ApiKey'); - assert.strictEqual(toPascalCase('my-variable-name'), 'MyVariableName'); + expect(toPascalCase('api-key')).toBe('ApiKey'); + expect(toPascalCase('my-variable-name')).toBe('MyVariableName'); }); it('should convert snake_case to PascalCase', () => { - assert.strictEqual(toPascalCase('api_key'), 'ApiKey'); - assert.strictEqual(toPascalCase('my_variable_name'), 'MyVariableName'); + expect(toPascalCase('api_key')).toBe('ApiKey'); + expect(toPascalCase('my_variable_name')).toBe('MyVariableName'); }); it('should handle already PascalCase', () => { - assert.strictEqual(toPascalCase('ApiKey'), 'Apikey'); + expect(toPascalCase('ApiKey')).toBe('Apikey'); }); }); }); @@ -157,9 +156,9 @@ describe('getenv', () => { cleanupTestVars(); try { process.env.TEST_VAR = 'value'; - assert.strictEqual(getenv('testVar'), 'value'); - assert.strictEqual(getenv('test-var'), 'value'); - assert.strictEqual(getenv('TEST_VAR'), 'value'); + expect(getenv('testVar')).toBe('value'); + expect(getenv('test-var')).toBe('value'); + expect(getenv('TEST_VAR')).toBe('value'); } finally { restoreEnv(); } @@ -169,8 +168,8 @@ describe('getenv', () => { cleanupTestVars(); try { process.env.testVar = 'value'; - assert.strictEqual(getenv('TEST_VAR'), 'value'); - assert.strictEqual(getenv('test-var'), 'value'); + expect(getenv('TEST_VAR')).toBe('value'); + expect(getenv('test-var')).toBe('value'); } finally { restoreEnv(); } @@ -180,7 +179,7 @@ describe('getenv', () => { cleanupTestVars(); try { process.env['test-var'] = 'value'; - assert.strictEqual(getenv('testVar'), 'value'); + expect(getenv('testVar')).toBe('value'); } finally { restoreEnv(); } @@ -189,7 +188,7 @@ describe('getenv', () => { it('should return default value when not found', () => { cleanupTestVars(); try { - assert.strictEqual(getenv('NON_EXISTENT', 'default'), 'default'); + expect(getenv('NON_EXISTENT', 'default')).toBe('default'); } finally { restoreEnv(); } @@ -198,7 +197,7 @@ describe('getenv', () => { it('should return empty string as default when not specified', () => { cleanupTestVars(); try { - assert.strictEqual(getenv('NON_EXISTENT'), ''); + expect(getenv('NON_EXISTENT')).toBe(''); } finally { restoreEnv(); } @@ -209,7 +208,7 @@ describe('getenv', () => { try { process.env.myKey = 'original'; process.env.MY_KEY = 'upper'; - assert.strictEqual(getenv('myKey'), 'original'); + expect(getenv('myKey')).toBe('original'); } finally { restoreEnv(); } @@ -263,8 +262,8 @@ describe('makeConfig', () => { argv: ['node', 'script.js'], }); - assert.strictEqual(config.port, 3000); - assert.strictEqual(config.verbose, false); + expect(config.port).toBe(3000); + expect(config.verbose).toBe(false); } finally { cleanup(); } @@ -281,8 +280,8 @@ describe('makeConfig', () => { argv: ['node', 'script.js', '--port', '8080', '--verbose'], }); - assert.strictEqual(config.port, 8080); - assert.strictEqual(config.verbose, true); + expect(config.port).toBe(8080); + expect(config.verbose).toBe(true); } finally { cleanup(); } @@ -297,8 +296,8 @@ describe('makeConfig', () => { argv: ['node', 'script.js'], }); - assert.strictEqual(config.apiKey, 'key123'); - assert.strictEqual(config['api-key'], undefined); + expect(config.apiKey).toBe('key123'); + expect(config['api-key']).toBe(undefined); } finally { cleanup(); } @@ -321,8 +320,8 @@ describe('makeConfig', () => { argv: ['node', 'script.js'], }); - assert.strictEqual(config.port, 5000); - assert.strictEqual(process.env.APP_PORT, '5000'); + expect(config.port).toBe(5000); + expect(process.env.APP_PORT).toBe('5000'); } finally { cleanup(); } @@ -345,8 +344,8 @@ describe('makeConfig', () => { }); // --configuration should override default .lenv - assert.strictEqual(config.port, 9000); - assert.strictEqual(process.env.APP_PORT, '9000'); + expect(config.port).toBe(9000); + expect(process.env.APP_PORT).toBe('9000'); } finally { cleanup(); } @@ -368,7 +367,7 @@ describe('makeConfig', () => { }); // CLI should have highest priority - assert.strictEqual(config.port, 7000); + expect(config.port).toBe(7000); } finally { cleanup(); } @@ -387,7 +386,7 @@ describe('makeConfig', () => { argv: ['node', 'script.js'], }); - assert.strictEqual(config.port, 3000); + expect(config.port).toBe(3000); } finally { cleanup(); } @@ -409,9 +408,9 @@ describe('makeConfig', () => { argv: ['node', 'script.js'], }); - assert.strictEqual(process.env.API_KEY, 'key123'); - assert.strictEqual(process.env.MY_PORT, '3000'); - assert.strictEqual(process.env.MY_HOST, 'localhost'); + expect(process.env.API_KEY).toBe('key123'); + expect(process.env.MY_PORT).toBe('3000'); + expect(process.env.MY_HOST).toBe('localhost'); } finally { cleanup(); } @@ -437,9 +436,9 @@ describe('makeConfig', () => { }); // getenv should find API_KEY when asked for apiKey - assert.strictEqual(config.apiKey, 'secret'); + expect(config.apiKey).toBe('secret'); // Non-existent should use default - assert.strictEqual(config.anotherKey, 'default'); + expect(config.anotherKey).toBe('default'); } finally { cleanup(); } @@ -463,8 +462,8 @@ describe('makeConfig', () => { }); // Should not load from .lenv - assert.strictEqual(config.port, 3000); - assert.strictEqual(process.env.APP_PORT, undefined); + expect(config.port).toBe(3000); + expect(process.env.APP_PORT).toBe(undefined); } finally { cleanup(); } @@ -481,7 +480,7 @@ describe('makeConfig', () => { argv: ['node', 'script.js'], }); - assert.strictEqual(config.test, false); + expect(config.test).toBe(false); } finally { cleanup(); } @@ -503,7 +502,7 @@ describe('makeConfig', () => { argv: ['node', 'script.js'], }); - assert.strictEqual(config.port, 3000); + expect(config.port).toBe(3000); } finally { cleanup(); } @@ -523,7 +522,7 @@ describe('makeConfig', () => { argv: ['node', 'script.js', '-c', testConfigFile], }); - assert.strictEqual(config.port, 9000); + expect(config.port).toBe(9000); } finally { cleanup(); } @@ -566,9 +565,9 @@ describe('makeConfig', () => { argv: ['node', 'script.js'], }); - assert.strictEqual(config.port, 3000); - assert.strictEqual(config.host, 'localhost'); - assert.strictEqual(config.name, 'base'); + expect(config.port).toBe(3000); + expect(config.host).toBe('localhost'); + expect(config.name).toBe('base'); // Clean env for next test delete process.env.APP_PORT; @@ -595,9 +594,9 @@ describe('makeConfig', () => { argv: ['node', 'script.js', '--configuration', testConfigFile], }); - assert.strictEqual(config.port, 5000); // from --configuration - assert.strictEqual(config.host, 'override-host'); // from --configuration - assert.strictEqual(config.name, 'base'); // from .lenv (not in --configuration) + expect(config.port).toBe(5000); // from --configuration + expect(config.host).toBe('override-host'); // from --configuration + expect(config.name).toBe('base'); // from .lenv (not in --configuration) // Clean env for next test delete process.env.APP_PORT; @@ -631,9 +630,9 @@ describe('makeConfig', () => { ], }); - assert.strictEqual(config.port, 9000); // from CLI (highest priority) - assert.strictEqual(config.host, 'override-host'); // from --configuration - assert.strictEqual(config.name, 'base'); // from .lenv + expect(config.port).toBe(9000); // from CLI (highest priority) + expect(config.host).toBe('override-host'); // from --configuration + expect(config.name).toBe('base'); // from .lenv } finally { cleanup(); } @@ -649,34 +648,34 @@ describe('parseLinoArguments (legacy)', () => { it('should parse simple links notation format', () => { const input = '(\n --verbose\n --port 3000\n)'; const result = parseLinoArguments(input); - assert.ok(result.includes('--verbose')); - assert.ok(result.includes('--port')); - assert.ok(result.includes('3000')); + expect(result.includes('--verbose')).toBeTruthy(); + expect(result.includes('--port')).toBeTruthy(); + expect(result.includes('3000')).toBeTruthy(); }); it('should parse arguments without parentheses', () => { const input = '--debug\n--host localhost'; const result = parseLinoArguments(input); - assert.ok(result.includes('--debug')); - assert.ok(result.includes('--host')); - assert.ok(result.includes('localhost')); + expect(result.includes('--debug')).toBeTruthy(); + expect(result.includes('--host')).toBeTruthy(); + expect(result.includes('localhost')).toBeTruthy(); }); it('should handle empty input', () => { const result = parseLinoArguments(''); - assert.deepStrictEqual(result, []); + expect(result).toEqual([]); }); it('should handle null input', () => { const result = parseLinoArguments(null); - assert.deepStrictEqual(result, []); + expect(result).toEqual([]); }); it('should filter out comments', () => { const input = '# Comment\n--verbose\n# Another comment\n--debug'; const result = parseLinoArguments(input); - assert.ok(!result.some((arg) => arg.startsWith('#'))); - assert.ok(result.includes('--verbose')); - assert.ok(result.includes('--debug')); + expect(!result.some((arg) => arg.startsWith('#'))).toBeTruthy(); + expect(result.includes('--verbose')).toBeTruthy(); + expect(result.includes('--debug')).toBeTruthy(); }); });