From b76954d243fee60604f408aacdbd7bb1a88329d2 Mon Sep 17 00:00:00 2001 From: JamBalaya56562 Date: Thu, 30 Apr 2026 02:58:47 +0900 Subject: [PATCH] fix: replace broken HTML-scraping version discovery Resolves #12, #17. The Linux/macOS branch of hooks/available.lua scraped php.net/downloads.php for h3#vX.Y.Z elements; that structure no longer exists on php.net, so PLUGIN:Available() silently dropped the LTS list. mise install php (which embeds vfox-php) failed to resolve php@latest to anything usable, and the remaining list was returned in a non-sorted order so latest landed on 5.6.16-nts. Replace the HTML scraping with a JSON manifest published as a release asset on the version-manifest tag, refreshed twice a week by a new GitHub Actions cron (.github/workflows/update-version-list.yaml). The manifest is generated from authoritative sources: - https://www.php.net/releases/index.php?json for source tarballs - https://windows.php.net/downloads/releases/{,archives/} for Windows zips Plugin Lua becomes trivially simple: HTTP-fetch the manifest, with a local-file fallback (RUNTIME.pluginDirPath/version-manifest.json) for offline installs and CI runs that pre-generate the manifest before zipping. Asset, not body: a complete pretty-printed manifest is ~352 KB, which exceeds GitHub's 125,000-character release-body cap. The manifest gets its own tag (version-manifest) so it cannot collide with the manifest tag owned by plugin-manifest-action. Also bump metadata.lua / Injection.lua to the current vfox-plugin-template conventions (minRuntimeVersion 0.5.1, plugin version 0.4.0) and align all GitHub Actions to current major versions (actions/checkout@v6, actions/setup-python@v6). The test workflows now extract the just-installed PHP version with 'grep -oE ...' / 'Select-String' rather than the legacy 'sed -n "s/-> v//p"' trick. vfox 1.0+ does not mark a version with '-> v' until something has been activated, so the legacy extraction returned an empty string right after install and 'vfox use -p php@' fell into an interactive prompt that CI cannot answer. --- .github/update-manifest.py | 182 +++++++++++++++++++++ .github/workflows/publish.yaml | 2 +- .github/workflows/test-linux.yaml | 19 ++- .github/workflows/test-macos.yaml | 19 ++- .github/workflows/test-windows.yaml | 20 ++- .github/workflows/update-version-list.yaml | 88 ++++++++++ .gitignore | 3 +- Injection.lua | 5 +- README.md | 24 ++- hooks/available.lua | 84 ++-------- hooks/pre_install.lua | 108 ++++++------ lib/constants.lua | 9 +- lib/util.lua | 101 ++++++------ metadata.lua | 12 +- 14 files changed, 482 insertions(+), 194 deletions(-) create mode 100644 .github/update-manifest.py create mode 100644 .github/workflows/update-version-list.yaml diff --git a/.github/update-manifest.py b/.github/update-manifest.py new file mode 100644 index 0000000..173af87 --- /dev/null +++ b/.github/update-manifest.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Generate the vfox-php version manifest from upstream PHP release indexes. + +Outputs JSON with two top-level arrays: + + { + "source": [ {"version", "filename", "sha256", "md5"}, ... ], + "windows": [ {"version", "filename", "arch", "current", "nts"}, ... ] + } + +Both arrays are sorted newest-first using a numeric version key. + +Sources: + - https://www.php.net/releases/index.php?json (canonical source releases) + - https://windows.php.net/downloads/releases/{,archives/} (Windows binaries) +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import time +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +USER_AGENT = "vfox-php-manifest-updater (+https://github.com/version-fox/vfox-php)" +SOURCE_MAJORS = (5, 7, 8) +MIN_SUPPORTED = (5, 3, 2) + + +def http_get(url: str, retries: int = 3, timeout: int = 60) -> str: + last_err: Exception | None = None + for attempt in range(retries): + try: + req = Request(url, headers={"User-Agent": USER_AGENT}) + with urlopen(req, timeout=timeout) as resp: + return resp.read().decode("utf-8", errors="replace") + except (HTTPError, URLError, TimeoutError) as exc: + last_err = exc + if attempt < retries - 1: + time.sleep(2 ** attempt) + raise RuntimeError(f"GET {url} failed: {last_err}") + + +def version_key(v: str) -> tuple[int, ...]: + base = v.split("-", 1)[0] + parts: list[int] = [] + for chunk in base.split("."): + try: + parts.append(int(chunk)) + except ValueError: + parts.append(0) + nts_penalty = 1 if "-nts" in v else 0 + return tuple(parts) + (-nts_penalty,) + + +def meets_minimum(v: str) -> bool: + return version_key(v)[: len(MIN_SUPPORTED)] >= MIN_SUPPORTED + + +def fetch_source_versions() -> list[dict]: + out: list[dict] = [] + for major in SOURCE_MAJORS: + url = f"https://www.php.net/releases/index.php?json&max=500&version={major}" + body = http_get(url) + data = json.loads(body) + for ver, meta in data.items(): + if not isinstance(meta, dict): + continue + sources = meta.get("source") or [] + tarball = next( + (s for s in sources if str(s.get("filename", "")).endswith(".tar.gz")), + None, + ) + if not tarball: + continue + if not meets_minimum(ver): + continue + entry = {"version": ver, "filename": tarball["filename"]} + if tarball.get("sha256"): + entry["sha256"] = tarball["sha256"] + if tarball.get("md5"): + entry["md5"] = tarball["md5"] + out.append(entry) + out.sort(key=lambda e: version_key(e["version"]), reverse=True) + # Deduplicate (the API can repeat entries across major queries). + seen: set[str] = set() + deduped: list[dict] = [] + for entry in out: + if entry["version"] in seen: + continue + seen.add(entry["version"]) + deduped.append(entry) + return deduped + + +WIN_FILE_RE = re.compile( + r"^php-" + r"(?P\d+\.\d+\.\d+)" + r"(?P-nts)?" + r"-Win32-(?:vc|vs|VC|VS)\d+-" + r"(?Px64|x86|arm64)" + r"\.zip$" +) +WIN_SKIP_PREFIXES = ("php-debug-", "php-devel-", "php-test-") + + +def parse_windows_listing(html: str, current: bool) -> list[dict]: + out: list[dict] = [] + for m in re.finditer(r'href="([^"]+\.zip)"', html): + filename = m.group(1) + # Skip diagnostic builds and source archives. + if any(filename.startswith(p) for p in WIN_SKIP_PREFIXES): + continue + if filename.endswith("-src.zip") or filename.endswith("-source.zip"): + continue + if "-dev-" in filename or "-latest-" in filename: + continue + match = WIN_FILE_RE.match(filename) + if not match: + continue + base = match.group("version") + if not meets_minimum(base): + continue + is_nts = bool(match.group("nts")) + version = f"{base}-nts" if is_nts else base + out.append( + { + "version": version, + "filename": filename, + "arch": match.group("arch"), + "current": current, + "nts": is_nts, + } + ) + return out + + +def fetch_windows_versions() -> list[dict]: + current_html = http_get("https://windows.php.net/downloads/releases/") + archives_html = http_get("https://windows.php.net/downloads/releases/archives/") + current = parse_windows_listing(current_html, True) + archives = parse_windows_listing(archives_html, False) + + # Prefer the "current" listing when a filename appears in both. + by_filename: dict[str, dict] = {e["filename"]: e for e in archives} + for entry in current: + by_filename[entry["filename"]] = entry + + merged = list(by_filename.values()) + merged.sort( + key=lambda e: (version_key(e["version"]), e["arch"], e["nts"]), + reverse=True, + ) + return merged + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description="Generate the vfox-php version manifest.") + parser.add_argument("-o", "--output", default="manifest.json", help="Output JSON path.") + args = parser.parse_args(argv) + + manifest = { + "source": fetch_source_versions(), + "windows": fetch_windows_versions(), + } + payload = json.dumps(manifest, indent=2, ensure_ascii=False) + with open(args.output, "w", encoding="utf-8", newline="\n") as f: + f.write(payload) + f.write("\n") + print( + f"Wrote {args.output}: " + f"{len(manifest['source'])} source / {len(manifest['windows'])} windows entries", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 7a83f39..5184878 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -16,7 +16,7 @@ jobs: if: github.event_name == 'push' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.title, 'Release v')) steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Extract version from tag if: github.event_name == 'push' diff --git a/.github/workflows/test-linux.yaml b/.github/workflows/test-linux.yaml index a109539..569bfc7 100644 --- a/.github/workflows/test-linux.yaml +++ b/.github/workflows/test-linux.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: install vfox (Linux) run: | echo "deb [trusted=yes] https://apt.fury.io/versionfox/ /" | sudo tee /etc/apt/sources.list.d/versionfox.list @@ -25,6 +25,9 @@ jobs: - name: Install packages run: sudo apt-get update && sudo apt-get install -y autoconf bison build-essential curl gettext git libgd-dev libcurl4-openssl-dev libedit-dev libicu-dev libjpeg-dev libmysqlclient-dev libonig-dev libpng-dev libpq-dev libreadline-dev libsqlite3-dev libssl-dev libxml2-dev libxslt-dev libzip-dev openssl pkg-config re2c zlib1g-dev + - name: Pre-generate manifest (for fallback when version-manifest release does not yet exist) + run: python3 ./.github/update-manifest.py -o ./version-manifest.json + - name: Generate PHP plugin run: | zip -r php.zip ./ @@ -33,7 +36,19 @@ jobs: run: | vfox add -s php.zip vfox install php@latest - vfox use -p php@$(vfox list php | sed -n 's/-> v//p') + # `vfox list php` does not mark a version with `-> v` until something + # has been activated, so the legacy `sed -n 's/-> v//p'` extraction + # would return an empty string right after install. Match the bare + # version string instead. + INSTALLED=$(vfox list php | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-nts)?' | head -n 1) + test -n "$INSTALLED" + # vfox 1.0+ requires the shell hook for project / session scope and + # only refreshes $PATH on the *next* activate after a `use`. Mirror + # the double-activate pattern used by vfox-erlang's E2E tests: + # first call sets up the hook, `use -g` writes the global pin, and + # the second call picks up the just-installed PHP for $PATH. + eval "$(vfox activate bash)" + vfox use -g "php@$INSTALLED" eval "$(vfox activate bash)" php -v php -m diff --git a/.github/workflows/test-macos.yaml b/.github/workflows/test-macos.yaml index c1e9b3a..40d3393 100644 --- a/.github/workflows/test-macos.yaml +++ b/.github/workflows/test-macos.yaml @@ -15,12 +15,15 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: install vfox (MacOS) run: | brew tap version-fox/tap brew install vfox + - name: Pre-generate manifest (for fallback when version-manifest release does not yet exist) + run: python3 ./.github/update-manifest.py -o ./version-manifest.json + - name: Generate PHP plugin run: | zip -r php.zip ./ @@ -33,7 +36,19 @@ jobs: run: | vfox add -s php.zip vfox install php@latest - vfox use -p php@$(vfox list php | sed -n 's/-> v//p') + # `vfox list php` does not mark a version with `-> v` until something + # has been activated, so the legacy `sed -n 's/-> v//p'` extraction + # would return an empty string right after install. Match the bare + # version string instead. + INSTALLED=$(vfox list php | grep -oE '[0-9]+\.[0-9]+\.[0-9]+(-nts)?' | head -n 1) + test -n "$INSTALLED" + # vfox 1.0+ requires the shell hook for project / session scope and + # only refreshes $PATH on the *next* activate after a `use`. Mirror + # the double-activate pattern used by vfox-erlang's E2E tests: + # first call sets up the hook, `use -g` writes the global pin, and + # the second call picks up the just-installed PHP for $PATH. + eval "$(vfox activate bash)" + vfox use -g "php@$INSTALLED" eval "$(vfox activate bash)" php -v php -m diff --git a/.github/workflows/test-windows.yaml b/.github/workflows/test-windows.yaml index bf2661e..ad6f9e8 100644 --- a/.github/workflows/test-windows.yaml +++ b/.github/workflows/test-windows.yaml @@ -15,7 +15,11 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 + + - name: Pre-generate manifest (for fallback when version-manifest release does not yet exist) + shell: pwsh + run: python ./.github/update-manifest.py -o ./version-manifest.json - name: install vfox and test (Windows) shell: pwsh @@ -27,7 +31,19 @@ jobs: vfox -v vfox add -s php.zip vfox install php@latest - vfox use -p php@$(vfox list php | sed -n 's/-> v//p') + # `vfox list php` does not mark a version with `-> v` until something + # has been activated, so the legacy `sed -n 's/-> v//p'` extraction + # would return an empty string right after install. Match the bare + # version string instead. + $installed = (vfox list php | Select-String -Pattern '\d+\.\d+\.\d+(?:-nts)?').Matches[0].Value + if (-not $installed) { throw 'no installed version detected from `vfox list php`' } + # vfox 1.0+ requires the shell hook for project / session scope and + # only refreshes $env:PATH on the *next* activate after a `use`. + # Mirror the double-activate pattern used by vfox-erlang's E2E tests: + # first call sets up the hook, `use -g` writes the global pin, and + # the second call picks up the just-installed PHP for $env:PATH. + Invoke-Expression "$(vfox activate pwsh)" + vfox use -g "php@$installed" Invoke-Expression "$(vfox activate pwsh)" php -v php -m diff --git a/.github/workflows/update-version-list.yaml b/.github/workflows/update-version-list.yaml new file mode 100644 index 0000000..95634d0 --- /dev/null +++ b/.github/workflows/update-version-list.yaml @@ -0,0 +1,88 @@ +name: Check Updates Periodically + +on: + schedule: + # Twice a week — Mon and Thu at 16:00 UTC. Mirrors vfox-clang's cadence. + - cron: "0 16 * * 1,4" + workflow_dispatch: + push: + branches: ["main", "master"] + paths: + - ".github/update-manifest.py" + - ".github/workflows/update-version-list.yaml" + +permissions: + contents: write + +jobs: + update-version-list: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Generate manifest + run: python ./.github/update-manifest.py -o version-manifest.json + + - name: Compare with previous manifest + id: compare + env: + REPO: ${{ github.repository }} + run: | + python <<'PY' + import json, os, sys, urllib.error, urllib.request + + repo = os.environ["REPO"] + new = json.load(open("version-manifest.json", encoding="utf-8")) + + url = f"https://github.com/{repo}/releases/download/version-manifest/version-manifest.json" + changed = True + try: + with urllib.request.urlopen(url, timeout=30) as resp: + old = json.load(resp) + changed = old != new + except urllib.error.HTTPError as exc: + if exc.code != 404: + raise + + out = os.environ["GITHUB_OUTPUT"] + with open(out, "a", encoding="utf-8") as f: + f.write(f"HAS_UPDATES={'true' if changed else 'false'}\n") + print("Update needed" if changed else "Already up to date") + PY + + - name: Build release notes + if: steps.compare.outputs.HAS_UPDATES == 'true' + run: | + python <<'PY' + import json + m = json.load(open("version-manifest.json", encoding="utf-8")) + src_top = [e["version"] for e in m["source"][:5]] + win_top = sorted({e["version"] for e in m["windows"]}, reverse=True)[:5] + with open("notes.md", "w", encoding="utf-8") as f: + f.write("# vfox-php version manifest\n\n") + f.write(f"- Source releases: **{len(m['source'])}**\n") + f.write(f"- Windows binaries: **{len(m['windows'])}**\n\n") + f.write(f"Latest source: `{', '.join(src_top)}`\n\n") + f.write(f"Latest Windows: `{', '.join(win_top)}`\n\n") + f.write("Consumed by `lib/util.lua` at install time. Auto-refreshed by ") + f.write("`.github/workflows/update-version-list.yaml`.\n") + PY + + - name: Update manifest release + if: steps.compare.outputs.HAS_UPDATES == 'true' + uses: ncipollo/release-action@v1 + with: + name: "version manifest" + tag: "version-manifest" + allowUpdates: true + bodyFile: "notes.md" + artifacts: "version-manifest.json" + replacesArtifacts: true + omitNameDuringUpdate: true + omitPrereleaseDuringUpdate: true diff --git a/.gitignore b/.gitignore index 723ef36..ddd342e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -.idea \ No newline at end of file +.idea +version-manifest.json diff --git a/Injection.lua b/Injection.lua index a18c044..7367392 100644 --- a/Injection.lua +++ b/Injection.lua @@ -5,13 +5,12 @@ it's just there to show what objects are injected by vfox and what they do. It's just handy when developing plugins, IDE can use this object for code hints! --]] RUNTIME = { - --- Operating system type at runtime (Windows, Linux, Darwin) + --- Operating system type at runtime (windows, linux, darwin) osType = "", - --- Operating system architecture at runtime (amd64, arm64, etc.) + --- Operating system architecture at runtime (amd64, arm64, 386, etc.) archType = "", --- vfox runtime version version = "", --- Plugin directory pluginDirPath = "", } - diff --git a/README.md b/README.md index cae17e7..73796a3 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,16 @@ vfox search php # or specific version vfox install php@8.4.5 -# or nts version +# or nts (non-thread-safe) version on Windows vfox install php@8.4.5-nts + +# install latest stable +vfox install php@latest ``` -## Prerequirements +## Prerequisites -PHP installation requires some dependencies. Please install the dependencies based on the error messages, or refer to [.github/workflows/test-\*.yaml](https://github.com/version-fox/vfox-php/tree/main/.github/workflows) for guidance. +PHP installation requires some dependencies. Please install the dependencies based on the error messages, or refer to the [test workflows](https://github.com/version-fox/vfox-php/tree/main/.github/workflows) for guidance. ### macOS @@ -36,3 +39,18 @@ brew install gmp libsodium imagemagick ``` Note that the supported extensions are not exhaustive, so you may need to edit the [bin/install](./bin/install) script to support additional extension. Feel free to submit a PR for any missing extensions. + +### Linux (Debian/Ubuntu) + +```shell +sudo apt-get install -y autoconf bison build-essential curl gettext git libgd-dev \ + libcurl4-openssl-dev libedit-dev libicu-dev libjpeg-dev libmysqlclient-dev \ + libonig-dev libpng-dev libpq-dev libreadline-dev libsqlite3-dev libssl-dev \ + libxml2-dev libxslt-dev libzip-dev openssl pkg-config re2c zlib1g-dev +``` + +### Windows + +No build tools are needed. The plugin downloads the official prebuilt zip from +[windows.php.net](https://windows.php.net/downloads/releases/) and installs +Composer alongside it. diff --git a/hooks/available.lua b/hooks/available.lua index 59e5027..c2cb3b1 100644 --- a/hooks/available.lua +++ b/hooks/available.lua @@ -1,82 +1,30 @@ -local http = require('http') -local html = require('html') -local util = require('util') -require('constants') +local util = require("util") +require("constants") --- Return all available versions provided by this plugin --- @param ctx table Empty table used as context, for future extension --- @return table Descriptions of available versions and accompanying tool descriptions function PLUGIN:Available(ctx) - if RUNTIME.osType == 'windows' then - return GetReleaseListForWindows() - else - return GetReleaseListForLinux() - end -end - -function GetReleaseListForWindows() + local manifest = util.fetch_manifest() local result = {} - local urls = { WIN_RELEASES_URL, WIN_RELEASES_URL_LTS } - for _, url in ipairs(urls) do - local resp, err = http.get({ url = url }) - - if resp then - local doc = html.parse(resp.body) - local versions = {} - doc:find('a'):each(function(i, selection) - local versionStr = selection:text() - table.insert(versions, versionStr) - end) - -- TODO like this because for some reason sorting it at the end resets is_from_lts to false - table.sort(versions, function(a, b) - return util.compare_versions(a, b) > 0 - end) - for _, versionStr in ipairs(versions) do - if util.filter_windows_version(versionStr) then - local versions = util.split_string(versionStr, '-') - if util.compare_versions(versions[2], "5.3.2") >= 0 then - local entry = { - version = (versions[3] ~= "nts") and versions[2] or versions[2] .. "-nts", - name = versionStr - } - - entry.is_from_lts = (url == WIN_RELEASES_URL_LTS) - table.insert(result, entry) - end - end + if RUNTIME.osType == "windows" then + local arch = util.windows_arch(RUNTIME.archType) + local seen = {} + for _, entry in ipairs(manifest.windows or {}) do + if entry.arch == arch and not seen[entry.version] then + seen[entry.version] = true + table.insert(result, { version = entry.version }) end end - end - - return result -end - -function GetReleaseListForLinux() - local result = {} - local urls = { RELEASES_URL, RELEASES_URL_LTS } - - for _, url in ipairs(urls) do - local resp, err = http.get({ url = url }) - local is_from_lts = (url == RELEASES_URL_LTS) - - if resp then - local doc = html.parse(resp.body) - local query = "#layout-content " .. (is_from_lts and "h3" or "h2") - doc:find(query):each(function(i, selection) - local versionStr = is_from_lts and selection:attr("id") or selection:text() - versionStr = versionStr:gsub("^v", "") - if util.compare_versions(versionStr, "5.3.2") >= 0 then - table.insert(result, { - version = versionStr, - }) - end - end) + else + for _, entry in ipairs(manifest.source or {}) do + table.insert(result, { version = entry.version }) end end - table.sort(result, function(a, b) - return util.compare_versions(a.version, b.version) > 0 - end) + if #result > 0 then + result[1].note = "latest" + end return result end diff --git a/hooks/pre_install.lua b/hooks/pre_install.lua index f9fa16e..a3d012e 100644 --- a/hooks/pre_install.lua +++ b/hooks/pre_install.lua @@ -1,7 +1,5 @@ -local http = require('http') -local json = require('json') -local util = require('util') -require('constants') +local util = require("util") +require("constants") --- Returns some pre-installed information, such as version number, download address, local files, etc. --- If checksum is provided, vfox will automatically check it for you. @@ -10,65 +8,73 @@ require('constants') --- @return table Version information function PLUGIN:PreInstall(ctx) local version = ctx.version - local lists = self:Available({}) - if version == 'latest' or version == '' then - version = lists[1].version - end + local manifest = util.fetch_manifest() - local versions = {} - for _, value in pairs(lists) do - if util.starts_with(value.version, version .. '.') then - versions = value - end - if value.version == version then - versions = value - end - if next(versions) ~= nil then - break + if RUNTIME.osType == "windows" then + return WindowsPreInstall(manifest, version) + else + return SourcePreInstall(manifest, version) + end +end + +local function find_match(list, version, predicate) + if version == "" or version == "latest" then + for _, e in ipairs(list) do + if not predicate or predicate(e) then + return e + end end + return nil end - if next(versions) == nil then - error('version not found for provided version ' .. version) + for _, e in ipairs(list) do + if e.version == version and (not predicate or predicate(e)) then + return e + end end - - if RUNTIME.osType == 'windows' then - return GetReleaseForWindows(versions) - else - return GetReleaseForLinux(versions) + local prefix = version .. "." + for _, e in ipairs(list) do + if util.starts_with(e.version, prefix) and (not predicate or predicate(e)) then + return e + end end + return nil end -function GetReleaseForWindows(versions) - url = WIN_RELEASES_URL .. versions.name - - if (versions.is_from_lts) then - url = WIN_RELEASES_URL_LTS .. versions.name +function SourcePreInstall(manifest, version) + local entry = find_match(manifest.source or {}, version, nil) + if not entry then + error("PHP source release not found for version: " .. tostring(version)) end - return { - version = versions.version, - url = url, + local result = { + version = entry.version, + url = PHP_DIST_URL .. entry.filename, } + if entry.sha256 and entry.sha256 ~= "" then + result.sha256 = entry.sha256 + end + if entry.md5 and entry.md5 ~= "" then + result.md5 = entry.md5 + end + return result end -function GetReleaseForLinux(versions) - local resp, err = http.get({ - url = URL .. "/releases/index.php?json&version=" .. versions.version - }) - local data = json.decode(resp.body) - - local filename, md5, sha256 = "", "", "" - for _, s in pairs(data["source"]) do - if util.ends_with(s.filename, ".tar.gz") then - filename = s.filename - md5 = s.md5 - sha256 = s.sha256 - break - end +function WindowsPreInstall(manifest, version) + local arch = util.windows_arch(RUNTIME.archType) + local entry = find_match(manifest.windows or {}, version, function(e) + return e.arch == arch and not e.nts + end) + if not entry and version ~= "" and version ~= "latest" then + -- The user may have explicitly requested an NTS build (e.g. "8.5.5-nts"). + entry = find_match(manifest.windows or {}, version, function(e) + return e.arch == arch + end) + end + if not entry then + error("PHP Windows binary not found for version " .. tostring(version) .. " (arch=" .. tostring(arch) .. ")") end + local base = entry.current and PHP_WIN_RELEASES or PHP_WIN_ARCHIVES return { - version = versions.version, - url = URL .. "/distributions/" .. filename, - sha256 = sha256, - md5 = md5 + version = entry.version, + url = base .. entry.filename, } end diff --git a/lib/constants.lua b/lib/constants.lua index ce2e09a..ab51de2 100644 --- a/lib/constants.lua +++ b/lib/constants.lua @@ -1,5 +1,4 @@ -URL = 'https://www.php.net' -RELEASES_URL = URL .. '/releases/' -RELEASES_URL_LTS = URL .. '/downloads.php' -WIN_RELEASES_URL = 'https://windows.php.net/downloads/releases/archives/' -WIN_RELEASES_URL_LTS = 'https://windows.php.net/downloads/releases/' +PHP_NET = "https://www.php.net" +PHP_DIST_URL = PHP_NET .. "/distributions/" +PHP_WIN_RELEASES = "https://windows.php.net/downloads/releases/" +PHP_WIN_ARCHIVES = PHP_WIN_RELEASES .. "archives/" diff --git a/lib/util.lua b/lib/util.lua index 6b62ab5..5cbc7da 100644 --- a/lib/util.lua +++ b/lib/util.lua @@ -1,74 +1,81 @@ -local util = {} +local http = require("http") +local json = require("json") -function util.starts_with(str, prefix) - return str:sub(1, string.len(prefix)) == prefix +local M = {} + +function M.starts_with(str, prefix) + return str:sub(1, #prefix) == prefix end -function util.ends_with(str, suffix) - return str:sub(-string.len(suffix)) == suffix +function M.ends_with(str, suffix) + return suffix == "" or str:sub(-#suffix) == suffix end -function util.split_string(str, delimiter) - local result = {} - for substr in str:gmatch('([^' .. delimiter .. ']+)') do - table.insert(result, substr) - end - return result +--- Map vfox archType to the architecture token used in windows.php.net filenames. +function M.windows_arch(arch) + if arch == "amd64" then return "x64" end + if arch == "386" then return "x86" end + return arch end -function util.compare_versions(v1, v2) - local v1_parts = {} - for part in string.gmatch(v1, '[^.]+') do - table.insert(v1_parts, tonumber(part)) - end +local manifest_cache - local v2_parts = {} - for part in string.gmatch(v2, '[^.]+') do - table.insert(v2_parts, tonumber(part)) - end +--- Fetch the version manifest. +--- +--- Primary source: the GitHub release asset on the `version-manifest` tag, +--- refreshed by .github/workflows/update-version-list.yaml. +--- Fallback: a `version-manifest.json` shipped inside the plugin directory. +--- The fallback is what CI uses (the test workflows generate the manifest +--- locally before zipping the plugin) and also what kicks in for offline / +--- pre-release-bootstrap installs. +function M.fetch_manifest() + if manifest_cache then return manifest_cache end - for i = 1, math.max(#v1_parts, #v2_parts) do - local v1_part = v1_parts[i] or 0 - local v2_part = v2_parts[i] or 0 - if v1_part > v2_part then - return 1 - elseif v1_part < v2_part then - return -1 - end + local githubURL = os.getenv("GITHUB_URL") or "https://github.com/" + githubURL = githubURL:gsub("/$", "") + local url = githubURL .. "/version-fox/vfox-php/releases/download/version-manifest/version-manifest.json" + + local resp, err = http.get({ url = url }) + if err == nil and resp and resp.status_code == 200 then + manifest_cache = json.decode(resp.body) + return manifest_cache end - return 0 -end + local local_path = RUNTIME.pluginDirPath .. "/version-manifest.json" + local content, read_err = M.read_file(local_path) + if read_err == nil and content ~= "" then + manifest_cache = json.decode(content) + return manifest_cache + end -function util.filter_windows_version(version) - return util.starts_with(version, 'php') - and not util.starts_with(version, 'php-debug') - and not util.starts_with(version, 'php-devel') - and not util.starts_with(version, 'php-test') - and not string.find(version, 'src') - and util.ends_with(version, '.zip') - and ((RUNTIME.archType == '386' and string.find(version, 'x86')) - or (RUNTIME.archType ~= '386' and string.find(version, 'x64'))) + local detail + if err ~= nil then + detail = err + else + detail = "HTTP " .. tostring(resp.status_code) .. " for " .. url + end + error("Failed to fetch version manifest: " .. detail + .. " (no fallback at " .. local_path .. ")") end -function util.read_file(filename) - local file = io.open(filename, 'r') +function M.read_file(filename) + local file = io.open(filename, "r") if not file then - return '', 'Failed to open file: ' .. filename + return "", "Failed to open file: " .. filename end - local content = file:read('*a') + local content = file:read("*a") file:close() return content end -function util.write_file(filename, content) - local file = io.open(filename, 'w') +function M.write_file(filename, content) + local file = io.open(filename, "w") if not file then - return false, 'Failed to open file for writing: ' .. filename + return false, "Failed to open file for writing: " .. filename end file:write(content) file:close() return true end -return util +return M diff --git a/metadata.lua b/metadata.lua index 2c9b8ce..0c87c13 100644 --- a/metadata.lua +++ b/metadata.lua @@ -5,13 +5,13 @@ PLUGIN = {} --- Plugin name PLUGIN.name = "php" --- Plugin version -PLUGIN.version = "0.3.0" +PLUGIN.version = "0.4.0" --- Plugin homepage PLUGIN.homepage = "https://github.com/version-fox/vfox-php" --- Plugin license, please choose a correct license according to your needs. PLUGIN.license = "Apache 2.0" --- Plugin description -PLUGIN.description = "php plugin" +PLUGIN.description = "PHP plugin, https://www.php.net/" --- !!! OPTIONAL !!! @@ -21,17 +21,11 @@ NOTE: If the plugin is not compatible with the current vfox version, vfox will not load the plugin and prompt the user to upgrade vfox. --]] -PLUGIN.minRuntimeVersion = "0.3.2" +PLUGIN.minRuntimeVersion = "0.5.1" --[[ NOTE: If configured, vfox will check for updates to the plugin at this address, otherwise it will check for updates at the global registry. - - If you want use the global registry to distribute your plugin, you can remove this field. - - If you develop a plugin based on the template, which will automatically generate a manifest file by CI, - you can set this address to the manifest file address, so that the plugin can be updated automatically. - --]] PLUGIN.manifestUrl = "https://github.com/version-fox/vfox-php/releases/download/manifest/manifest.json" -- Some things that need user to be attention!