diff --git a/.github/workflows/pr-package.yml b/.github/workflows/pr-package.yml new file mode 100644 index 0000000..9318be3 --- /dev/null +++ b/.github/workflows/pr-package.yml @@ -0,0 +1,167 @@ +name: Publish Preview Packages +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened, closed] + +permissions: + contents: read + pull-requests: write + +env: + PR_PACKAGE_HOST: pkg.ing + NODE_VERSION: "24" + PKG_DIR: node-utils + +jobs: + # ── Compute tags once so the publish + comment jobs use the same set. ───── + tag: + if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') + runs-on: ubuntu-latest + outputs: + tags: ${{ steps.tags.outputs.tags }} + short: ${{ steps.tags.outputs.short }} + steps: + - id: tags + env: + EVENT: ${{ github.event_name }} + BRANCH: ${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + # PR builds run on the merge commit by default; tag the actual head sha + # so consumers can pin to a specific PR commit. + SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + short="${SHA:0:7}" + long="$SHA" + tags=("$short" "$long" "$BRANCH") + if [ "$EVENT" = "pull_request" ]; then + tags+=("pr-${PR_NUMBER}") + fi + json=$(printf '%s\n' "${tags[@]}" | jq -R . | jq -s -c .) + echo "tags=$json" >> "$GITHUB_OUTPUT" + echo "short=$short" >> "$GITHUB_OUTPUT" + echo "Tags: $json" + + # ── Build + publish the package. ────────────────────────────────────────── + publish: + needs: tag + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install --frozen-lockfile + + - run: bun run build + + - name: Publish + env: + TOKEN: ${{ secrets.PR_PACKAGE_TOKEN }} + TAGS: ${{ needs.tag.outputs.tags }} + SHORT_SHA: ${{ needs.tag.outputs.short }} + working-directory: packages/${{ env.PKG_DIR }} + run: | + set -euo pipefail + PKG_NAME=$(node -p "require('./package.json').name") + rm -f *.tgz + bun pm pack --destination . + tgz=$(ls *.tgz) + echo "Publishing ${PKG_NAME} (${tgz}) with tags ${TAGS}" + curl -fsSL --show-error -X PUT \ + "https://${PR_PACKAGE_HOST}/projects/${PKG_NAME}/packages" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "X-Tags: ${TAGS}" \ + -H "Content-Type: application/gzip" \ + --data-binary "@${tgz}" + + INSTALL_URL="https://${PR_PACKAGE_HOST}/${PKG_NAME}/${SHORT_SHA}" + echo "::notice title=${PKG_NAME}::Install: bun add ${INSTALL_URL}" + { + echo "### ${PKG_NAME}" + echo + echo '```sh' + echo "bun add ${INSTALL_URL}" + echo '```' + echo + } >> "$GITHUB_STEP_SUMMARY" + + # ── PR-only: sticky comment with install URL pinned to this commit. ─────── + comment: + needs: [tag, publish] + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Comment on PR + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + SHORT_SHA: ${{ needs.tag.outputs.short }} + run: | + set -euo pipefail + MARKER="" + PKG_NAME=$(node -p "require('./packages/${PKG_DIR}/package.json').name") + + { + echo "$MARKER" + echo + echo "Install the package built from this commit:" + echo + echo "**${PKG_NAME}**" + echo '```sh' + echo "bun add https://${PR_PACKAGE_HOST}/${PKG_NAME}/${SHORT_SHA}" + echo '```' + } > /tmp/body.md + + BODY=$(cat /tmp/body.md) + + existing=$(gh api --paginate \ + "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | startswith(\"${MARKER}\")) | .id" \ + | head -1) + + if [ -n "$existing" ]; then + gh api -X PATCH "repos/${REPO}/issues/comments/${existing}" -f body="$BODY" + else + gh api -X POST "repos/${REPO}/issues/${PR_NUMBER}/comments" -f body="$BODY" + fi + + # ── main-only: post a commit comment with install URL pinned to this sha. ─ + commit-comment: + needs: [tag, publish] + if: github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Comment on commit + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + SHORT_SHA: ${{ needs.tag.outputs.short }} + run: | + set -euo pipefail + PKG_NAME=$(node -p "require('./packages/${PKG_DIR}/package.json').name") + { + echo "Package built from this commit (\`${SHORT_SHA}\`):" + echo + echo "**${PKG_NAME}**" + echo '```sh' + echo "bun add https://${PR_PACKAGE_HOST}/${PKG_NAME}/${SHORT_SHA}" + echo '```' + } > /tmp/body.md + + BODY=$(cat /tmp/body.md) + gh api -X POST "repos/${REPO}/commits/${SHA}/comments" -f body="$BODY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a575ca1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,245 @@ +name: Release NPM Packages + +on: + workflow_dispatch: + inputs: + bump: + description: "Bump type" + required: true + type: choice + options: + - patch + - minor + - major + version-override: + description: "Exact version override (e.g. 0.5.0-alpha.0). Leave empty to use bump type." + required: false + type: string + +permissions: + contents: write + id-token: write + +env: + NODE_VERSION: "24" + # Files produced by the bump job and consumed by the commit-and-tag job. + # All publishable packages share one version, so all three package.jsons + # plus bun.lock and CHANGELOG.md get rewritten. + BUMP_ARTIFACT_PATHS: | + packages/cloudflare-rolldown-plugin/package.json + packages/cloudflare-runtime/package.json + packages/cloudflare-vite-plugin/package.json + bun.lock + CHANGELOG.md + +jobs: + # ── Step 1: Compute the next version and stage the bump in-memory. ── + # No git commit: a failed publish leaves no orphan commit behind. The staged + # files ride to commit-and-tag as an artifact. + bump: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.bump.outputs.version }} + sha: ${{ steps.sha.outputs.sha }} + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - id: sha + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-node@v6 + with: + registry-url: https://registry.npmjs.org/ + node-version: ${{ env.NODE_VERSION }} + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: | + bun-${{ runner.os }}- + + - run: bun install + + - id: bump + run: | + set -euo pipefail + if [ -n "${{ inputs.version-override }}" ]; then + bun ./scripts/bump.ts "${{ inputs.version-override }}" + VERSION="${{ inputs.version-override }}" + else + bun ./scripts/bump.ts "${{ inputs.bump }}" + VERSION=$(node -p "require('./packages/cloudflare-rolldown-plugin/package.json').version") + fi + echo "Resolved version: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - uses: actions/upload-artifact@v4 + with: + name: bump-files + path: ${{ env.BUMP_ARTIFACT_PATHS }} + if-no-files-found: error + retention-days: 7 + + # ── Step 2: Commit + tag BEFORE publishing. + # Committing first means the publishable source of truth is the tagged commit; + # publish jobs check it out by SHA. Durability: if a previous attempt already + # committed and tagged but failed during npm publish, this job detects HEAD is + # already at the tag and skips the commit/push. + commit-and-tag: + needs: bump + runs-on: ubuntu-latest + outputs: + sha: ${{ steps.commit.outputs.sha }} + steps: + - name: Generate bot token + id: bot-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.ALCHEMY_VERSION_BOT_ID }} + private-key: ${{ secrets.ALCHEMY_VERSION_BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v6 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + token: ${{ steps.bot-token.outputs.token }} + + - uses: actions/download-artifact@v4 + with: + name: bump-files + + - name: Configure git + run: | + git config user.email "alchemy-version-bot[bot]@users.noreply.github.com" + git config user.name "alchemy-version-bot[bot]" + + - id: commit + name: Commit bump + changelog, tag, push + env: + VERSION: ${{ needs.bump.outputs.version }} + run: | + set -euo pipefail + TAG="v${VERSION}" + + # Durability: if HEAD already points at the release tag (resumed run + # after a previous attempt committed+tagged but publish failed), there + # is nothing to do. Use HEAD as the publish SHA. + HEAD_TAG=$(git describe --exact-match --tags HEAD 2>/dev/null || true) + if [ "$HEAD_TAG" = "$TAG" ]; then + echo "HEAD is already at ${TAG}; skipping commit/tag/push" + SHA=$(git rev-parse HEAD) + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git add -A + + if git diff --cached --quiet; then + echo "No changes to commit (already on a release commit?)" + else + git commit -m "chore(release): ${VERSION}" + fi + + if ! git rev-parse --verify "refs/tags/${TAG}" >/dev/null 2>&1; then + git tag -a "${TAG}" -m "Release ${TAG}" + fi + + git push origin HEAD + if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/dev/null 2>&1; then + echo "Tag ${TAG} already on remote, skipping tag push" + else + git push origin "refs/tags/${TAG}" + fi + + SHA=$(git rev-parse HEAD) + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + + publish-node-utils: + needs: [bump, commit-and-tag] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.commit-and-tag.outputs.sha }} + + - uses: actions/setup-node@v6 + with: + registry-url: https://registry.npmjs.org/ + node-version: ${{ env.NODE_VERSION }} + + - name: Upgrade npm for OIDC trusted publishing + run: | + npm install -g npm@latest && npm --version + sed -i '/always-auth/d' ~/.npmrc 2>/dev/null || true + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - run: bun install + + - run: bun run build + + - name: Publish + env: + VERSION: ${{ needs.bump.outputs.version }} + PKG_DIR: cloudflare-rolldown-plugin + run: | + set -euo pipefail + PKG_NAME=$(node -p "require('./packages/${PKG_DIR}/package.json').name") + echo "--- Publishing ${PKG_NAME}@${VERSION} ---" + if npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1; then + echo "${PKG_NAME}@${VERSION} already published, skipping" + exit 0 + fi + cd "packages/${PKG_DIR}" + bun pm pack --destination . + TARBALL=$(ls *.tgz | head -1) + TAG="latest" + if echo "${VERSION}" | grep -q '-'; then TAG="next"; fi + npm publish "${TARBALL}" --access public --tag "${TAG}" + rm -f *.tgz + echo "--- Published ${PKG_NAME}@${VERSION} ---" + + # ── Step 4: Create GitHub Release. ── + # Runs only after every publish job succeeded. Skipped for prerelease versions + # (those containing '-'). + finalize: + needs: [bump, commit-and-tag, publish-node-utils] + if: ${{ !contains(needs.bump.outputs.version, '-') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ needs.commit-and-tag.outputs.sha }} + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Create GitHub Release + env: + VERSION: ${{ needs.bump.outputs.version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="v${VERSION}" + if gh release view "${TAG}" >/dev/null 2>&1; then + echo "Release ${TAG} already exists, skipping" + exit 0 + fi + PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "") + if [ -n "$PREV_TAG" ]; then + bunx changelogithub --from "$PREV_TAG" --to "${TAG}" + else + bunx changelogithub --to "${TAG}" + fi diff --git a/README.md b/README.md index 9355730..475930a 100644 --- a/README.md +++ b/README.md @@ -1,183 +1,78 @@ -# proper-lockfile +# node-utils -[![NPM version][npm-image]][npm-url] [![Downloads][downloads-image]][npm-url] [![Build Status][travis-image]][travis-url] [![Coverage Status][codecov-image]][codecov-url] [![Dependency status][david-dm-image]][david-dm-url] [![Dev Dependency status][david-dm-dev-image]][david-dm-dev-url] +Modern ESM + TypeScript ports of a few classic Node CommonJS modules, vendored into a single zero-dependency package. -[npm-url]:https://npmjs.org/package/proper-lockfile -[downloads-image]:https://img.shields.io/npm/dm/proper-lockfile.svg -[npm-image]:https://img.shields.io/npm/v/proper-lockfile.svg -[travis-url]:https://travis-ci.org/moxystudio/node-proper-lockfile -[travis-image]:https://img.shields.io/travis/moxystudio/node-proper-lockfile/master.svg -[codecov-url]:https://codecov.io/gh/moxystudio/node-proper-lockfile -[codecov-image]:https://img.shields.io/codecov/c/github/moxystudio/node-proper-lockfile/master.svg -[david-dm-url]:https://david-dm.org/moxystudio/node-proper-lockfile -[david-dm-image]:https://img.shields.io/david/moxystudio/node-proper-lockfile.svg -[david-dm-dev-url]:https://david-dm.org/moxystudio/node-proper-lockfile?type=dev -[david-dm-dev-image]:https://img.shields.io/david/dev/moxystudio/node-proper-lockfile.svg +## Why? -An inter-process and inter-machine lockfile utility that works on a local or network file system. +The originals are stable but unmaintained, CJS-only, and pull in `@types/*` and transitive deps for code that's now smaller than its dependency tree. Vendoring + rewriting gives us: +- Pure ESM, native to Node 22+ and Bun. +- Real TypeScript types co-located with the implementation. +- No runtime npm dependencies — only Node built-ins. -## Installation +## What's vendored -`$ npm install proper-lockfile` +All of these live in `packages/node-utils/src/`: +| Original (CJS) | Rewritten as | +| ------------------------------------------------------------------------------------- | ------------------------------------- | +| [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) | `lockfile.ts` (+ `index.ts`) | +| [`retry`](https://github.com/tim-kos/node-retry) | `retry.ts` | +| [`signal-exit`](https://github.com/tapjs/signal-exit) | `exit-hook.ts` | +| [`graceful-fs`](https://github.com/isaacs/node-graceful-fs) sync/async shims | `adapter.ts` | -## Design +## `@alchemy.run/node-utils` -There are various ways to achieve [file locking](http://en.wikipedia.org/wiki/File_locking). +A `proper-lockfile`-equivalent file lock, with `retry`, `signal-exit`, and the `graceful-fs` adapter all bundled in. -This library utilizes the `mkdir` strategy which works atomically on any kind of file system, even network based ones. -The lockfile path is based on the file path you are trying to lock by suffixing it with `.lock`. - -When a lock is successfully acquired, the lockfile's `mtime` (modified time) is periodically updated to prevent staleness. This allows to effectively check if a lock is stale by checking its `mtime` against a stale threshold. If the update of the mtime fails several times, the lock might be compromised. The `mtime` is [supported](http://en.wikipedia.org/wiki/Comparison_of_file_systems) in almost every `filesystem`. - - -### Comparison - -This library is similar to [lockfile](https://github.com/isaacs/lockfile) but the latter has some drawbacks: - -- It relies on `open` with `O_EXCL` flag which has problems in network file systems. `proper-lockfile` uses `mkdir` which doesn't have this issue. - -> O_EXCL is broken on NFS file systems; programs which rely on it for performing locking tasks will contain a race condition. - -- The lockfile staleness check is done via `ctime` (creation time) which is unsuitable for long running processes. `proper-lockfile` constantly updates lockfiles `mtime` to do proper staleness check. - -- It does not check if the lockfile was compromised which can lead to undesirable situations. `proper-lockfile` checks the lockfile when updating the `mtime`. - -- It has a default value of `0` for the stale option which isn't good because any crash or process kill that the package can't handle gracefully will leave the lock active forever. - - -### Compromised - -`proper-lockfile` does not detect cases in which: - -- A `lockfile` is manually removed and someone else acquires the lock right after -- Different `stale`/`update` values are being used for the same file, possibly causing two locks to be acquired on the same file - -`proper-lockfile` detects cases in which: - -- Updates to the `lockfile` fail -- Updates take longer than expected, possibly causing the lock to become stale for a certain amount of time - - -As you see, the first two are a consequence of bad usage. Technically, it was possible to detect the first two but it would introduce complexity and eventual race conditions. - - -## Usage - -### .lock(file, [options]) - -Tries to acquire a lock on `file` or rejects the promise on error. - -If the lock succeeds, a `release` function is provided that should be called when you want to release the lock. The `release` function also rejects the promise on error (e.g. when the lock was already compromised). - -Available options: - -- `stale`: Duration in milliseconds in which the lock is considered stale, defaults to `10000` (minimum value is `5000`) -- `update`: The interval in milliseconds in which the lockfile's `mtime` will be updated, defaults to `stale/2` (minimum value is `1000`, maximum value is `stale/2`) -- `retries`: The number of retries or a [retry](https://www.npmjs.org/package/retry) options object, defaults to `0` -- `realpath`: Resolve symlinks using realpath, defaults to `true` (note that if `true`, the `file` must exist previously) -- `fs`: A custom fs to use, defaults to `graceful-fs` -- `onCompromised`: Called if the lock gets compromised, defaults to a function that simply throws the error which will probably cause the process to die -- `lockfilePath`: Custom lockfile path. e.g.: If you want to lock a directory and create the lock file inside it, you can pass `file` as `