mudlet/CI/check-mpackage-sync.lua
Vadim Peretokin f7da6ca3da
improve: ship every default package as an mpackage (#9626)
#### Brief overview of PR changes/additions

- Every package Mudlet preinstalls now lives in `src/packages/<name>/`,
holding its `config.lua`, `.xml` and the `.mpackage` built from them -
previously they were scattered across `src/` and `src/mudlet-lua/lua/`,
and four had no metadata at all.
- The game loaders (Carrion Fields, Icesus, MorgenGrauen, Medievia) and
the two `mudlet.org` dev packages shipped as bare xml, so the Package
Manager showed them with no version, author or description. They are
packaged now, keeping their existing package names so nothing renames on
upgrade.
- New `DefaultPackagesTest` walks the preinstall table for seven games,
checks every queued path is really compiled in, and installs all 15
archives.

#### Motivation for adding to Mudlet

An mpackage carries Mudlet's metadata and a bare xml cannot. Keeping
each package's sources next to its archive also makes the Lua reviewable
in diffs, which a committed zip on its own is not.

#### Other info

Stacked on #9624, whose check now covers all 15 packages. The IRE mapper
stays an xml because upstream publishes it that way and
`update-3rdparty.yml` overwrites it weekly. Package repository PR
Mudlet/mudlet-package-repository#746 updates the sync paths and must
merge right after this.

#### Test case

`ctest` 66/67 locally (`TKeySequenceEditTest` is the known headless
flake - passes under openbox); `DefaultPackagesTest` 28/28; `python3
CI/check-mpackage-sync.py --base-ref origin/development` clean.

Assisted-by: Claude:claude-opus-5

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-08-04 10:18:48 +02:00

194 lines
6.2 KiB
Lua
Executable file

#!/usr/bin/env lua
--[[
Check packaged .mpackage archives against their checked-in sources.
Mudlet installs the .mpackage archive, not the loose config.lua/.xml files
sitting next to it, so editing a source file without rebuilding the archive
silently changes nothing at all.
Some of these packages are also published to the package repository
(Mudlet/mudlet-package-repository), which offers updates by comparing the
version in config.lua. A content change that keeps the old version number
never reaches players who installed the package with mpkg.
Run with no arguments to check archive contents. Pass --base-ref to also
require a version bump for any package whose contents changed:
lua CI/check-mpackage-sync.lua --base-ref origin/development
]]
-- packages the package repository syncs weekly, where mpkg needs a version bump
-- to offer the update - see update-core-packages.yml over there
local publishedPackages = {
"src/packages/deleteOldProfiles/deleteOldProfiles.mpackage",
"src/packages/echo/echo.mpackage",
"src/packages/enable-accessibility/enable-accessibility.mpackage",
"src/packages/generic_mapper/generic_mapper.mpackage",
"src/packages/mudlet-base-ui/mudlet-base-ui.mpackage",
"src/packages/run-lua-code/run-lua-code.mpackage",
}
local errors = {}
local function contains(list, wanted)
for _, item in ipairs(list) do
if item == wanted then return true end
end
return false
end
local function quote(argument)
return "'" .. argument:gsub("'", "'\\''") .. "'"
end
local function capture(command)
local pipe = assert(io.popen(command, "r"))
local output = pipe:read("*a")
pipe:close()
return output
end
local function readFile(path)
local file = io.open(path, "rb")
if not file then return nil end
local contents = file:read("*a")
file:close()
return contents
end
-- Every entry in the archive, mapped to its bytes. Directory entries, which
-- zipinfo lists with a trailing slash, are not files and are skipped.
local function contentsOf(archive)
local members = {}
for name in capture("unzip -Z1 " .. quote(archive)):gmatch("[^\n]+") do
if not name:match("/$") then
members[name] = capture(string.format("unzip -p %s %s", quote(archive), quote(name)))
end
end
return members
end
local function sameContents(one, other)
for name, bytes in pairs(one) do
if other[name] ~= bytes then return false end
end
for name in pairs(other) do
if one[name] == nil then return false end
end
return true
end
local function versionOf(members)
for line in (members["config.lua"] or ""):gmatch("[^\n]+") do
local version = line:match("^version%s*=%s*(.-)%s*$")
if version then
return (version:gsub("^[%[\"']+", ""):gsub("[%]\"']+$", ""))
end
end
return nil
end
-- Sortable form of a version, tolerating parts like "2" or "1.0.0rc1"
local function versionParts(version)
local parts = {}
for part in version:gmatch("[^.]+") do
parts[#parts + 1] = {tonumber(part:match("%d+")) or 0, part}
end
return parts
end
local function isNewer(candidate, existing)
local new, old = versionParts(candidate), versionParts(existing)
for index = 1, math.max(#new, #old) do
local newPart = new[index] or {0, ""}
local oldPart = old[index] or {0, ""}
if newPart[1] ~= oldPart[1] then return newPart[1] > oldPart[1] end
if newPart[2] ~= oldPart[2] then return newPart[2] > oldPart[2] end
end
return false
end
-- Archive contents at baseRef, or nil if the package is new there
local function contentsAtBaseRef(path, baseRef)
local temporary = os.tmpname()
local archive = capture(string.format("git show %s 2>/dev/null", quote(baseRef .. ":" .. path)))
if archive == "" then
os.remove(temporary)
return nil
end
local file = assert(io.open(temporary, "wb"))
file:write(archive)
file:close()
local members = contentsOf(temporary)
os.remove(temporary)
return members
end
-- Every member with a file of the same name beside the archive must match it
local function checkSourcesMatch(path, members)
local directory = path:match("^(.*)/[^/]+$")
for name, packaged in pairs(members) do
local source = directory .. "/" .. name
local onDisk = readFile(source)
if onDisk and onDisk ~= packaged then
table.insert(errors, string.format("%s does not match %s - rebuild the archive after editing the source", path, source))
end
end
end
local function checkVersionBumped(path, members, baseRef)
local was = contentsAtBaseRef(path, baseRef)
if not was or sameContents(was, members) then return end
local old, new = versionOf(was), versionOf(members)
if not new then
table.insert(errors, string.format("%s has no version in its config.lua", path))
elseif old and not isNewer(new, old) then
table.insert(errors, string.format("%s changed but is still version %s - bump it so mpkg offers the update", path, new))
end
end
local baseRef
for index = 1, #arg do
if arg[index] == "--base-ref" then
baseRef = arg[index + 1]
elseif arg[index]:match("^%-%-base%-ref=") then
baseRef = arg[index]:match("=(.*)$")
end
end
-- every default package lives in its own directory under src/packages, named
-- after the package, holding the archive and the sources it was built from
local packages = {}
for name in capture("ls -1 src/packages"):gmatch("[^\n]+") do
local archive = string.format("src/packages/%s/%s.mpackage", name, name)
if readFile(archive) then table.insert(packages, archive) end
end
table.sort(packages)
for _, path in ipairs(publishedPackages) do
if not contains(packages, path) then
table.insert(errors, string.format("%s is listed as published but is not in src/packages", path))
end
end
for _, path in ipairs(packages) do
local members = contentsOf(path)
checkSourcesMatch(path, members)
if baseRef and contains(publishedPackages, path) then
checkVersionBumped(path, members, baseRef)
end
end
for _, message in ipairs(errors) do
print("error: " .. message)
end
if #errors > 0 then
print(string.format("\n%d problem(s) found. Rebuild an archive from its sources with:", #errors))
print(" cd src/packages/<name> && zip <name>.mpackage config.lua <name>.xml")
os.exit(1)
end
print(string.format("%d mpackage archives match their sources.", #packages))