Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion .github/workflows/pr-preview-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,43 @@ permissions:

jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha }}
- name: Build installable preview
run: ./scripts/build-pr-preview.sh "${{ github.event.pull_request.number }}" "${{ github.event.pull_request.head.sha }}"
- uses: actions/upload-artifact@v7
with:
name: ca-pr-preview-${{ runner.os }}
path: dist/pr-preview/
if-no-files-found: error
retention-days: 7

verify-reproducible:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: ca-pr-preview-Linux
path: artifacts/linux
- uses: actions/download-artifact@v8
with:
name: ca-pr-preview-macOS
path: artifacts/macos
- name: Compare Linux and macOS output
run: diff -r artifacts/linux artifacts/macos
- name: Prepare verified preview
run: |
mkdir -p dist/pr-preview
cp -a artifacts/linux/. dist/pr-preview/
- uses: actions/upload-artifact@v7
with:
name: ca-pr-preview
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-preview-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ permissions:
actions: read
contents: write
issues: write
pull-requests: read
pull-requests: write

concurrency:
group: ca-pr-preview-publish
Expand Down
31 changes: 25 additions & 6 deletions scripts/build-pr-preview.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ OUTPUT_DIR="${3:-$ROOT/dist/pr-preview}"
SOURCE_DIR="$ROOT/source/community.applications"
PLUGIN_TEMPLATE="$ROOT/plugins/community.applications.plg"
SHORT_SHA="${GIT_SHA:0:7}"
VERSION="$(date -u +%Y.%m.%d)-pr${PR_NUMBER}-${SHORT_SHA}"
COMMIT_TIMESTAMP="$(git -C "$ROOT" show -s --format=%ct "$GIT_SHA")"
VERSION_DATE="$(python3 - "$COMMIT_TIMESTAMP" <<'PY'
from datetime import datetime, timezone
import sys

print(datetime.fromtimestamp(int(sys.argv[1]), timezone.utc).strftime("%Y.%m.%d"))
PY
)"
VERSION="${VERSION_DATE}-pr${PR_NUMBER}-${SHORT_SHA}"
PACKAGE="community.applications-${VERSION}-x86_64-1.txz"
BASE_URL="https://raw.githubusercontent.com/unraid/community.applications/pr-previews/pr/${PR_NUMBER}"

Expand All @@ -27,15 +35,21 @@ mkdir -p "$OUTPUT_DIR"
STAGING="$(mktemp -d -t ca-pr-preview.XXXXXX)"
trap 'rm -rf "$STAGING"' EXIT

COPYFILE_DISABLE=1 cp -R "$SOURCE_DIR/" "$STAGING/"
# `source/.` copies the directory contents on both GNU and BSD cp. A trailing
# slash alone behaves differently on Ubuntu and previously nested the entire
# package below ./community.applications/ in CI-built previews.
COPYFILE_DISABLE=1 cp -R "$SOURCE_DIR/." "$STAGING/"
find "$STAGING" \( -name '.DS_Store' -o -name '._*' -o -name 'sftp-config.json' \) -delete
find "$STAGING" -name '.claude' -type d -prune -exec rm -rf {} + 2>/dev/null || true
chmod -R 0755 "$STAGING"

if tar --version 2>/dev/null | grep -q 'GNU tar'; then
tar -C "$STAGING" --owner=0 --group=0 --numeric-owner -cJf "$OUTPUT_DIR/$PACKAGE" .
else
COPYFILE_DISABLE=1 tar -C "$STAGING" --uid 0 --gid 0 --uname root --gname root -cJf "$OUTPUT_DIR/$PACKAGE" .
python3 "$ROOT/scripts/create-reproducible-tar.py" \
"$STAGING" "$OUTPUT_DIR/$PACKAGE" "$COMMIT_TIMESTAMP"

tar -tf "$OUTPUT_DIR/$PACKAGE" > "$STAGING/package-contents.txt"
if ! grep -Fxq './usr/local/emhttp/plugins/community.applications/Apps.page' "$STAGING/package-contents.txt"; then
echo "Preview package has an invalid root layout." >&2
exit 1
fi

