Skip to content

Commit 184fd03

Browse files
authored
Merge pull request #11 from commandprompt/fix/pgxn-meta-tag
Fix the PGXN tag that blocked the 2.0.0 upload, and check META.json in CI
2 parents cecb6d1 + 67266f1 commit 184fd03

5 files changed

Lines changed: 188 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ jobs:
4848
echo "PGHOST=/var/run/postgresql" >> "$GITHUB_ENV"
4949
echo "PGPORT=$port" >> "$GITHUB_ENV"
5050
51+
# No server needed, and it guards the release process rather than the
52+
# code, so it runs before anything else can fail.
53+
- name: Check META.json against the PGXN spec
54+
run: make metacheck PG_CONFIG="$PG_CONFIG"
55+
5156
- name: Run the regression suite
5257
run: make installcheck PG_CONFIG="$PG_CONFIG"
5358

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@ All notable changes to plx are recorded here. The format follows
44
[Keep a Changelog](https://keepachangelog.com/), and plx uses the extension
55
version in `plx.control` (currently `1.0`).
66

7+
## [2.0.1] - 2026-08-25
8+
9+
Packaging only. **The extension is unchanged at 2.0.0**, so there is no
10+
`ALTER EXTENSION plx UPDATE` for this release and nothing to install if you are
11+
already on 2.0.0. Only the distribution metadata changed, which is why the
12+
distribution version moved and the extension version did not.
13+
14+
### Fixed
15+
16+
- `META.json` listed the tag `pl/sql`, which PGXN rejects: a Tag may not contain
17+
a slash. It is now `plsql`. This was found only when PGXN refused the 2.0.0
18+
upload, since the check happens at upload time, after a release has been
19+
tagged and published.
20+
21+
### Added
22+
23+
- `make metacheck` (`test/check_meta.py`) validates `META.json` against the
24+
PGXN Meta Specification v1 before a release rather than at upload time, and
25+
runs in CI. It reproduces the rejection above.
26+
27+
Worth recording, because the prose and the schema disagree and the prose is
28+
the trap: the spec text says a Tag may contain no "slash, backslash, control,
29+
or space" characters, but that sentence describes a *Term*. The Tag schema is
30+
`^[^/\\\p{Cntrl}]{2,}$`, which permits spaces. So `sql server` is a valid
31+
tag and an invalid term, and only the slash in `pl/sql` was ever a problem.
32+
733
## [2.0.0] - 2026-08-24
834

935
Major release for one behaviour change: interpolating a NULL now propagates it

META.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "plx",
33
"abstract": "Write PostgreSQL functions in Ruby, PHP, JavaScript, TypeScript, Python, Go, COBOL, Oracle PL/SQL, or Transact-SQL, compiled to plpgsql",
44
"description": "plx is a dialect-pluggable procedural language for PostgreSQL. A function body written in a Ruby, PHP, JavaScript, TypeScript, Python, Go, COBOL, Oracle PL/SQL, or Transact-SQL (SQL Server) dialect is transpiled to plpgsql at CREATE FUNCTION time and stored in pg_proc.prosrc, then executed by the standard plpgsql interpreter. There is no separate language runtime in the backend, the generated plpgsql is visible in the catalog, and every plpgsql construct is reachable from every dialect. plx also ships plx_strbuild, an expanded-object string builder with amortized-O(1) append that fixes plpgsql's quadratic in-loop string concatenation.",
5-
"version": "2.0.0",
5+
"version": "2.0.1",
66
"maintainer": [
77
"Command Prompt, Inc. <pgxn@commandprompt.com>"
88
],
@@ -50,7 +50,7 @@
5050
"go",
5151
"cobol",
5252
"oracle",
53-
"pl/sql",
53+
"plsql",
5454
"transact-sql",
5555
"sql server",
5656
"string builder"

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,10 @@ PLX_PYTHON ?= python3
2828
.PHONY: differentialcheck
2929
differentialcheck:
3030
PLX_PSQL="$(shell $(PG_CONFIG) --bindir)/psql" $(PLX_PYTHON) test/differential.py
31+
32+
# Check META.json against the PGXN Meta Spec. PGXN validates at upload time,
33+
# which is after a release is tagged and published, so check it before then.
34+
# Needs no server.
35+
.PHONY: metacheck
36+
metacheck:
37+
$(PLX_PYTHON) test/check_meta.py

test/check_meta.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
#!/usr/bin/env python3
2+
"""Validate META.json against the PGXN Meta Specification v1.
3+
4+
PGXN rejects a distribution whose META.json does not satisfy the spec, and it
5+
does so at upload time, which is after a release has been tagged and published.
6+
That is an expensive place to discover a typo, so the constraints are checked
7+
here instead.
8+
9+
The rules mirror the v1 schemas at https://github.com/pgxn/meta. Note that a
10+
Tag and a Term are not the same: both exclude slash, backslash and control
11+
characters, but only a Term also excludes spaces, so "sql server" is a valid
12+
tag and an invalid term.
13+
14+
Run with `make metacheck`.
15+
"""
16+
import json
17+
import os
18+
import re
19+
import sys
20+
import unicodedata
21+
22+
HERE = os.path.dirname(os.path.abspath(__file__))
23+
META = os.environ.get("PLX_META", os.path.join(HERE, os.pardir, "META.json"))
24+
25+
REQUIRED = ["name", "version", "abstract", "maintainer", "license", "provides",
26+
"meta-spec"]
27+
SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
28+
r"(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?"
29+
r"(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$")
30+
RELEASE_STATUS = {"stable", "testing", "unstable"}
31+
32+
33+
def has_control(s):
34+
return any(unicodedata.category(c) == "Cc" for c in s)
35+
36+
37+
def check_term(value, label, errs):
38+
"""A Term: 2 or more characters, no slash, backslash, space or control."""
39+
if not isinstance(value, str) or len(value) < 2:
40+
errs.append("%s must be a string of at least two characters" % label)
41+
return
42+
for ch, name in (("/", "slash"), ("\\", "backslash")):
43+
if ch in value:
44+
errs.append("%s %r must not contain a %s" % (label, value, name))
45+
if any(c.isspace() for c in value):
46+
errs.append("%s %r must not contain whitespace" % (label, value))
47+
if has_control(value):
48+
errs.append("%s %r must not contain control characters" % (label, value))
49+
50+
51+
def check_tag(value, label, errs):
52+
"""A Tag: 2 to 255 characters, no slash, backslash or control. Spaces are
53+
allowed, which is the one way a Tag is looser than a Term."""
54+
if not isinstance(value, str):
55+
errs.append("%s must be a string" % label)
56+
return
57+
if not 2 <= len(value) <= 255:
58+
errs.append("%s %r must be between 2 and 255 characters" % (label, value))
59+
for ch, name in (("/", "slash"), ("\\", "backslash")):
60+
if ch in value:
61+
errs.append("%s %r must not contain a %s" % (label, value, name))
62+
if has_control(value):
63+
errs.append("%s %r must not contain control characters" % (label, value))
64+
65+
66+
def main():
67+
try:
68+
with open(META, encoding="utf-8") as fh:
69+
m = json.load(fh)
70+
except (OSError, ValueError) as exc:
71+
sys.stderr.write("META.json could not be read as JSON: %s\n" % exc)
72+
return 2
73+
74+
errs = []
75+
for field in REQUIRED:
76+
if field not in m:
77+
errs.append("required field %s is missing" % field)
78+
79+
if "name" in m:
80+
check_term(m["name"], "name", errs)
81+
82+
if "version" in m and not SEMVER.match(str(m["version"])):
83+
errs.append("version %r is not a semantic version" % m["version"])
84+
85+
if "maintainer" in m:
86+
mt = m["maintainer"]
87+
mt = [mt] if isinstance(mt, str) else mt
88+
if not isinstance(mt, list) or not mt:
89+
errs.append("maintainer must be a string or a non-empty list")
90+
elif not all(isinstance(x, str) and x.strip() for x in mt):
91+
errs.append("every maintainer must be a non-empty string")
92+
93+
if "meta-spec" in m and "version" not in (m["meta-spec"] or {}):
94+
errs.append("meta-spec must carry a version")
95+
96+
prov = m.get("provides")
97+
if not isinstance(prov, dict) or not prov:
98+
errs.append("provides must be a non-empty object")
99+
else:
100+
for name, ext in prov.items():
101+
check_term(name, "provides key", errs)
102+
if not isinstance(ext, dict):
103+
errs.append("provides/%s must be an object" % name)
104+
continue
105+
if "file" not in ext:
106+
errs.append("provides/%s is missing file" % name)
107+
elif not os.path.exists(os.path.join(os.path.dirname(META),
108+
ext["file"])):
109+
errs.append("provides/%s names %s, which is not in the "
110+
"distribution" % (name, ext["file"]))
111+
if "version" not in ext:
112+
errs.append("provides/%s is missing version" % name)
113+
elif not SEMVER.match(str(ext["version"])):
114+
errs.append("provides/%s version %r is not a semantic version"
115+
% (name, ext["version"]))
116+
117+
if "tags" in m:
118+
tags = m["tags"]
119+
if not isinstance(tags, list) or not tags:
120+
errs.append("tags must be a non-empty list")
121+
else:
122+
if len(set(tags)) != len(tags):
123+
errs.append("tags must be unique")
124+
for i, t in enumerate(tags):
125+
check_tag(t, "tags[%d]" % i, errs)
126+
127+
if "release_status" in m and m["release_status"] not in RELEASE_STATUS:
128+
errs.append("release_status %r must be one of %s"
129+
% (m["release_status"], ", ".join(sorted(RELEASE_STATUS))))
130+
131+
if errs:
132+
print("META.json does not satisfy the PGXN Meta Spec v1:")
133+
for e in errs:
134+
print(" %s" % e)
135+
return 1
136+
137+
print("META.json satisfies the PGXN Meta Spec v1")
138+
print(" name %s" % m["name"])
139+
print(" version %s (distribution)" % m["version"])
140+
for name, ext in m["provides"].items():
141+
print(" provides %s %s -> %s"
142+
% (name, ext["version"], ext["file"]))
143+
print(" tags %d, all valid" % len(m.get("tags", [])))
144+
return 0
145+
146+
147+
if __name__ == "__main__":
148+
sys.exit(main())

0 commit comments

Comments
 (0)