diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7db4d86..3693685 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,9 +19,9 @@ jobs: node: [20, 22, 24] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: ${{ matrix.node }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5f598ac --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +name: CodeQL + +# GitHub's own static analysis. Free on public repositories; on a private repo it +# requires GitHub Advanced Security, so this will simply not run until the +# repository is public. +# +# Worth more here than on a pure JavaScript project: src/ is C++ driving a raw C API +# with hand-managed stack indices, string buffers and object lifetimes, which is +# exactly what the c-cpp queries are built to find. +on: + push: + branches: [master] + pull_request: + branches: [master] + # Lets the first scan be triggered without waiting for a push, and lets a rerun be + # forced after a rule update. + workflow_dispatch: + schedule: + # Rules are updated continuously, so a weekly run finds things that did not exist + # as findings when the code was written. + - cron: '0 7 * * 1' + +jobs: + analyze: + name: Analyze ${{ matrix.language }} + runs-on: ubuntu-latest + # Skip rather than fail while the repository is private: code scanning needs + # GitHub Advanced Security there, and a permanently red workflow trains people to + # ignore red workflows. + if: ${{ !github.event.repository.private }} + permissions: + security-events: write + contents: read + + strategy: + fail-fast: false + matrix: + include: + # The addon has to be compiled for CodeQL to observe it, so the build runs + # between init and analyze below. + - language: c-cpp + build-mode: manual + - language: javascript-typescript + build-mode: none + + steps: + - uses: actions/checkout@v7 + + - uses: github/codeql-action/init@v4.37.3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # security-extended adds lower-severity rules, worth it for code that does + # its own memory and stack bookkeeping. + queries: security-extended + # NOTE: path filters are not honoured for compiled languages, so alerts in + # vendor/ will still appear for c-cpp. Dismiss those as "used in tests" or + # "won't fix": Lua 5.1.5 and LuaFileSystem are vendored verbatim and are not + # patched here, so a finding in them is upstream's, not ours. The filter is + # kept because it does apply to the javascript-typescript run. + config: | + paths-ignore: + - vendor/** + + - uses: actions/setup-node@v7 + if: matrix.build-mode == 'manual' + with: + node-version: 24 + + - name: Build the addon + if: matrix.build-mode == 'manual' + run: | + npm install --ignore-scripts + npx --yes node-gyp@12 rebuild + + - uses: github/codeql-action/analyze@v4.37.3 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..fdceabe --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,59 @@ +name: PR title + +# The PR title becomes the squashed commit message, and release-please derives the +# next version from it. A title that does not parse means a release that silently +# does not happen, so it is validated before merge rather than discovered after. +on: + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read + +jobs: + conventional-commit: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # feat -> minor, fix/perf -> patch, feat! or a BREAKING CHANGE footer -> major. + # The rest are patch-or-nothing and never drive a release on their own. + types: | + feat + fix + perf + refactor + docs + test + build + ci + chore + revert + # Optional, but keep the vocabulary small so it stays meaningful. A scope is + # not required, but any scope used must appear here. + # + # 'master' is not a component — it is the branch name release-please puts in + # its own PR title ("chore(master): release 2.1.0"). Without it that title + # fails this check, and once the check is required by a branch ruleset the + # release PR becomes unmergeable. + scopes: | + lua + napi + build + vendor + lfs + deps + docs + release + master + requireScope: false + # Only the trailing full stop is rejected. A lower-case rule is deliberately + # not used: Dependabot capitalises some of its titles ("Bump x from 1 to 2") + # and not others, so it fails dependency PRs for a reason unrelated to + # anything a human chose. + subjectPattern: ^(?!.*\.$).+$ + subjectPatternError: | + The subject "{subject}" must not end with a full stop, e.g. + "fix(lua): resolve the stack index before pushing". diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..c823dd7 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,35 @@ +name: Release PR + +# Maintains an open "chore(master): release x.y.z" pull request that accumulates every +# merged change, derives the next version from the conventional-commit history, and +# rewrites CHANGELOG.md. Nothing is versioned or tagged until that PR is merged. +# +# Chosen over fully automatic publishing on purpose: releasing is irreversible on npm, +# so there is a human gate. Merging the release PR tags the commit and publishes the +# GitHub Release, which is what release.yml listens for. +on: + push: + branches: [master] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v5 + with: + # A PAT, not GITHUB_TOKEN. GitHub refuses to trigger workflows from events + # raised with GITHUB_TOKEN, so the release pull request would arrive with + # every check stuck in "action_required" and never run. Falls back to + # GITHUB_TOKEN so the workflow still functions without the secret; the PR + # just will not get checks. + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + # Configuration lives in release-please-config.json and the current version + # in .release-please-manifest.json. Without the manifest the action logs + # 'No version for path .' and never opens a release PR — it cannot know what + # the previous version was. + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f0f10ff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +name: Release to npm + +# Fires when release-please publishes a GitHub Release — that only happens when a +# release pull request is merged, which is itself a deliberate act with the version +# and changelog visible for review. So merging the release PR is the single action +# that ships a version. +# +# Still dispatchable by hand, defaulting to a dry run, for re-publishing after a +# failure or validating the tarball without shipping. +on: + release: + types: [published] + workflow_dispatch: + inputs: + dry_run: + description: 'Pack and validate without publishing' + type: boolean + default: true + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: read + # Required for OIDC — this is what npm exchanges for a short-lived publish + # credential, and what provenance is derived from. Only works on a public + # repository. + id-token: write + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + # 24 rather than 22: trusted publishing needs npm >= 11.5.1, and Node 22 + # still ships npm 10.x. + node-version: 24 + cache: npm + registry-url: https://registry.npmjs.org + + - name: Ensure an npm new enough for trusted publishing + run: | + npm install -g npm@^11 + npm --version + + # Also compiles the addon, since the install script runs node-gyp. Ubuntu is + # unaffected by the Visual Studio detection problem that forces ci.yml to pin + # its own node-gyp on Windows. + - run: npm ci + + - name: Test + run: npm test + + - name: Refuse to publish a version that already exists + run: | + NAME=$(node -p "require('./package.json').name") + VERSION=$(node -p "require('./package.json').version") + echo "Preparing $NAME@$VERSION" + if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then + echo "::error::$NAME@$VERSION is already published. Bump the version first." + exit 1 + fi + + # This package builds from source on the user's machine, so the vendored Lua + # and LuaFileSystem sources are not an optional extra — a tarball missing them + # is unbuildable for everyone who installs it, and npm versions are immutable. + # Cheapest possible check against the most expensive possible mistake. + - name: Verify the tarball can actually build + run: | + npm pack --dry-run --json > pack.json + node -e " + // npm 11 and earlier emit an array of results; npm 12 emits an object + // keyed by package name. Accept either, so an npm upgrade cannot turn + // this guard into a crash — or, worse, into a silent pass. + const raw = require('./pack.json'); + const entry = Array.isArray(raw) ? raw[0] : Object.values(raw)[0]; + if (!entry || !Array.isArray(entry.files)) { + console.error('could not read the file list from npm pack --json'); + process.exit(1); + } + const files = entry.files.map(f => f.path); + + const needed = ['index.js', 'binding.gyp', 'src/luastate.cc', 'src/nodelua.cc', 'src/utils.cc', 'vendor/lfs/lfs.c', 'README.md', 'LICENSE.md']; + const missing = needed.filter(n => !files.includes(n)); + if (missing.length) { + console.error('missing from tarball:', missing.join(', ')); + process.exit(1); + } + + // Lua 5.1.5 is 29 translation units; a partial copy links with undefined + // symbols rather than failing loudly at pack time. + const lua = files.filter(f => /^vendor\/lua\/.+\.c\$/.test(f)); + if (lua.length !== 29) { + console.error('expected 29 vendored Lua sources, found ' + lua.length); + process.exit(1); + } + + // Build output is platform-specific and must never ship; node_modules + // would bloat the tarball and shadow the consumer's own tree. + const leaked = files.filter(f => /^(build|node_modules|test|examples)\//.test(f)); + if (leaked.length) { + console.error('unexpected files in tarball:', leaked.join(', ')); + process.exit(1); + } + + console.log(files.length + ' files, all expected'); + " + + - name: Pack (dry run) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + run: npm publish --dry-run + + # No NODE_AUTH_TOKEN. Publishing uses npm trusted publishing (OIDC): npm + # verifies this workflow's identity against the trusted publisher configured on + # the package, so there is no long-lived token to leak or rotate. + # + # Provenance is automatic under OIDC for a public package from a public repo, + # so --provenance is not passed explicitly. + # On a release event inputs.dry_run is undefined, so this must not rely on + # negating it — an undefined input would otherwise read as "not a dry run" by + # luck rather than intent. + - name: Publish + if: ${{ github.event_name == 'release' || !inputs.dry_run }} + run: npm publish --access public + + # No tagging step: release-please already created the tag and the GitHub + # Release that triggered this run. diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..895bf0e --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "2.0.0" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..944a81b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,54 @@ +# Changelog + +## [2.0.0](https://github.com/mschmicking/node-lua-runner/releases/tag/v2.0.0) + +Maintenance release that makes the package build and run on current Node.js. + +### Changed + +- **Migrated the binding from NAN to Node-API** (`node-addon-api`). NAN tracks V8's unstable C++ + API and no longer compiles on current Node — the build failed inside `nan.h` itself. Node-API is + ABI-stable, so a build keeps working across future Node major versions. +- **Vendored Lua 5.1.5 and LuaFileSystem 1.8.0**, compiled into the addon. The prebuilt libraries + are gone. The package now builds on Linux, macOS and Windows, on x64 and ARM64, with no system + Lua to install. +- **LuaJIT replaced by stock Lua 5.1.5.** Only Windows ever linked LuaJIT; macOS already shipped + stock Lua 5.1.5. In exchange, Apple Silicon and ARM64 Linux build at all, which they previously + could not. +- **`require('lfs')` now works on every platform.** It was a Windows-only prebuilt DLL loaded via an + `LUA_CPATH` hack; LuaFileSystem is now compiled in and registered through `package.preload`. +- Node.js 18 or newer is required. + +### Fixed + +Each of these changes observable behaviour, hence the major version. + +- `SetField` pushed its **key** argument as the value, so every assignment wrote the field name into + the field. It also failed to resolve a relative stack index before pushing the value, which put + Lua into an unprotected error and **aborted the process**. +- `LoadFile` and `LoadString` were bound to the `DoFile`/`DoString` handlers, so they executed the + chunk instead of only compiling it. The correct implementations were unreachable. +- Lua booleans converted to the numbers `1` and `0` rather than `true` and `false`. +- `Push` truncated numbers through `lua_pushinteger`, turning `3.5` into `3`. +- `AddPackagePath` appended to `package.path` without a separator, corrupting the last entry so + `require` usually failed, and interpolated the path into generated Lua source where a quote could + break out of the string literal. +- Table conversion used a hardcoded relative stack index and only worked when the table happened to + be on top of the stack. +- `get_str` allocated on every string argument and never freed it. +- Six `sprintf` calls formatted arbitrary-length Lua error messages into a fixed 1024-byte stack + buffer. +- `~LuaState` never called `lua_close`, leaking the interpreter; calling `Close` twice was a + use-after-free. `Close` is now idempotent and later use of a closed state throws. +- `SetField` and `GetField` reject non-table targets instead of letting Lua abort the process. +- Registered callbacks are looked up through a closure upvalue rather than a global singleton, so + separate `LuaState` instances no longer clash. + +### Added + +- Test suite covering the public API, with regression tests pinning each fix above. +- CI across Linux, macOS and Windows on Node 20, 22 and 24. + +--- + +Releases from 2.0.1 onward are generated by release-please from conventional commits. diff --git a/README.md b/README.md index b3fe5ed..6edebe3 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,20 @@ npm install npm test ``` +### Releasing + +Commits follow [Conventional Commits](https://www.conventionalcommits.org/); the pull request +title is what matters, since it becomes the squashed commit message. + +release-please keeps an open `chore(master): release x.y.z` pull request that accumulates merged +changes, works out the next version and rewrites `CHANGELOG.md`. Merging that pull request tags the +commit and publishes a GitHub Release, which is what triggers the npm publish. So merging the +release pull request is the single deliberate act that ships a version — nothing publishes on an +ordinary merge to `master`. + +Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) over OIDC, so +there is no npm token stored in this repository. + ## License ISC — see [LICENSE.md](LICENSE.md), which also covers the vendored Lua and LuaFileSystem sources. diff --git a/package.json b/package.json index a61d5a9..77e0c38 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ ], "scripts": { "install": "node-gyp rebuild", - "test": "node --test" + "test": "node --test", + "prepublishOnly": "npm test" }, "dependencies": { "node-addon-api": "^8.9.1" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..e29fc99 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "node", + "package-name": "node-lua-runner", + "changelog-path": "CHANGELOG.md", + "include-v-in-tag": true, + "bump-minor-pre-major": false, + "changelog-sections": [ + { + "type": "feat", + "section": "Added" + }, + { + "type": "fix", + "section": "Fixed" + }, + { + "type": "perf", + "section": "Performance" + }, + { + "type": "refactor", + "section": "Changed" + }, + { + "type": "build", + "section": "Build" + }, + { + "type": "docs", + "section": "Documentation", + "hidden": true + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "ci", + "section": "CI", + "hidden": true + }, + { + "type": "chore", + "section": "Chores", + "hidden": true + } + ], + "include-component-in-tag": false + } + } +}