if command -v md5sum >/dev/null 2>&1; then
Expand All @@ -51,6 +65,11 @@ import sys

source, destination, version, md5, package_url, plugin_url = sys.argv[1:]
text = Path(source).read_text(encoding="utf-8")

# Keep `name` canonical because WebGUI uses it to locate the plugin's UI
# directory. `pluginURL` must point at the stable per-PR installer so WebGUI
# update checks follow new preview builds; CA recognizes that bounded URL as an
# alias of its canonical app-feed template.
replacements = {
"version": version,
"md5": md5,
Expand Down
68 changes: 68 additions & 0 deletions scripts/create-reproducible-tar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Create a deterministic root-owned XZ-compressed tar archive."""

from __future__ import annotations

import os
from pathlib import Path
import sys
import tarfile


def normalized_info(tar: tarfile.TarFile, path: Path, name: str, mtime: int) -> tarfile.TarInfo:
info = tar.gettarinfo(str(path), arcname=name)
info.uid = 0
info.gid = 0
info.uname = "root"
info.gname = "root"
info.mtime = mtime
info.mode = 0o755
info.pax_headers = {}
return info


def add_tree(tar: tarfile.TarFile, root: Path, relative: Path, mtime: int) -> None:
path = root / relative
name = f"./{relative.as_posix()}"
info = normalized_info(tar, path, name, mtime)

if info.isfile():
with path.open("rb") as source:
tar.addfile(info, source)
return

tar.addfile(info)
if info.isdir():
for child in sorted(path.iterdir(), key=lambda item: os.fsencode(item.name)):
add_tree(tar, root, relative / child.name, mtime)


def main() -> int:
if len(sys.argv) != 4:
raise SystemExit("usage: create-reproducible-tar.py <source-dir> <archive.txz> <unix-mtime>")

source = Path(sys.argv[1]).resolve()
destination = Path(sys.argv[2]).resolve()
mtime = int(sys.argv[3])
if not source.is_dir():
raise SystemExit(f"source directory does not exist: {source}")

destination.parent.mkdir(parents=True, exist_ok=True)
with tarfile.open(destination, mode="w:xz", format=tarfile.GNU_FORMAT, preset=6) as tar:
root_info = tarfile.TarInfo("./")
root_info.type = tarfile.DIRTYPE
root_info.uid = 0
root_info.gid = 0
root_info.uname = "root"
root_info.gname = "root"
root_info.mode = 0o755
root_info.mtime = mtime
tar.addfile(root_info)
for child in sorted(source.iterdir(), key=lambda item: os.fsencode(item.name)):
add_tree(tar, source, Path(child.name), mtime)

return 0


if __name__ == "__main__":
raise SystemExit(main())
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ d3a67ac4ee1d34b4d7a8566461d3c2be ./include/previous_apps_helpers.php
c20989527333cfd8854e6f17ee4d9570 ./include/exec.php
ded36f72268bd8df6705a691b0e2e845 ./include/_ide_stubs.php
fdc5cd0784fc206c2457e323f9debddf ./include/populate_autocomplete_helpers.php
f9759bb5ed3c2df4897613e6c2659300 ./include/helpers.php
e70568ca8f4f6b964e978426ea6248b7 ./include/helpers.php
798a9eedc722c96d470ffa171e24344a ./include/plugin_identity.php
d0b92f8633ab8e811d8c942f1aec62e8 ./include/pinned_apps_helpers.php
d7f71ed9145bb9f4bae7a7c17aeb0ca0 ./include/xml_libs.php
488b71146cccc768ee6fbf847893d157 ./pluginPreAndPostScripts/CA_postHook
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*/

require_once __DIR__ . "/paths.php";
require_once __DIR__ . "/plugin_identity.php";

/**
* Emit the hidden + checkbox <input> pair for a boolean settings toggle in the
Expand Down Expand Up @@ -1663,7 +1664,10 @@ function checkInstalledPlugin($template) {
if ( ! file_exists("/var/log/plugins/$pluginName") ) return false;

if ( isset($template['hideFromCA']) ) return false;
return strtolower(trim(ca_plugin("pluginURL","/var/log/plugins/$pluginName"))) == strtolower(trim($template['PluginURL']));
return caPluginUrlMatchesTemplate(
(string) ca_plugin("pluginURL", "/var/log/plugins/$pluginName"),
(string) $template['PluginURL']
);
}

/**
Expand Down Expand Up @@ -2880,4 +2884,4 @@ function caBuildXmlConfigFromImageConfig(array $imageConfig): array {
}
});

?>
?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php
/* Copyright 2026, Lime Technology
* Licensed under GPL-2.0-or-later
* SPDX-License-Identifier: GPL-2.0-or-later
*/

/**
* Determine whether an installed plugin URL represents an app-feed template.
*
* Exact URL equality remains the default identity rule. Community Applications
* PR previews are a bounded exception: their per-PR URL must remain installed
* so WebGUI update checks follow the preview, while the Apps page must still
* recognize the canonical CA template as installed.
*/
function caPluginUrlMatchesTemplate(string $installedUrl, string $templateUrl): bool {
$installedUrl = strtolower(trim($installedUrl));
$templateUrl = strtolower(trim($templateUrl));

if ($installedUrl === $templateUrl) {
return true;
}

$canonicalCaUrl = "https://raw.githubusercontent.com/unraid/community.applications/master/plugins/community.applications.plg";
if ($templateUrl !== $canonicalCaUrl) {
return false;
}

return preg_match(
'#^https://raw\.githubusercontent\.com/unraid/community\.applications/pr-previews/pr/[1-9][0-9]*/community\.applications\.plg$#',
$installedUrl
) === 1;
}
37 changes: 37 additions & 0 deletions tests/test_plugin_identity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php
/* Regression tests for CA plugin URL identity matching. */

require_once dirname(__DIR__) . "/source/community.applications/usr/local/emhttp/plugins/community.applications/include/plugin_identity.php";

$failures = 0;

function expectPluginIdentity(string $label, bool $expected, string $installedUrl, string $templateUrl): void {
global $failures;
$actual = caPluginUrlMatchesTemplate($installedUrl, $templateUrl);
if ($actual !== $expected) {
$failures++;
fwrite(STDERR, "FAIL: {$label}: expected " . ($expected ? "true" : "false") . ", got " . ($actual ? "true" : "false") . "\n");
return;
}
echo "PASS: {$label}\n";
}

$canonicalCa = "https://raw.githubusercontent.com/unraid/community.applications/master/plugins/community.applications.plg";
$canonicalOther = "https://raw.githubusercontent.com/example/other/master/other.plg";
$preview = "https://raw.githubusercontent.com/unraid/community.applications/pr-previews/pr/127/community.applications.plg";

expectPluginIdentity("canonical URL", true, $canonicalCa, $canonicalCa);
expectPluginIdentity("normalization", true, " " . strtoupper($canonicalCa) . " ", $canonicalCa);
expectPluginIdentity("official CA PR preview", true, $preview, $canonicalCa);
expectPluginIdentity("preview is not another plugin", false, $preview, $canonicalOther);
expectPluginIdentity("fork preview rejected", false, str_replace("/unraid/", "/someone/", $preview), $canonicalCa);
expectPluginIdentity("non-numeric PR rejected", false, str_replace("/127/", "/abc/", $preview), $canonicalCa);
expectPluginIdentity("wrong preview branch rejected", false, str_replace("/pr-previews/", "/beta/", $preview), $canonicalCa);
expectPluginIdentity("wrong manifest rejected", false, str_replace("community.applications.plg", "other.plg", $preview), $canonicalCa);

if ($failures > 0) {
fwrite(STDERR, "{$failures} plugin identity test(s) failed\n");
exit(1);
}

echo "All plugin identity tests passed\n";