From e1a6b00fc44ced7f9b7a1f5e1ee91b448af714ad Mon Sep 17 00:00:00 2001 From: Maurice Schmicking Date: Fri, 7 Aug 2026 20:48:19 +0200 Subject: [PATCH] ci: add release automation and CodeQL Ports the release workflow set from iobroker-sync, adapted for a native addon. - release-please.yml keeps an open release PR on master, deriving the version and changelog from conventional commits. Merging it tags and publishes a GitHub Release. - release.yml publishes to npm on that Release event via trusted publishing (OIDC), so no npm token is stored. Dispatchable by hand, defaulting to a dry run. - pr-title.yml validates PR titles, since release-please reads them to work out the next version. Scope vocabulary retuned for this repo, and 'master' is allowed because release-please titles its own PR chore(master). - codeql.yml analyses c-cpp and javascript. The c-cpp run compiles the addon so CodeQL can observe it, which is where the value is: src/ hand-manages stack indices, buffers and object lifetimes. The tarball guard in release.yml is the important part. This package builds from source on the user's machine, so a tarball missing the vendored Lua sources is unbuildable for everyone who installs it, and npm versions are immutable. It asserts all 29 Lua translation units are present and that build output and node_modules are not. It reads npm pack --json, whose shape changed: npm 11 and earlier emit an array, npm 12 an object keyed by package name. Handled both, verified against real output plus a synthesised array payload and a negative case, rather than assuming the shape the pinned npm happens to produce today. Bootstraps the manifest at 2.0.0 and writes CHANGELOG.md by hand for that version; release-please takes over from 2.0.1. Aligns ci.yml on the same action versions and adds prepublishOnly so a manual publish cannot skip the tests. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 4 +- .github/workflows/codeql.yml | 78 ++++++++++++++++ .github/workflows/pr-title.yml | 59 +++++++++++++ .github/workflows/release-please.yml | 35 ++++++++ .github/workflows/release.yml | 127 +++++++++++++++++++++++++++ .release-please-manifest.json | 3 + CHANGELOG.md | 54 ++++++++++++ README.md | 14 +++ package.json | 3 +- release-please-config.json | 55 ++++++++++++ 10 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/release.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 release-please-config.json 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 + } + } +}