From 9813feebde64db0ff2dbf86af5c15dc662f76808 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 22:31:40 +0800 Subject: [PATCH 01/13] feat: release v0.1.0 --- .github/workflows/release.yml | 56 +++++++++++++++++++++++++++++ README.md | 13 +++++++ lua/qjson.lua | 2 +- lua/qjson/lib.lua | 36 +++++++++++++++++++ lua/qjson/table.lua | 2 +- rockspec/lua-qjson-0.1.0-1.rockspec | 37 +++++++++++++++++++ 6 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 lua/qjson/lib.lua create mode 100644 rockspec/lua-qjson-0.1.0-1.rockspec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4fc0a3c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + branches: + - "main" + paths: + - "rockspec/**" + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Lua + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.1.5" + + - name: Install LuaRocks + uses: leafo/gh-actions-luarocks@v4 + with: + luarocksVersion: "3.11.1" + + - name: Extract release name + id: release_env + shell: bash + run: | + title="${{ github.event.head_commit.message }}" + re="^feat: release v*(\S+)" + if [[ $title =~ $re ]]; then + v=v${BASH_REMATCH[1]} + echo "version=${v}" >> $GITHUB_OUTPUT + echo "version_without_v=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT + else + echo "commit format is not correct" + exit 1 + fi + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.release_env.outputs.version }} + name: ${{ steps.release_env.outputs.version }} + draft: false + prerelease: false + + - name: Upload to LuaRocks + env: + LUAROCKS_TOKEN: ${{ secrets.LUAROCKS_TOKEN }} + run: | + luarocks install dkjson + luarocks upload rockspec/lua-qjson-${{ steps.release_env.outputs.version_without_v }}-1.rockspec --api-key=${LUAROCKS_TOKEN} diff --git a/README.md b/README.md index 64f34d7..80df155 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,19 @@ cargo build --release A `Makefile` wraps the common workflows; run `make help` to see `build`, `test`, `lint`, `bench`, and `clean` targets. Override `LUAJIT` / `LUA_CPATH` per invocation if your environment differs from the defaults. +## Installing + +```sh +luarocks install lua-qjson +``` + +The rock builds the Rust native library during installation, so Rust/Cargo +must be available on the target system. The Lua module name remains `qjson`: + +```lua +local qjson = require("qjson") +``` + ## Testing ```sh diff --git a/lua/qjson.lua b/lua/qjson.lua index 3e57324..cfcf4ff 100644 --- a/lua/qjson.lua +++ b/lua/qjson.lua @@ -43,7 +43,7 @@ int qjson_cursor_object_entry_at(const qjson_cursor*, size_t i, qjson_cursor* value_out); ]] -local C = ffi.load("qjson") +local C = require("qjson.lib") local err_box = ffi.new("int[1]") local i64_box = ffi.new("int64_t[1]") diff --git a/lua/qjson/lib.lua b/lua/qjson/lib.lua new file mode 100644 index 0000000..5f32572 --- /dev/null +++ b/lua/qjson/lib.lua @@ -0,0 +1,36 @@ +local ffi = require("ffi") + +local tried = {} + +local function try_load(name) + if tried[name] then + return nil + end + tried[name] = true + local ok, lib = pcall(ffi.load, name) + if ok then + return lib + end + return nil +end + +local function load_from_cpath() + local names = { "qjson", "libqjson" } + for template in string.gmatch(package.cpath, "[^;]+") do + for _, name in ipairs(names) do + local path = string.gsub(template, "%?", name) + local lib = try_load(path) + if lib then + return lib + end + end + end + return nil +end + +local lib = try_load("qjson") or try_load("libqjson") or load_from_cpath() +if not lib then + error("qjson: failed to load native library qjson") +end + +return lib diff --git a/lua/qjson/table.lua b/lua/qjson/table.lua index f23c076..04e1f93 100644 --- a/lua/qjson/table.lua +++ b/lua/qjson/table.lua @@ -5,7 +5,7 @@ -- they require this module. local ffi = require("ffi") -local C = ffi.load("qjson") +local C = require("qjson.lib") -- Defer the require to avoid a circular dependency when qjson.lua -- re-exports this module. By the time _M.decode is called, qjson -- is already registered in package.loaded. diff --git a/rockspec/lua-qjson-0.1.0-1.rockspec b/rockspec/lua-qjson-0.1.0-1.rockspec new file mode 100644 index 0000000..42118d8 --- /dev/null +++ b/rockspec/lua-qjson-0.1.0-1.rockspec @@ -0,0 +1,37 @@ +package = "lua-qjson" +version = "0.1.0-1" + +source = { + url = "git+https://github.com/api7/lua-qjson.git", + tag = "v0.1.0", +} + +description = { + summary = "Fast JSON decoder for LuaJIT FFI consumers", + detailed = [[ + qjson is a Rust-implemented JSON decoder exposed to LuaJIT through FFI. + It is optimized for parsing large JSON payloads while reading only the + fields needed by Lua callers. + ]], + homepage = "https://github.com/api7/lua-qjson", + license = "Apache-2.0", +} + +dependencies = { + "lua >= 5.1", +} + +build = { + type = "command", + build_command = "cargo build --release", + install_command = [[ + mkdir -p "$(LUADIR)/qjson" "$(LIBDIR)" && + cp lua/qjson.lua "$(LUADIR)/qjson.lua" && + cp lua/qjson/lib.lua "$(LUADIR)/qjson/lib.lua" && + cp lua/qjson/table.lua "$(LUADIR)/qjson/table.lua" && + lib=target/release/libqjson.so; + if [ ! -f "$lib" ]; then lib=target/release/libqjson.dylib; fi; + if [ ! -f "$lib" ]; then echo "qjson native library not found"; exit 1; fi; + cp "$lib" "$(LIBDIR)/qjson.so" + ]], +} From df2d55debc758635071c4c10f89dfa8329d498c4 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 22:39:48 +0800 Subject: [PATCH 02/13] ci: validate luarocks package --- .github/workflows/ci.yml | 9 +++++++++ .github/workflows/release.yml | 9 +++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f2595a..32aeaee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,3 +92,12 @@ jobs: LD_LIBRARY_PATH="$PWD/target/release" \ busted --lua=$(which luajit) tests/lua \ --lpath='./lua/?.lua' + + - name: Validate LuaRocks package + run: | + rm -rf /tmp/lua-qjson-rock + luarocks make rockspec/lua-qjson-0.1.0-1.rockspec --tree /tmp/lua-qjson-rock + eval "$(luarocks path --tree /tmp/lua-qjson-rock)" + unset LD_LIBRARY_PATH + luajit -e 'local qjson = require("qjson"); local doc = qjson.parse("{\"a\":42}"); assert(doc:get_i64("a") == 42)' + busted --lua=$(which luajit) tests/lua diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fc0a3c..f60a169 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,9 @@ on: paths: - "rockspec/**" +permissions: + contents: write + jobs: release: name: Release @@ -28,9 +31,11 @@ jobs: - name: Extract release name id: release_env shell: bash + env: + HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} run: | - title="${{ github.event.head_commit.message }}" - re="^feat: release v*(\S+)" + title="$HEAD_COMMIT_MESSAGE" + re="^feat: release v?(\S+)" if [[ $title =~ $re ]]; then v=v${BASH_REMATCH[1]} echo "version=${v}" >> $GITHUB_OUTPUT From 485ba651a29b9f27809f0a41ff4ad8daef12d1f7 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 22:41:04 +0800 Subject: [PATCH 03/13] chore: address luarocks review feedback --- README.md | 3 ++- lua/qjson/lib.lua | 8 +++++++- rockspec/lua-qjson-0.1.0-1.rockspec | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 80df155..c8a1462 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ luarocks install lua-qjson ``` The rock builds the Rust native library during installation, so Rust/Cargo -must be available on the target system. The Lua module name remains `qjson`: +and LuaJIT must be available on the target system. The Lua module name remains +`qjson`: ```lua local qjson = require("qjson") diff --git a/lua/qjson/lib.lua b/lua/qjson/lib.lua index 5f32572..0da7bf2 100644 --- a/lua/qjson/lib.lua +++ b/lua/qjson/lib.lua @@ -1,16 +1,20 @@ local ffi = require("ffi") local tried = {} +local attempts = {} +local last_error local function try_load(name) if tried[name] then return nil end tried[name] = true + attempts[#attempts + 1] = name local ok, lib = pcall(ffi.load, name) if ok then return lib end + last_error = lib return nil end @@ -30,7 +34,9 @@ end local lib = try_load("qjson") or try_load("libqjson") or load_from_cpath() if not lib then - error("qjson: failed to load native library qjson") + error("qjson: failed to load native library qjson; tried " + .. table.concat(attempts, ", ") + .. "; last error: " .. tostring(last_error)) end return lib diff --git a/rockspec/lua-qjson-0.1.0-1.rockspec b/rockspec/lua-qjson-0.1.0-1.rockspec index 42118d8..a3a25e0 100644 --- a/rockspec/lua-qjson-0.1.0-1.rockspec +++ b/rockspec/lua-qjson-0.1.0-1.rockspec @@ -1,5 +1,6 @@ package = "lua-qjson" version = "0.1.0-1" +supported_platforms = { "linux", "macosx" } source = { url = "git+https://github.com/api7/lua-qjson.git", @@ -18,7 +19,7 @@ description = { } dependencies = { - "lua >= 5.1", + "lua == 5.1", } build = { From 93f9953e052227a036bea224d568f693381774d1 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 22:47:21 +0800 Subject: [PATCH 04/13] chore: harden release parsing --- .github/workflows/release.yml | 4 ++-- lua/qjson/lib.lua | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f60a169..6cf0e20 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,13 +35,13 @@ jobs: HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} run: | title="$HEAD_COMMIT_MESSAGE" - re="^feat: release v?(\S+)" + re="^feat: release v?([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$" if [[ $title =~ $re ]]; then v=v${BASH_REMATCH[1]} echo "version=${v}" >> $GITHUB_OUTPUT echo "version_without_v=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT else - echo "commit format is not correct" + echo "commit format is not correct (expected: feat: release vX.Y.Z[-prerelease])" exit 1 fi diff --git a/lua/qjson/lib.lua b/lua/qjson/lib.lua index 0da7bf2..2c32df8 100644 --- a/lua/qjson/lib.lua +++ b/lua/qjson/lib.lua @@ -21,11 +21,13 @@ end local function load_from_cpath() local names = { "qjson", "libqjson" } for template in string.gmatch(package.cpath, "[^;]+") do - for _, name in ipairs(names) do - local path = string.gsub(template, "%?", name) - local lib = try_load(path) - if lib then - return lib + if string.find(template, "?", 1, true) then + for _, name in ipairs(names) do + local path = string.gsub(template, "%?", name) + local lib = try_load(path) + if lib then + return lib + end end end end From 7b8dd8bc7aeb2e72e6618a3c817edc3a11580a04 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 22:52:27 +0800 Subject: [PATCH 05/13] chore: tighten release workflow --- .github/workflows/release.yml | 6 +++++- rockspec/lua-qjson-0.1.0-1.rockspec | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cf0e20..abe2ddc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + with: + persist-credentials: false - name: Install Lua uses: leafo/gh-actions-lua@v10 @@ -28,6 +30,9 @@ jobs: with: luarocksVersion: "3.11.1" + - name: Install upload dependency + run: luarocks install dkjson + - name: Extract release name id: release_env shell: bash @@ -57,5 +62,4 @@ jobs: env: LUAROCKS_TOKEN: ${{ secrets.LUAROCKS_TOKEN }} run: | - luarocks install dkjson luarocks upload rockspec/lua-qjson-${{ steps.release_env.outputs.version_without_v }}-1.rockspec --api-key=${LUAROCKS_TOKEN} diff --git a/rockspec/lua-qjson-0.1.0-1.rockspec b/rockspec/lua-qjson-0.1.0-1.rockspec index a3a25e0..c0bdfa5 100644 --- a/rockspec/lua-qjson-0.1.0-1.rockspec +++ b/rockspec/lua-qjson-0.1.0-1.rockspec @@ -26,6 +26,7 @@ build = { type = "command", build_command = "cargo build --release", install_command = [[ + set -e; mkdir -p "$(LUADIR)/qjson" "$(LIBDIR)" && cp lua/qjson.lua "$(LUADIR)/qjson.lua" && cp lua/qjson/lib.lua "$(LUADIR)/qjson/lib.lua" && From abafe913a04e02c3ad16803a201770a34e8aeac0 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 22:56:35 +0800 Subject: [PATCH 06/13] ci: select packaged rockspec dynamically --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32aeaee..dd3103a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,8 @@ jobs: - name: Validate LuaRocks package run: | rm -rf /tmp/lua-qjson-rock - luarocks make rockspec/lua-qjson-0.1.0-1.rockspec --tree /tmp/lua-qjson-rock + ROCKSPEC="$(ls -1 rockspec/lua-qjson-*-1.rockspec | sort -V | tail -n1)" + luarocks make "$ROCKSPEC" --tree /tmp/lua-qjson-rock eval "$(luarocks path --tree /tmp/lua-qjson-rock)" unset LD_LIBRARY_PATH luajit -e 'local qjson = require("qjson"); local doc = qjson.parse("{\"a\":42}"); assert(doc:get_i64("a") == 42)' From b1b7adb46e0c27f12b85ea3d650e179becec5f6f Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 23:02:07 +0800 Subject: [PATCH 07/13] chore: refine release workflow --- .github/workflows/release.yml | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abe2ddc..110cd92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,27 +39,43 @@ jobs: env: HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} run: | - title="$HEAD_COMMIT_MESSAGE" + title="${HEAD_COMMIT_MESSAGE%%$'\n'*}" re="^feat: release v?([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$" if [[ $title =~ $re ]]; then v=v${BASH_REMATCH[1]} echo "version=${v}" >> $GITHUB_OUTPUT echo "version_without_v=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT + if [[ ${BASH_REMATCH[1]} == *-* ]]; then + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi else echo "commit format is not correct (expected: feat: release vX.Y.Z[-prerelease])" exit 1 fi + - name: Verify rockspec exists + id: verify_rockspec + shell: bash + run: | + rockspec_file="rockspec/lua-qjson-${{ steps.release_env.outputs.version_without_v }}-1.rockspec" + if [ ! -f "$rockspec_file" ]; then + echo "rockspec file not found: $rockspec_file" + exit 1 + fi + echo "rockspec_file=${rockspec_file}" >> $GITHUB_OUTPUT + - name: Create Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.release_env.outputs.version }} name: ${{ steps.release_env.outputs.version }} draft: false - prerelease: false + prerelease: ${{ steps.release_env.outputs.is_prerelease }} - name: Upload to LuaRocks env: LUAROCKS_TOKEN: ${{ secrets.LUAROCKS_TOKEN }} run: | - luarocks upload rockspec/lua-qjson-${{ steps.release_env.outputs.version_without_v }}-1.rockspec --api-key=${LUAROCKS_TOKEN} + luarocks upload "${{ steps.verify_rockspec.outputs.rockspec_file }}" --api-key="${LUAROCKS_TOKEN}" From 135af3a9e50ffbcb7eab754161b6d9b2430569b2 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 23:06:55 +0800 Subject: [PATCH 08/13] test: cover native loader failures --- .github/workflows/release.yml | 7 ++++++- tests/lua/loader_spec.lua | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 tests/lua/loader_spec.lua diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 110cd92..94b6af6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,5 +77,10 @@ jobs: - name: Upload to LuaRocks env: LUAROCKS_TOKEN: ${{ secrets.LUAROCKS_TOKEN }} + shell: bash run: | - luarocks upload "${{ steps.verify_rockspec.outputs.rockspec_file }}" --api-key="${LUAROCKS_TOKEN}" + mkdir -p "$HOME/.luarocks" + chmod 700 "$HOME/.luarocks" + lua -e 'local token = assert(os.getenv("LUAROCKS_TOKEN"), "missing LUAROCKS_TOKEN"); local f = assert(io.open(os.getenv("HOME") .. "/.luarocks/upload_config.lua", "w")); f:write(("key = %q\n"):format(token)); f:write("server = \"https://luarocks.org\"\nversion = \"1.0\"\n"); f:close()' + chmod 600 "$HOME/.luarocks/upload_config.lua" + luarocks upload "${{ steps.verify_rockspec.outputs.rockspec_file }}" diff --git a/tests/lua/loader_spec.lua b/tests/lua/loader_spec.lua new file mode 100644 index 0000000..0753500 --- /dev/null +++ b/tests/lua/loader_spec.lua @@ -0,0 +1,27 @@ +describe("qjson native loader", function() + it("reports attempted libraries and the last loader error", function() + local old_lib = package.loaded["qjson.lib"] + local old_ffi = package.loaded.ffi + local old_cpath = package.cpath + + package.loaded["qjson.lib"] = nil + package.loaded.ffi = { + load = function(name) + error("mock load failure for " .. name) + end, + } + package.cpath = "/tmp/qjson-test/?.so;/tmp/qjson-test/loadall.so" + + local ok, err = pcall(require, "qjson.lib") + + package.loaded["qjson.lib"] = old_lib + package.loaded.ffi = old_ffi + package.cpath = old_cpath + + assert.is_false(ok) + assert.matches("qjson: failed to load native library qjson", err, 1, true) + assert.matches("qjson, libqjson, /tmp/qjson-test/qjson.so, /tmp/qjson-test/libqjson.so", err, 1, true) + assert.matches("mock load failure for /tmp/qjson-test/libqjson.so", err, 1, true) + assert.is_nil(string.find(err, "loadall.so", 1, true)) + end) +end) From ca74811903e656e59a9a06eaaf6bc22b0325d3ee Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 23:10:45 +0800 Subject: [PATCH 09/13] chore: address package review feedback --- .github/workflows/ci.yml | 39 ++++++++++++++++++++++++++++- .github/workflows/release.yml | 7 +++--- lua/qjson/lib.lua | 10 +++++++- rockspec/lua-qjson-0.1.0-1.rockspec | 18 ++++++------- tests/lua/loader_spec.lua | 30 ++++++++++++++++++++++ 5 files changed, 90 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd3103a..9b50ec7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,9 +96,46 @@ jobs: - name: Validate LuaRocks package run: | rm -rf /tmp/lua-qjson-rock - ROCKSPEC="$(ls -1 rockspec/lua-qjson-*-1.rockspec | sort -V | tail -n1)" + ROCKSPEC="$(ls -1 rockspec/lua-qjson-*.rockspec | sort | tail -n1)" luarocks make "$ROCKSPEC" --tree /tmp/lua-qjson-rock eval "$(luarocks path --tree /tmp/lua-qjson-rock)" unset LD_LIBRARY_PATH luajit -e 'local qjson = require("qjson"); local doc = qjson.parse("{\"a\":42}"); assert(doc:get_i64("a") == 42)' busted --lua=$(which luajit) tests/lua + + package: + name: LuaRocks package (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: rust + strategy: + matrix: + os: [ubuntu-latest, macos-14] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Rust (stable) + run: | + rustup toolchain install stable --profile minimal --no-self-update + rustup default stable + + - name: Install LuaJIT + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "luajit" + + - name: Install LuaRocks + uses: leafo/gh-actions-luarocks@v4 + with: + luarocksVersion: "3.11.1" + + - name: Validate LuaRocks package + run: | + rm -rf /tmp/lua-qjson-rock + ROCKSPEC="$(ls -1 rockspec/lua-qjson-*.rockspec | sort | tail -n1)" + luarocks make "$ROCKSPEC" --tree /tmp/lua-qjson-rock + eval "$(luarocks path --tree /tmp/lua-qjson-rock)" + unset LD_LIBRARY_PATH + unset DYLD_LIBRARY_PATH + luajit -e 'local qjson = require("qjson"); local doc = qjson.parse("{\"a\":42}"); assert(doc:get_i64("a") == 42)' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94b6af6..6955f47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,11 +59,12 @@ jobs: id: verify_rockspec shell: bash run: | - rockspec_file="rockspec/lua-qjson-${{ steps.release_env.outputs.version_without_v }}-1.rockspec" - if [ ! -f "$rockspec_file" ]; then - echo "rockspec file not found: $rockspec_file" + mapfile -t rockspecs < <(find rockspec -maxdepth 1 -name "lua-qjson-${{ steps.release_env.outputs.version_without_v }}-*.rockspec" -print | sort) + if [ ${#rockspecs[@]} -eq 0 ]; then + echo "rockspec file not found for version ${{ steps.release_env.outputs.version_without_v }}" exit 1 fi + rockspec_file="${rockspecs[$((${#rockspecs[@]} - 1))]}" echo "rockspec_file=${rockspec_file}" >> $GITHUB_OUTPUT - name: Create Release diff --git a/lua/qjson/lib.lua b/lua/qjson/lib.lua index 2c32df8..3ebb2b6 100644 --- a/lua/qjson/lib.lua +++ b/lua/qjson/lib.lua @@ -3,6 +3,7 @@ local ffi = require("ffi") local tried = {} local attempts = {} local last_error +local required_symbol = "qjson_parse" local function try_load(name) if tried[name] then @@ -12,7 +13,14 @@ local function try_load(name) attempts[#attempts + 1] = name local ok, lib = pcall(ffi.load, name) if ok then - return lib + local has_symbol, symbol = pcall(function() + return lib[required_symbol] + end) + if has_symbol and symbol ~= nil then + return lib + end + last_error = "loaded " .. name .. " but missing required symbol " .. required_symbol + return nil end last_error = lib return nil diff --git a/rockspec/lua-qjson-0.1.0-1.rockspec b/rockspec/lua-qjson-0.1.0-1.rockspec index c0bdfa5..acc0010 100644 --- a/rockspec/lua-qjson-0.1.0-1.rockspec +++ b/rockspec/lua-qjson-0.1.0-1.rockspec @@ -24,16 +24,16 @@ dependencies = { build = { type = "command", - build_command = "cargo build --release", + build_command = "CARGO_TARGET_DIR=target cargo build --release", install_command = [[ - set -e; - mkdir -p "$(LUADIR)/qjson" "$(LIBDIR)" && - cp lua/qjson.lua "$(LUADIR)/qjson.lua" && - cp lua/qjson/lib.lua "$(LUADIR)/qjson/lib.lua" && - cp lua/qjson/table.lua "$(LUADIR)/qjson/table.lua" && - lib=target/release/libqjson.so; - if [ ! -f "$lib" ]; then lib=target/release/libqjson.dylib; fi; - if [ ! -f "$lib" ]; then echo "qjson native library not found"; exit 1; fi; + set -e + mkdir -p "$(LUADIR)/qjson" "$(LIBDIR)" + cp lua/qjson.lua "$(LUADIR)/qjson.lua" + cp lua/qjson/lib.lua "$(LUADIR)/qjson/lib.lua" + cp lua/qjson/table.lua "$(LUADIR)/qjson/table.lua" + lib=target/release/libqjson.so + if [ ! -f "$lib" ]; then lib=target/release/libqjson.dylib; fi + if [ ! -f "$lib" ]; then echo "qjson native library not found"; exit 1; fi cp "$lib" "$(LIBDIR)/qjson.so" ]], } diff --git a/tests/lua/loader_spec.lua b/tests/lua/loader_spec.lua index 0753500..be8b7e5 100644 --- a/tests/lua/loader_spec.lua +++ b/tests/lua/loader_spec.lua @@ -24,4 +24,34 @@ describe("qjson native loader", function() assert.matches("mock load failure for /tmp/qjson-test/libqjson.so", err, 1, true) assert.is_nil(string.find(err, "loadall.so", 1, true)) end) + + it("skips loadable libraries without qjson symbols", function() + local old_lib = package.loaded["qjson.lib"] + local old_ffi = package.loaded.ffi + local old_cpath = package.cpath + local valid_lib = { qjson_parse = true } + + package.loaded["qjson.lib"] = nil + package.loaded.ffi = { + load = function(name) + if name == "qjson" then + return {} + end + if name == "libqjson" then + return valid_lib + end + error("unexpected load: " .. name) + end, + } + package.cpath = "" + + local ok, lib = pcall(require, "qjson.lib") + + package.loaded["qjson.lib"] = old_lib + package.loaded.ffi = old_ffi + package.cpath = old_cpath + + assert.is_true(ok) + assert.are.equal(valid_lib, lib) + end) end) From 6cf19916994638ec6f8948958e1c3ec01664b512 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 23:16:43 +0800 Subject: [PATCH 10/13] ci: pin luajit package validation runtime --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b50ec7..18ef81f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,9 +121,9 @@ jobs: rustup default stable - name: Install LuaJIT - uses: leafo/gh-actions-lua@v10 + uses: leafo/gh-actions-lua@v13 with: - luaVersion: "luajit" + luaVersion: "luajit-2.1.0-beta3" - name: Install LuaRocks uses: leafo/gh-actions-luarocks@v4 From fbab3ad5d3583e6425ef00844862a9d4a4c0e5d8 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 23:22:13 +0800 Subject: [PATCH 11/13] ci: use lua command for package smoke --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18ef81f..6aac170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,4 +138,4 @@ jobs: eval "$(luarocks path --tree /tmp/lua-qjson-rock)" unset LD_LIBRARY_PATH unset DYLD_LIBRARY_PATH - luajit -e 'local qjson = require("qjson"); local doc = qjson.parse("{\"a\":42}"); assert(doc:get_i64("a") == 42)' + lua -e 'assert(jit, "LuaJIT required"); local qjson = require("qjson"); local doc = qjson.parse("{\"a\":42}"); assert(doc:get_i64("a") == 42)' From ffc82a22e0bea82e7cc4f4f0bec63e0aea6d32b3 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 18 May 2026 23:31:17 +0800 Subject: [PATCH 12/13] chore: harden release package selection --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++-- .github/workflows/release.yml | 38 +++++++++++++++++++++++++------ lua/qjson/lib.lua | 42 ++++++++++++++++++++++++++++------- tests/lua/loader_spec.lua | 8 ++++++- 4 files changed, 104 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aac170..753206c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,22 @@ jobs: - name: Validate LuaRocks package run: | rm -rf /tmp/lua-qjson-rock - ROCKSPEC="$(ls -1 rockspec/lua-qjson-*.rockspec | sort | tail -n1)" + ROCKSPEC="$(python3 - <<'PY' + import re + from pathlib import Path + + pattern = re.compile(r"^lua-qjson-(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?-(\d+)\.rockspec$") + matches = [] + for path in Path("rockspec").glob("lua-qjson-*.rockspec"): + match = pattern.match(path.name) + if match: + major, minor, patch, prerelease, revision = match.groups() + matches.append((int(major), int(minor), int(patch), prerelease is None, prerelease or "", int(revision), str(path))) + if not matches: + raise SystemExit("no lua-qjson rockspec found") + print(max(matches)[-1]) + PY + )" luarocks make "$ROCKSPEC" --tree /tmp/lua-qjson-rock eval "$(luarocks path --tree /tmp/lua-qjson-rock)" unset LD_LIBRARY_PATH @@ -133,7 +148,22 @@ jobs: - name: Validate LuaRocks package run: | rm -rf /tmp/lua-qjson-rock - ROCKSPEC="$(ls -1 rockspec/lua-qjson-*.rockspec | sort | tail -n1)" + ROCKSPEC="$(python3 - <<'PY' + import re + from pathlib import Path + + pattern = re.compile(r"^lua-qjson-(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?-(\d+)\.rockspec$") + matches = [] + for path in Path("rockspec").glob("lua-qjson-*.rockspec"): + match = pattern.match(path.name) + if match: + major, minor, patch, prerelease, revision = match.groups() + matches.append((int(major), int(minor), int(patch), prerelease is None, prerelease or "", int(revision), str(path))) + if not matches: + raise SystemExit("no lua-qjson rockspec found") + print(max(matches)[-1]) + PY + )" luarocks make "$ROCKSPEC" --tree /tmp/lua-qjson-rock eval "$(luarocks path --tree /tmp/lua-qjson-rock)" unset LD_LIBRARY_PATH diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6955f47..006489c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,14 +58,37 @@ jobs: - name: Verify rockspec exists id: verify_rockspec shell: bash + env: + VERSION_WITHOUT_V: ${{ steps.release_env.outputs.version_without_v }} run: | - mapfile -t rockspecs < <(find rockspec -maxdepth 1 -name "lua-qjson-${{ steps.release_env.outputs.version_without_v }}-*.rockspec" -print | sort) - if [ ${#rockspecs[@]} -eq 0 ]; then - echo "rockspec file not found for version ${{ steps.release_env.outputs.version_without_v }}" + rockspec_file="$(python3 - <<'PY' + import os + import re + from pathlib import Path + + version = os.environ["VERSION_WITHOUT_V"] + pattern = re.compile(r"^lua-qjson-" + re.escape(version) + r"-(\d+)\.rockspec$") + matches = [] + for path in Path("rockspec").glob("lua-qjson-" + version + "-*.rockspec"): + match = pattern.match(path.name) + if match: + matches.append((int(match.group(1)), str(path))) + if not matches: + raise SystemExit("rockspec file not found for version " + version) + print(max(matches)[1]) + PY + )" + echo "rockspec_file=${rockspec_file}" >> $GITHUB_OUTPUT + + - name: Validate LuaRocks token + shell: bash + env: + LUAROCKS_TOKEN: ${{ secrets.LUAROCKS_TOKEN }} + run: | + if [ -z "$LUAROCKS_TOKEN" ]; then + echo "LUAROCKS_TOKEN secret is not configured" exit 1 fi - rockspec_file="${rockspecs[$((${#rockspecs[@]} - 1))]}" - echo "rockspec_file=${rockspec_file}" >> $GITHUB_OUTPUT - name: Create Release uses: softprops/action-gh-release@v2 @@ -78,10 +101,11 @@ jobs: - name: Upload to LuaRocks env: LUAROCKS_TOKEN: ${{ secrets.LUAROCKS_TOKEN }} + ROCKSPEC_FILE: ${{ steps.verify_rockspec.outputs.rockspec_file }} shell: bash run: | mkdir -p "$HOME/.luarocks" chmod 700 "$HOME/.luarocks" - lua -e 'local token = assert(os.getenv("LUAROCKS_TOKEN"), "missing LUAROCKS_TOKEN"); local f = assert(io.open(os.getenv("HOME") .. "/.luarocks/upload_config.lua", "w")); f:write(("key = %q\n"):format(token)); f:write("server = \"https://luarocks.org\"\nversion = \"1.0\"\n"); f:close()' + lua -e 'local token = os.getenv("LUAROCKS_TOKEN"); assert(token and token ~= "", "missing LUAROCKS_TOKEN"); local f = assert(io.open(os.getenv("HOME") .. "/.luarocks/upload_config.lua", "w")); f:write(("key = %q\n"):format(token)); f:write("server = \"https://luarocks.org\"\nversion = \"1.0\"\n"); f:close()' chmod 600 "$HOME/.luarocks/upload_config.lua" - luarocks upload "${{ steps.verify_rockspec.outputs.rockspec_file }}" + luarocks upload "$ROCKSPEC_FILE" diff --git a/lua/qjson/lib.lua b/lua/qjson/lib.lua index 3ebb2b6..7abd9aa 100644 --- a/lua/qjson/lib.lua +++ b/lua/qjson/lib.lua @@ -3,7 +3,31 @@ local ffi = require("ffi") local tried = {} local attempts = {} local last_error -local required_symbol = "qjson_parse" +local required_symbols = { + "qjson_strerror", + "qjson_parse", + "qjson_parse_ex", + "qjson_free", + "qjson_get_str", + "qjson_get_i64", + "qjson_get_f64", + "qjson_get_bool", + "qjson_is_null", + "qjson_typeof", + "qjson_len", + "qjson_open", + "qjson_cursor_open", + "qjson_cursor_field", + "qjson_cursor_index", + "qjson_cursor_get_str", + "qjson_cursor_get_i64", + "qjson_cursor_get_f64", + "qjson_cursor_get_bool", + "qjson_cursor_typeof", + "qjson_cursor_len", + "qjson_cursor_bytes", + "qjson_cursor_object_entry_at", +} local function try_load(name) if tried[name] then @@ -13,14 +37,16 @@ local function try_load(name) attempts[#attempts + 1] = name local ok, lib = pcall(ffi.load, name) if ok then - local has_symbol, symbol = pcall(function() - return lib[required_symbol] - end) - if has_symbol and symbol ~= nil then - return lib + for _, required_symbol in ipairs(required_symbols) do + local has_symbol, symbol = pcall(function() + return lib[required_symbol] + end) + if not has_symbol or symbol == nil then + last_error = "loaded " .. name .. " but missing required symbol " .. required_symbol + return nil + end end - last_error = "loaded " .. name .. " but missing required symbol " .. required_symbol - return nil + return lib end last_error = lib return nil diff --git a/tests/lua/loader_spec.lua b/tests/lua/loader_spec.lua index be8b7e5..3ea571b 100644 --- a/tests/lua/loader_spec.lua +++ b/tests/lua/loader_spec.lua @@ -29,7 +29,13 @@ describe("qjson native loader", function() local old_lib = package.loaded["qjson.lib"] local old_ffi = package.loaded.ffi local old_cpath = package.cpath - local valid_lib = { qjson_parse = true } + local valid_lib = setmetatable({}, { + __index = function(_, key) + if string.sub(key, 1, 6) == "qjson_" then + return true + end + end, + }) package.loaded["qjson.lib"] = nil package.loaded.ffi = { From 703dc3518bd135a7783b5e6ae71ee252072a2d28 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 19 May 2026 08:35:28 +0800 Subject: [PATCH 13/13] Address LuaRocks review comments --- .github/workflows/ci.yml | 26 +++++++++++++++++++++-- lua/qjson.lua | 43 --------------------------------------- lua/qjson/lib.lua | 43 +++++++++++++++++++++++++++++++++++++++ lua/qjson/table.lua | 4 ---- tests/lua/loader_spec.lua | 2 ++ 5 files changed, 69 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 753206c..028dfa2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,12 +101,23 @@ jobs: from pathlib import Path pattern = re.compile(r"^lua-qjson-(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?-(\d+)\.rockspec$") + def prerelease_key(value): + if value is None: + return () + key = [] + for part in value.split("."): + if part.isdigit(): + key.append((0, int(part))) + else: + key.append((1, part)) + return tuple(key) + matches = [] for path in Path("rockspec").glob("lua-qjson-*.rockspec"): match = pattern.match(path.name) if match: major, minor, patch, prerelease, revision = match.groups() - matches.append((int(major), int(minor), int(patch), prerelease is None, prerelease or "", int(revision), str(path))) + matches.append((int(major), int(minor), int(patch), prerelease is None, prerelease_key(prerelease), int(revision), str(path))) if not matches: raise SystemExit("no lua-qjson rockspec found") print(max(matches)[-1]) @@ -153,12 +164,23 @@ jobs: from pathlib import Path pattern = re.compile(r"^lua-qjson-(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?-(\d+)\.rockspec$") + def prerelease_key(value): + if value is None: + return () + key = [] + for part in value.split("."): + if part.isdigit(): + key.append((0, int(part))) + else: + key.append((1, part)) + return tuple(key) + matches = [] for path in Path("rockspec").glob("lua-qjson-*.rockspec"): match = pattern.match(path.name) if match: major, minor, patch, prerelease, revision = match.groups() - matches.append((int(major), int(minor), int(patch), prerelease is None, prerelease or "", int(revision), str(path))) + matches.append((int(major), int(minor), int(patch), prerelease is None, prerelease_key(prerelease), int(revision), str(path))) if not matches: raise SystemExit("no lua-qjson rockspec found") print(max(matches)[-1]) diff --git a/lua/qjson.lua b/lua/qjson.lua index cfcf4ff..d30b72b 100644 --- a/lua/qjson.lua +++ b/lua/qjson.lua @@ -1,48 +1,5 @@ local ffi = require("ffi") -ffi.cdef[[ -typedef struct qjson_doc qjson_doc; -typedef struct { - const qjson_doc* doc; - uint32_t idx_start, idx_end, _reserved0, _reserved1; -} qjson_cursor; - -typedef struct { - uint32_t mode; - uint32_t max_depth; -} qjson_options; - -const char* qjson_strerror(int code); -qjson_doc* qjson_parse (const uint8_t* buf, size_t len, int* err_out); -qjson_doc* qjson_parse_ex(const uint8_t* buf, size_t len, - const qjson_options* opts, int* err_out); -void qjson_free (qjson_doc* doc); - -int qjson_get_str (qjson_doc*, const char* path, size_t path_len, const uint8_t** p, size_t* n); -int qjson_get_i64 (qjson_doc*, const char* path, size_t path_len, int64_t* out); -int qjson_get_f64 (qjson_doc*, const char* path, size_t path_len, double* out); -int qjson_get_bool(qjson_doc*, const char* path, size_t path_len, int* out); -int qjson_is_null (qjson_doc*, const char* path, size_t path_len, int* out); -int qjson_typeof (qjson_doc*, const char* path, size_t path_len, int* out); -int qjson_len (qjson_doc*, const char* path, size_t path_len, size_t* out); - -int qjson_open (qjson_doc*, const char* path, size_t path_len, qjson_cursor* out); -int qjson_cursor_open (const qjson_cursor*, const char* path, size_t path_len, qjson_cursor* out); -int qjson_cursor_field(const qjson_cursor*, const char* key, size_t key_len, qjson_cursor* out); -int qjson_cursor_index(const qjson_cursor*, size_t i, qjson_cursor* out); - -int qjson_cursor_get_str (const qjson_cursor*, const char*, size_t, const uint8_t**, size_t*); -int qjson_cursor_get_i64 (const qjson_cursor*, const char*, size_t, int64_t*); -int qjson_cursor_get_f64 (const qjson_cursor*, const char*, size_t, double*); -int qjson_cursor_get_bool(const qjson_cursor*, const char*, size_t, int*); -int qjson_cursor_typeof (const qjson_cursor*, const char*, size_t, int*); -int qjson_cursor_len (const qjson_cursor*, const char*, size_t, size_t*); -int qjson_cursor_bytes(const qjson_cursor*, size_t* byte_start, size_t* byte_end); -int qjson_cursor_object_entry_at(const qjson_cursor*, size_t i, - const uint8_t** key_ptr, size_t* key_len, - qjson_cursor* value_out); -]] - local C = require("qjson.lib") local err_box = ffi.new("int[1]") diff --git a/lua/qjson/lib.lua b/lua/qjson/lib.lua index 7abd9aa..3e6c686 100644 --- a/lua/qjson/lib.lua +++ b/lua/qjson/lib.lua @@ -1,5 +1,48 @@ local ffi = require("ffi") +ffi.cdef[[ +typedef struct qjson_doc qjson_doc; +typedef struct { + const qjson_doc* doc; + uint32_t idx_start, idx_end, _reserved0, _reserved1; +} qjson_cursor; + +typedef struct { + uint32_t mode; + uint32_t max_depth; +} qjson_options; + +const char* qjson_strerror(int code); +qjson_doc* qjson_parse (const uint8_t* buf, size_t len, int* err_out); +qjson_doc* qjson_parse_ex(const uint8_t* buf, size_t len, + const qjson_options* opts, int* err_out); +void qjson_free (qjson_doc* doc); + +int qjson_get_str (qjson_doc*, const char* path, size_t path_len, const uint8_t** p, size_t* n); +int qjson_get_i64 (qjson_doc*, const char* path, size_t path_len, int64_t* out); +int qjson_get_f64 (qjson_doc*, const char* path, size_t path_len, double* out); +int qjson_get_bool(qjson_doc*, const char* path, size_t path_len, int* out); +int qjson_is_null (qjson_doc*, const char* path, size_t path_len, int* out); +int qjson_typeof (qjson_doc*, const char* path, size_t path_len, int* out); +int qjson_len (qjson_doc*, const char* path, size_t path_len, size_t* out); + +int qjson_open (qjson_doc*, const char* path, size_t path_len, qjson_cursor* out); +int qjson_cursor_open (const qjson_cursor*, const char* path, size_t path_len, qjson_cursor* out); +int qjson_cursor_field(const qjson_cursor*, const char* key, size_t key_len, qjson_cursor* out); +int qjson_cursor_index(const qjson_cursor*, size_t i, qjson_cursor* out); + +int qjson_cursor_get_str (const qjson_cursor*, const char*, size_t, const uint8_t**, size_t*); +int qjson_cursor_get_i64 (const qjson_cursor*, const char*, size_t, int64_t*); +int qjson_cursor_get_f64 (const qjson_cursor*, const char*, size_t, double*); +int qjson_cursor_get_bool(const qjson_cursor*, const char*, size_t, int*); +int qjson_cursor_typeof (const qjson_cursor*, const char*, size_t, int*); +int qjson_cursor_len (const qjson_cursor*, const char*, size_t, size_t*); +int qjson_cursor_bytes(const qjson_cursor*, size_t* byte_start, size_t* byte_end); +int qjson_cursor_object_entry_at(const qjson_cursor*, size_t i, + const uint8_t** key_ptr, size_t* key_len, + qjson_cursor* value_out); +]] + local tried = {} local attempts = {} local last_error diff --git a/lua/qjson/table.lua b/lua/qjson/table.lua index 04e1f93..86f50d0 100644 --- a/lua/qjson/table.lua +++ b/lua/qjson/table.lua @@ -1,8 +1,4 @@ -- Lazy table view + cjson-compatible encoder for qjson. --- --- This module relies on the FFI cdef set up by `lua/qjson.lua`, so --- callers must `require("qjson")` (transitively or directly) before --- they require this module. local ffi = require("ffi") local C = require("qjson.lib") diff --git a/tests/lua/loader_spec.lua b/tests/lua/loader_spec.lua index 3ea571b..c78b16d 100644 --- a/tests/lua/loader_spec.lua +++ b/tests/lua/loader_spec.lua @@ -6,6 +6,7 @@ describe("qjson native loader", function() package.loaded["qjson.lib"] = nil package.loaded.ffi = { + cdef = function() end, load = function(name) error("mock load failure for " .. name) end, @@ -39,6 +40,7 @@ describe("qjson native loader", function() package.loaded["qjson.lib"] = nil package.loaded.ffi = { + cdef = function() end, load = function(name) if name == "qjson" then return {}