diff --git a/.github/workflows/dependency-vulnerability-scan.yml b/.github/workflows/dependency-vulnerability-scan.yml new file mode 100644 index 00000000..6a7244d6 --- /dev/null +++ b/.github/workflows/dependency-vulnerability-scan.yml @@ -0,0 +1,80 @@ +name: Dependency Vulnerability Scan + +on: + schedule: + - cron: '53 7 * * 3' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: folderview-plus-dependency-vulnerability-scan + cancel-in-progress: false + +jobs: + scan: + name: Scan CycloneDX dependency inventory + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Scan generated SBOM against OSV + id: scan + continue-on-error: true + uses: google/osv-scanner-action/osv-scanner-action@06b2ab4348248b456ee06c9e953637f55e03504f # v2.5.0 + with: + scan-args: |- + --output=results.json + --format=json + --sbom=docs/sbom.cdx.json + + - name: Build SARIF vulnerability report + id: report + if: ${{ !cancelled() }} + continue-on-error: true + uses: google/osv-scanner-action/osv-reporter-action@06b2ab4348248b456ee06c9e953637f55e03504f # v2.5.0 + with: + scan-args: |- + --output=results.sarif + --new=results.json + --gh-annotations=false + --fail-on-vuln=true + + - name: Retain OSV report + if: ${{ !cancelled() && hashFiles('results.sarif') != '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: osv-scanner-sarif + path: results.sarif + if-no-files-found: error + retention-days: 5 + + - name: Upload OSV results to code scanning + if: ${{ !cancelled() && hashFiles('results.sarif') != '' }} + uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4 + with: + sarif_file: results.sarif + + - name: Enforce clean vulnerability scan + if: ${{ always() }} + env: + SCAN_OUTCOME: ${{ steps.scan.outcome }} + REPORT_OUTCOME: ${{ steps.report.outcome }} + shell: bash + run: | + set -euo pipefail + if [[ "${SCAN_OUTCOME}" != 'success' || "${REPORT_OUTCOME}" != 'success' ]]; then + echo "::error title=Dependency vulnerability scan failed::OSV scanning failed or reported one or more known vulnerabilities. Review the SARIF result in GitHub code scanning." + exit 1 + fi + echo "OSV dependency vulnerability scan passed." diff --git a/.github/workflows/unraid-docker-upstream-monitor.yml b/.github/workflows/unraid-docker-upstream-monitor.yml index 5ff2edc2..8e857de8 100644 --- a/.github/workflows/unraid-docker-upstream-monitor.yml +++ b/.github/workflows/unraid-docker-upstream-monitor.yml @@ -1,8 +1,8 @@ -name: Unraid Docker Upstream Monitor +name: Unraid Compatibility Monitor on: schedule: - - cron: '43 9 * * 2' + - cron: '43 9 * * *' workflow_dispatch: permissions: @@ -10,14 +10,14 @@ permissions: issues: write concurrency: - group: folderview-plus-unraid-docker-upstream-monitor + group: folderview-plus-unraid-compatibility-monitor cancel-in-progress: true jobs: monitor: - name: Check Docker API and native page compatibility + name: Check official Unraid and Community Applications contracts runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 45 steps: - name: Checkout FolderView Plus @@ -25,69 +25,199 @@ jobs: with: fetch-depth: 1 - - name: Fetch current Unraid release notes + - name: Setup CI environment + uses: ./.github/actions/setup-ci-env + + - name: Fetch official upstream contracts + id: upstream shell: bash run: | set -euo pipefail - git clone --depth 1 --filter=blob:none --sparse https://github.com/unraid/docs.git "${RUNNER_TEMP}/unraid-docs" - git -C "${RUNNER_TEMP}/unraid-docs" sparse-checkout set docs/unraid-os/release-notes + upstream_root="${RUNNER_TEMP}/fvplus-upstream" + docs_root="${upstream_root}/unraid-docs" + webgui_root="${upstream_root}/unraid-webgui" + ca_root="${upstream_root}/community-apps-starter" + mkdir -p "${upstream_root}" - - name: Check upstream activation signals - id: monitor + git clone --depth 1 --filter=blob:none --sparse https://github.com/unraid/docs.git "${docs_root}" + git -C "${docs_root}" sparse-checkout set docs/unraid-os/release-notes + + git clone --depth 1 --filter=blob:none --sparse https://github.com/unraid/webgui.git "${webgui_root}" + git -C "${webgui_root}" sparse-checkout set --no-cone \ + emhttp/plugins/dynamix.docker.manager/DockerContainers.page \ + emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php \ + emhttp/plugins/dynamix.docker.manager/include/UserPrefs.php \ + emhttp/plugins/dynamix.docker.manager/javascript/docker.js \ + emhttp/plugins/dynamix.docker.manager/nchan/docker_load \ + emhttp/plugins/dynamix.vm.manager/VMMachines.page \ + emhttp/plugins/dynamix.vm.manager/include/VMMachines.php \ + emhttp/plugins/dynamix.vm.manager/include/VMajax.php \ + emhttp/plugins/dynamix.vm.manager/include/UserPrefs.php \ + emhttp/plugins/dynamix/Dashboard.page \ + emhttp/plugins/dynamix/include/DashboardApps.php \ + emhttp/plugins/dynamix.plugin.manager/PluginHelpers.page \ + emhttp/plugins/dynamix.plugin.manager/PluginInstall.page \ + emhttp/plugins/dynamix.plugin.manager/Plugins.page \ + emhttp/plugins/dynamix.plugin.manager/PluginsError.page \ + emhttp/plugins/dynamix.plugin.manager/PluginsStale.page \ + emhttp/plugins/dynamix.plugin.manager/include/Downgrade.php \ + emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php \ + emhttp/plugins/dynamix.plugin.manager/include/ShowChanges.php \ + emhttp/plugins/dynamix.plugin.manager/include/ShowPlugins.php \ + emhttp/plugins/dynamix.plugin.manager/post-hooks/post_plugin_checks \ + emhttp/plugins/dynamix.plugin.manager/pre-hooks/pre_plugin_checks \ + emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php \ + emhttp/plugins/dynamix.plugin.manager/scripts/checkall \ + emhttp/plugins/dynamix.plugin.manager/scripts/multiplugin \ + emhttp/plugins/dynamix.plugin.manager/scripts/plugin \ + emhttp/plugins/dynamix.plugin.manager/scripts/plugin_rm \ + emhttp/plugins/dynamix.plugin.manager/scripts/plugincheck \ + emhttp/plugins/dynamix.plugin.manager/scripts/showchanges + + git clone --depth 1 https://github.com/unraid/unraid-community-apps-starter.git "${ca_root}" + + curl -fsSL --connect-timeout 20 --max-time 90 \ + https://ca.unraid.net/submit/help/repository-xml \ + -o "${upstream_root}/ca-repository-help.html" + curl -fsSL --connect-timeout 20 --max-time 180 \ + https://ca.unraid.net/assets/feed/applicationFeed.json \ + -o "${upstream_root}/applicationFeed.json" + curl -fsSL --connect-timeout 20 --max-time 90 \ + https://raw.githubusercontent.com/alexphillips-dev/unraid-ca-templates/main/folderview.plus.xml \ + -o "${upstream_root}/catalog-folderview.plus.xml" + curl -fsSL --connect-timeout 20 --max-time 90 \ + https://raw.githubusercontent.com/alexphillips-dev/FolderView-Plus/main/folderview.plus.xml \ + -o "${upstream_root}/main-folderview.plus.xml" + curl -fsSL --connect-timeout 20 --max-time 90 \ + https://raw.githubusercontent.com/alexphillips-dev/FolderView-Plus/main/folderview.plus.plg \ + -o "${upstream_root}/main-folderview.plus.plg" + + { + echo "root=${upstream_root}" + echo "docs_root=${docs_root}" + echo "webgui_root=${webgui_root}" + echo "webgui_commit=$(git -C "${webgui_root}" rev-parse HEAD)" + echo "ca_root=${ca_root}" + echo "ca_commit=$(git -C "${ca_root}" rev-parse HEAD)" + } >> "${GITHUB_OUTPUT}" + + - name: Evaluate Docker API and native-page contract + id: docker shell: bash run: | + set +e bash scripts/unraid_docker_upstream_monitor.sh \ - --release-notes-dir "${RUNNER_TEMP}/unraid-docs/docs/unraid-os/release-notes" \ + --release-notes-dir "${{ steps.upstream.outputs.docs_root }}/docs/unraid-os/release-notes" \ + --json > "${RUNNER_TEMP}/docker-monitor.json" + monitor_exit=$? + set -e + if [[ "${monitor_exit}" -ne 0 && "${monitor_exit}" -ne 20 && "${monitor_exit}" -ne 21 ]]; then + echo "Unexpected Docker monitor exit: ${monitor_exit}" >&2 + exit "${monitor_exit}" + fi + cat "${RUNNER_TEMP}/docker-monitor.json" + + - name: Validate Community Applications publication contract + id: ca + shell: bash + run: | + set +e + node scripts/community_applications_guard.mjs \ + --metadata "${{ steps.upstream.outputs.root }}/main-folderview.plus.xml" \ + --manifest "${{ steps.upstream.outputs.root }}/main-folderview.plus.plg" \ + --catalog-template "${{ steps.upstream.outputs.root }}/catalog-folderview.plus.xml" \ + --feed "${{ steps.upstream.outputs.root }}/applicationFeed.json" \ + --portal-help "${{ steps.upstream.outputs.root }}/ca-repository-help.html" \ + --starter-plugin "${{ steps.upstream.outputs.ca_root }}/plugins/example-plugin.xml" \ + --expected-branch main \ + --report "${RUNNER_TEMP}/community-applications-report.md" \ + --json > "${RUNNER_TEMP}/community-applications.json" + ca_exit=$? + set -e + if [[ "${ca_exit}" -ne 0 && "${ca_exit}" -ne 20 && "${ca_exit}" -ne 21 ]]; then + echo "Unexpected Community Applications guard exit: ${ca_exit}" >&2 + exit "${ca_exit}" + fi + cat "${RUNNER_TEMP}/community-applications.json" + + - name: Evaluate reviewed compatibility baselines + id: monitor + shell: bash + run: | + set +e + node scripts/unraid_compatibility_monitor.mjs \ + --baseline docs/unraid-compatibility-baseline.json \ + --release-notes-dir "${{ steps.upstream.outputs.docs_root }}/docs/unraid-os/release-notes" \ + --webgui-dir "${{ steps.upstream.outputs.webgui_root }}" \ + --webgui-commit "${{ steps.upstream.outputs.webgui_commit }}" \ + --ca-starter-dir "${{ steps.upstream.outputs.ca_root }}" \ + --ca-starter-commit "${{ steps.upstream.outputs.ca_commit }}" \ + --docker-result "${RUNNER_TEMP}/docker-monitor.json" \ + --ca-result "${RUNNER_TEMP}/community-applications.json" \ + --report "${RUNNER_TEMP}/unraid-compatibility-report.md" \ --github-output "${GITHUB_OUTPUT}" \ - --json + --json > "${RUNNER_TEMP}/unraid-compatibility.json" + monitor_exit=$? + set -e + if [[ "${monitor_exit}" -ne 0 && "${monitor_exit}" -ne 20 && "${monitor_exit}" -ne 21 ]]; then + echo "Unexpected compatibility monitor exit: ${monitor_exit}" >&2 + exit "${monitor_exit}" + fi + cat "${RUNNER_TEMP}/unraid-compatibility.json" + + - name: Run isolated compatibility fixtures on upstream drift + id: compatibility + if: steps.monitor.outputs.status != 'matched' + continue-on-error: true + env: + FVPLUS_FIXTURE_BROWSERS: chromium + shell: bash + run: | + set -euo pipefail + npm ci --ignore-scripts + npx playwright install --with-deps chromium + bash scripts/run_ci_suite.sh --lane tests --lane fixture-browser - - name: Publish monitor summary + - name: Publish compatibility summary if: always() shell: bash run: | - { - echo "## Unraid Docker interface monitor" - echo - echo "- Status: \`${{ steps.monitor.outputs.status || 'unknown' }}\`" - echo "- Reason: \`${{ steps.monitor.outputs.reason || 'monitor-did-not-complete' }}\`" - echo "- Source gate: \`${{ steps.monitor.outputs.source_gate || 'unknown' }}\`" - echo "- Release announcement: \`${{ steps.monitor.outputs.release_announcement || 'unknown' }}\`" - echo "- Docker schema: \`${{ steps.monitor.outputs.schema_status || 'unknown' }}\`" - echo "- Latest API release: \`${{ steps.monitor.outputs.latest_api_release || 'unknown' }}\`" - echo "- API release baseline: \`${{ steps.monitor.outputs.api_release_status || 'unknown' }}\`" - } >> "${GITHUB_STEP_SUMMARY}" - - - name: Create or update compatibility alert - if: always() && steps.monitor.outputs.status != 'dormant' + if [[ -f "${RUNNER_TEMP}/unraid-compatibility-report.md" ]]; then + cat "${RUNNER_TEMP}/unraid-compatibility-report.md" >> "${GITHUB_STEP_SUMMARY}" + fi + if [[ -f "${RUNNER_TEMP}/community-applications-report.md" ]]; then + cat "${RUNNER_TEMP}/community-applications-report.md" >> "${GITHUB_STEP_SUMMARY}" + fi + + - name: Create or update compatibility review issue + if: always() && steps.monitor.outputs.status != 'matched' env: GH_TOKEN: ${{ github.token }} - STATUS: ${{ steps.monitor.outputs.status || 'unknown' }} - REASON: ${{ steps.monitor.outputs.reason || 'monitor-did-not-complete' }} - SOURCE_GATE: ${{ steps.monitor.outputs.source_gate || 'unknown' }} - RELEASE_ANNOUNCEMENT: ${{ steps.monitor.outputs.release_announcement || 'unknown' }} - SCHEMA_STATUS: ${{ steps.monitor.outputs.schema_status || 'unknown' }} - SCHEMA_SIGNATURE: ${{ steps.monitor.outputs.schema_signature || 'unknown' }} - LATEST_API_RELEASE: ${{ steps.monitor.outputs.latest_api_release || 'unknown' }} - API_RELEASE_STATUS: ${{ steps.monitor.outputs.api_release_status || 'unknown' }} + FIXTURE_OUTCOME: ${{ steps.compatibility.outcome || 'not-run' }} shell: bash run: | set -euo pipefail - title="Unraid Docker upstream compatibility review required" - body_file="$(mktemp)" + title="Unraid compatibility review required" + body_file="${RUNNER_TEMP}/compatibility-issue.md" { - echo "The scheduled FolderView Plus monitor detected an upstream Docker interface state that is no longer safely dormant." + if [[ -s "${RUNNER_TEMP}/unraid-compatibility-report.md" ]]; then + cat "${RUNNER_TEMP}/unraid-compatibility-report.md" + else + echo "# Unraid Compatibility Monitor" + echo + echo "The consolidated report was unavailable because an upstream fetch or monitor step failed. Inspect the linked workflow run for the exact failure." + fi echo - echo "- Status: \`${STATUS}\`" - echo "- Reason: \`${REASON}\`" - echo "- Source gate: \`${SOURCE_GATE}\`" - echo "- Release announcement: \`${RELEASE_ANNOUNCEMENT}\`" - echo "- Docker schema: \`${SCHEMA_STATUS}\`" - echo "- Docker schema signature: \`${SCHEMA_SIGNATURE}\`" - echo "- Latest API release: \`${LATEST_API_RELEASE}\`" - echo "- API release baseline: \`${API_RELEASE_STATUS}\`" - echo "- Workflow run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + if [[ -s "${RUNNER_TEMP}/community-applications-report.md" ]]; then + cat "${RUNNER_TEMP}/community-applications-report.md" + else + echo "## Community Applications" + echo + echo "The Community Applications report was unavailable." + fi echo - echo "Follow \`docs/unraid-docker-prerelease-qualification.md\`. Keep native-page safe mode enabled until fixture and live-host qualification pass." + echo "- Isolated compatibility fixtures: \`${FIXTURE_OUTCOME}\`" + echo "- Workflow run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" } > "${body_file}" issue_number="$( gh issue list \ @@ -98,28 +228,73 @@ jobs: | head -n 1 )" if [[ -n "${issue_number}" ]]; then - gh issue comment "${issue_number}" --body-file "${body_file}" - echo "Updated compatibility issue #${issue_number}." + gh issue edit "${issue_number}" --body-file "${body_file}" else gh issue create --title "${title}" --body-file "${body_file}" fi - - name: Close resolved compatibility alert - if: steps.monitor.outputs.status == 'dormant' + - name: Close resolved compatibility review issues + if: steps.monitor.outputs.status == 'matched' env: GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail - title="Unraid Docker upstream compatibility review required" - issue_number="$( - gh issue list \ - --state open \ - --search "\"${title}\" in:title" \ - --json number,title \ - --jq ".[] | select(.title == \"${title}\") | .number" \ - | head -n 1 - )" - if [[ -n "${issue_number}" ]]; then - gh issue close "${issue_number}" --comment "The current Unraid API release and Docker schema have been reviewed and now match the compatibility baseline." - fi + for title in \ + "Unraid compatibility review required" \ + "Unraid Docker upstream compatibility review required"; do + issue_number="$( + gh issue list \ + --state open \ + --search "\"${title}\" in:title" \ + --json number,title \ + --jq ".[] | select(.title == \"${title}\") | .number" \ + | head -n 1 + )" + if [[ -n "${issue_number}" ]]; then + gh issue close "${issue_number}" --comment "The official Unraid, API, webGUI, PHP, and Community Applications contracts now match the reviewed FolderView Plus baselines." + fi + done + + - name: Enforce reviewed compatibility state + if: always() && (steps.monitor.outputs.status != 'matched' || steps.compatibility.outcome == 'failure') + shell: bash + run: | + echo "::error title=Unraid compatibility review required::Official upstream state differs from the human-reviewed FolderView Plus baselines or isolated compatibility validation failed." + exit 1 + + php-runtime-compatibility: + name: PHP ${{ matrix.php }} / ${{ matrix.profile }} + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - profile: oldest-supported + unraid: 7.0.0 + php: 8.3.8 + image: php:8.3.8-cli-alpine + - profile: current-stable + unraid: 7.3.2 + php: 8.4.23 + image: php:8.4.23-cli-alpine + - profile: current-prerelease + unraid: 7.4.0-beta.1 + php: 8.4.24 + image: php:8.4.24-cli-alpine + + steps: + - name: Checkout FolderView Plus + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Validate shipped PHP on isolated Unraid runtime profile + shell: bash + run: | + set -euo pipefail + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --workdir /workspace \ + --env "FVPLUS_EXPECT_PHP_VERSION_PREFIX=${{ matrix.php }}" \ + "${{ matrix.image }}" \ + sh scripts/php_runtime_compatibility.sh diff --git a/docs/codeql-triage.md b/docs/codeql-triage.md index 5dcd45de..d3c62fd7 100644 --- a/docs/codeql-triage.md +++ b/docs/codeql-triage.md @@ -29,8 +29,11 @@ no new high-or-higher security findings before a protected branch can be merged. Dependency Review separately rejects pull requests that introduce high-or-critical known vulnerabilities or licenses outside the repository's approved quality-tooling set. OpenSSF Scorecard publishes a scheduled supply-chain posture report to code -scanning. Code-quality findings remain part of CI and triage without being -mislabeled as security vulnerabilities. +scanning. The weekly OSV job scans the generated CycloneDX dependency inventory, +uploads SARIF, and fails when it reports a known vulnerability. A scheduled-workflow +watchdog checks that CodeQL, Scorecard, and OSV continue producing timely successful +runs and maintains one recovery issue if they do not. Code-quality findings remain +part of CI and triage without being mislabeled as security vulnerabilities. ## Review Procedure diff --git a/docs/maintainability.md b/docs/maintainability.md index fbf30aab..0c80def0 100644 --- a/docs/maintainability.md +++ b/docs/maintainability.md @@ -15,8 +15,8 @@ FolderView Plus uses ratcheted checks so maintenance improvements cannot silentl - Dev versions are allocated above versions visible in the archive, manifests, branch history, and version tags. - Historical reproducibility checks use `FVPLUS_HISTORICAL_REBUILD=1` only inside isolated guard worktrees. - Main-to-dev back-merges build a fresh dev package from merged source; packaged/source drift is never bypassed. -- Release-mode validation fails closed unless the configured Unraid version matrix, browser smoke target, and black/white theme matrix can run. A missing live target is a release failure, not a skipped check. -- Release notes include source commit, archive SHA-256, previous stable tag, a full comparison URL, and bounded commit history. +- Release-mode validation fails closed unless the isolated Unraid fixture profiles, browser smoke fixtures, and black/white theme matrix can run. Repository validation never accepts a live Unraid URL, session, cookie, or credential. +- Release notes include source commit, archive SHA-256, previous stable tag, and a full comparison URL alongside curated user-facing changes; they never embed raw commit history. - Remote publication validation downloads and hashes the archive bytes. Its retry messages distinguish stale manifests, unavailable artifacts, stale checksum files, and stale archive content. ## Supply chain and dependencies @@ -25,6 +25,8 @@ FolderView Plus uses ratcheted checks so maintenance improvements cannot silentl - `scripts/action_pin_guard.mjs` rejects mutable references. - Dependabot proposes grouped weekly npm and GitHub Actions updates. jQuery major upgrades remain deliberate because Unraid host compatibility must be reviewed. - `docs/sbom.cdx.json` is a generated CycloneDX inventory of shipped browser libraries, Unraid-provided runtime contracts, npm development tools, and GitHub Actions. `scripts/runtime_components.json` is the canonical runtime inventory and classifies every file under `scripts/include`; run `npm run sbom` after runtime dependency, npm, or action changes. CI uses `npm run sbom:check`. +- `.github/workflows/dependency-vulnerability-scan.yml` scans that generated inventory against OSV every Wednesday and on demand. Versioned components with supported package identifiers are evaluated for known vulnerabilities; SARIF is retained and published to GitHub code scanning, and a reported vulnerability fails the job for review. Inventory entries without a usable package identifier remain visible for manual upstream review rather than being silently treated as scanned. +- Repository Actions policy permits only GitHub-owned actions plus the SHA-pinned `github/codeql-action@*`, `ossf/scorecard-action@*`, and `google/osv-scanner-action/*@*` patterns needed by the security workflows. Verified-publisher actions are not enabled globally. - PHPStan is downloaded at a pinned version and SHA-256 by `scripts/phpstan_guard.sh`; it is development-only and is never shipped in the plugin archive. ## UI, localization, and diagnostics @@ -39,8 +41,12 @@ FolderView Plus uses ratcheted checks so maintenance improvements cannot silentl ## Operational review -- The scheduled Unraid Docker monitor opens or updates one deduplicated GitHub issue when the upstream interface leaves its dormant state. +- The daily Unraid compatibility monitor opens or updates one deduplicated GitHub issue when a reviewed stable/prerelease OS version, PHP runtime, Docker API/schema, native-page gate, relevant Docker/VM/Dashboard webGUI file, plugin-manager install/update/downgrade contract, Community Applications starter contract, canonical template, or public catalog entry changes. +- `docs/unraid-compatibility-baseline.json` records human-reviewed upstream versions and Git blob signatures. Automation reports drift but never modifies or approves the baseline. +- Isolated PHP 8.3/8.4 profiles represent the oldest supported, current stable, and current prerelease Unraid runtimes. The compatibility lane syntax-checks every shipped PHP file and runs a request-authority smoke contract without connecting to a server. +- Community Applications validation uses the official public portal guidance, starter repository, catalog feed, and canonical template. The authenticated portal Validate/Scan session is never stored in GitHub Actions. - The scheduled validation workflow runs deterministic fixtures in Chromium, Firefox, and WebKit every Monday. It does not connect to live Unraid targets or require live-system repository secrets. +- The scheduled workflow watchdog checks the most recent successful CodeQL, OpenSSF Scorecard, OSV dependency scan, compatibility, browser-fixture, and clone-traffic jobs. It maintains one deduplicated recovery issue if an expected success is missing, failed, or stale. - Follow [Unraid Docker prerelease qualification](unraid-docker-prerelease-qualification.md) before changing native-page safe mode. - Audit current and reachable package history with `bash scripts/artifact_history_audit.sh` and `bash scripts/artifact_history_audit.sh --history`. - Follow [artifact retention](artifact-retention.md) before any coordinated Git LFS or history migration. History rewriting is intentionally never automated. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index 5fcf9e3e..739b190e 100644 --- a/docs/sbom.cdx.json +++ b/docs/sbom.cdx.json @@ -116,6 +116,19 @@ } ] }, + { + "type": "application", + "name": "google/osv-scanner-action", + "version": "06b2ab4348248b456ee06c9e953637f55e03504f", + "scope": "optional", + "purl": "pkg:github/google/osv-scanner-action@06b2ab4348248b456ee06c9e953637f55e03504f", + "properties": [ + { + "name": "folderview-plus:usage", + "value": "build-only-github-action" + } + ] + }, { "type": "application", "name": "ossf/scorecard-action", diff --git a/docs/unraid-compatibility-baseline.json b/docs/unraid-compatibility-baseline.json new file mode 100644 index 00000000..efe6845a --- /dev/null +++ b/docs/unraid-compatibility-baseline.json @@ -0,0 +1,92 @@ +{ + "schemaVersion": 1, + "reviewedAt": "2026-08-21", + "unraidOs": { + "docsCommit": "346bede9d3685543a3d9325e4bc52f075073ddee", + "latestReviewedStable": { + "version": "7.3.2", + "releaseNote": "7.3.2.md", + "releaseNoteBlobSha": "273861365c84ef9e19f484cede87a1f42c69282d", + "phpVersion": "8.4.23" + }, + "latestReviewedPrerelease": { + "version": "7.4.0-beta.1", + "releaseNote": "7.4.0.md", + "releaseNoteBlobSha": "a214604f2de133d278ed4ceeb441239fdb672fd6", + "phpVersion": "8.4.24" + }, + "oldestSupported": { + "version": "7.0.0", + "releaseNote": "7.0.0.md", + "releaseNoteBlobSha": "bcd7091b3b8f94b599eb23a58ab4c8f0eefefe55", + "phpVersion": "8.3.8" + } + }, + "webgui": { + "repository": "unraid/webgui", + "reviewedCommit": "995cc85e8801b8cf32a4016ea5d597ba400beb2d", + "files": { + "emhttp/plugins/dynamix.docker.manager/DockerContainers.page": "6e9f853f3a576367b003d5ca2d42fc6b5c29938a", + "emhttp/plugins/dynamix.docker.manager/include/DockerContainers.php": "b78393ceecf8a9878ca46656badf439a24de5c27", + "emhttp/plugins/dynamix.docker.manager/include/UserPrefs.php": "3a2ea460b99f2a521b5890107d15570df293ee52", + "emhttp/plugins/dynamix.docker.manager/javascript/docker.js": "bbf8b3543d8479b914732682e465783c526cd3a6", + "emhttp/plugins/dynamix.docker.manager/nchan/docker_load": "f5e99b98455f8827d20244012808516db589c084", + "emhttp/plugins/dynamix.vm.manager/VMMachines.page": "d937d6d4d582e890ce72e0d6ed69cdd7b992fe9a", + "emhttp/plugins/dynamix.vm.manager/include/VMMachines.php": "b791fc43a2a1472b3f7cb8a4373451e2aebacf45", + "emhttp/plugins/dynamix.vm.manager/include/VMajax.php": "34acadd4d73c7f1f1556db9ac685d11a4cb6bed2", + "emhttp/plugins/dynamix.vm.manager/include/UserPrefs.php": "57169f4224842b9151dabe8b9d2613e3f6206a20", + "emhttp/plugins/dynamix/Dashboard.page": "3c5d1a68ac7103fe7c286e8e36956b5174c22915", + "emhttp/plugins/dynamix/include/DashboardApps.php": "53ad8efab59e7453a350578204e317c3656c5c28", + "emhttp/plugins/dynamix.plugin.manager/PluginHelpers.page": "15a2397ead358ef9834922e9c3cb3880f8f3399c", + "emhttp/plugins/dynamix.plugin.manager/PluginInstall.page": "663dd369df119e0f8cfd2ac9ad9074b7d6da1024", + "emhttp/plugins/dynamix.plugin.manager/Plugins.page": "f98e82ed8bab64c83edac85114e69b9b231eaefd", + "emhttp/plugins/dynamix.plugin.manager/PluginsError.page": "10b746975962642cd17f00d3d14c29ac8246a094", + "emhttp/plugins/dynamix.plugin.manager/PluginsStale.page": "b226866b8fe80c236d98ab80ab706812ab7aa40e", + "emhttp/plugins/dynamix.plugin.manager/include/Downgrade.php": "9742e7bbc3fd41320f4f30b715a9712ad2006c5e", + "emhttp/plugins/dynamix.plugin.manager/include/PluginHelpers.php": "1ea0258b824d71316caf9f857a861e483a8a3dc4", + "emhttp/plugins/dynamix.plugin.manager/include/ShowChanges.php": "3500f5e326ac0458f37db67e3ef794cf4037816e", + "emhttp/plugins/dynamix.plugin.manager/include/ShowPlugins.php": "f238d1bec1399bc03280a263f199eec34ce59338", + "emhttp/plugins/dynamix.plugin.manager/post-hooks/post_plugin_checks": "0f9f6ad6178473844338b1f6fb15214fcddb1a04", + "emhttp/plugins/dynamix.plugin.manager/pre-hooks/pre_plugin_checks": "0bd6047ef6fd9aa50a6eee091773594a05901e67", + "emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php": "66b457448f9364320d941b6416a670a125bc6abb", + "emhttp/plugins/dynamix.plugin.manager/scripts/checkall": "3033b88051d663a02d9124772f7cd07e852181ca", + "emhttp/plugins/dynamix.plugin.manager/scripts/multiplugin": "3c87d6171ea679d10c3a9b8ee5a626d26478dfce", + "emhttp/plugins/dynamix.plugin.manager/scripts/plugin": "d9fa17beeae39b86863bcb57ca2123878b47acfb", + "emhttp/plugins/dynamix.plugin.manager/scripts/plugin_rm": "6a9210fdd14dfebcebf4ce85af606b7a12b926c3", + "emhttp/plugins/dynamix.plugin.manager/scripts/plugincheck": "301537304151ca963bd167f4d62c91e2215d1df4", + "emhttp/plugins/dynamix.plugin.manager/scripts/showchanges": "f5b85fba05e141e14146336cc4a99d6b5975b9f8" + } + }, + "communityApplications": { + "starterRepository": "unraid/unraid-community-apps-starter", + "reviewedCommit": "01398ff8bd728ce92dd9595e33d8577299e0e971", + "files": { + "README.md": "8a3c8a2b8a0880990a5e0b392fd446a95bdb1a97", + "ca_profile.xml": "8ce639fe6d2e9bf81f3681cfcc7f8200ee2b6b59", + "plugins/example-plugin.xml": "1d343631a1b11057b3d487aee2c89b4848c02ce2" + }, + "portalHelpUrl": "https://ca.unraid.net/submit/help/repository-xml", + "feedUrl": "https://ca.unraid.net/assets/feed/applicationFeed.json", + "catalogTemplateUrl": "https://raw.githubusercontent.com/alexphillips-dev/unraid-ca-templates/main/folderview.plus.xml" + }, + "phpMatrix": [ + { + "profile": "oldest-supported", + "unraidVersion": "7.0.0", + "phpVersion": "8.3.8", + "image": "php:8.3.8-cli-alpine" + }, + { + "profile": "current-stable", + "unraidVersion": "7.3.2", + "phpVersion": "8.4.23", + "image": "php:8.4.23-cli-alpine" + }, + { + "profile": "current-prerelease", + "unraidVersion": "7.4.0-beta.1", + "phpVersion": "8.4.24", + "image": "php:8.4.24-cli-alpine" + } + ] +} diff --git a/docs/unraid-docker-api-integration.md b/docs/unraid-docker-api-integration.md index d882a240..76d88b52 100644 --- a/docs/unraid-docker-api-integration.md +++ b/docs/unraid-docker-api-integration.md @@ -147,6 +147,8 @@ Integration remains disabled regardless of those individual booleans until Unrai The reviewed baseline is `docs/unraid-docker-upstream-baseline.json`. A gate change, schema change, missing required token, or newer API release produces a review signal and opens or updates the scheduled compatibility issue. Monitoring never changes runtime behavior automatically. +The daily `scripts/unraid_compatibility_monitor.mjs` orchestration adds stable/prerelease Unraid release notes, PHP runtime changes, exact relevant `unraid/webgui` file signatures, and Community Applications publication contracts. All baselines require a reviewed repository change; CI never advances them automatically. + ## Validation Focused contracts live in: diff --git a/docs/unraid-docker-interface-compatibility.md b/docs/unraid-docker-interface-compatibility.md index dadb6560..2a9aa16d 100644 --- a/docs/unraid-docker-interface-compatibility.md +++ b/docs/unraid-docker-interface-compatibility.md @@ -2,7 +2,7 @@ This document defines how FolderView Plus coexists with the current table-based Unraid Docker page and the native component/API replacement being developed by Unraid. -The upstream implementation was last reviewed on 2026-08-18 against Unraid API v4.37.1. That release added PKCE support to Unraid OIDC without changing the Docker interface, generated Docker GraphQL contract, native Docker page, or organizer implementation. The generated Docker contract retained the reviewed signature, and Unraid's [`docker-containers-page` file modification](https://github.com/unraid/api/blob/main/api/src/unraid-api/unraid-file-modifier/modifications/docker-containers-page.modification.ts) still returned `shouldApply: false`, while its replacement markup contained ``. The native Docker implementation therefore remains prerelease, and safe mode plus the existing organizer boundary remain unchanged. +The upstream implementation was last reviewed on 2026-08-21 against Unraid API v4.37.2. That release adds TXZ-install cleanup for stale Unraid API web-component files without changing the Docker interface, generated Docker GraphQL contract, native Docker page, or organizer implementation. The generated Docker contract retained the reviewed signature, and Unraid's [`docker-containers-page` file modification](https://github.com/unraid/api/blob/main/api/src/unraid-api/unraid-file-modifier/modifications/docker-containers-page.modification.ts) still returned `shouldApply: false`, while its replacement markup contained ``. The native Docker implementation therefore remains prerelease, and safe mode plus the existing organizer boundary remain unchanged. ## Host generations @@ -103,13 +103,13 @@ The `docker-api-legacy.html` fixture and `tests/fixtures/unraid-api/*.json` veri ## Activation triggers -`scripts/unraid_docker_upstream_monitor.sh` treats any of these as a review signal: +`scripts/unraid_docker_upstream_monitor.sh` treats any of these as a Docker/API review signal: 1. Upstream `docker-containers-page.modification.ts` changes to `shouldApply: true`. 2. Supplied official release notes announce a native/new Docker page or interface. 3. The tracked Docker GraphQL schema signature changes or a required capability disappears. 4. The latest official Unraid API release differs from the reviewed baseline. -An unrecognizable source shape is also a blocking signal because silently assuming the replacement remains disabled would be unsafe. The scheduled workflow `.github/workflows/unraid-docker-upstream-monitor.yml` checks the source gate, GraphQL schema, API release, and current Unraid release notes weekly and can be run manually. +An unrecognizable source shape is also a blocking signal because silently assuming the replacement remains disabled would be unsafe. The daily workflow `.github/workflows/unraid-docker-upstream-monitor.yml` combines that focused check with `scripts/unraid_compatibility_monitor.mjs`. It also compares reviewed stable/prerelease Unraid versions, PHP runtimes, exact Docker/VM/Dashboard webGUI file signatures, plugin-manager install/update/downgrade pages, helpers, hooks and scripts, the official Community Applications starter contract, the public catalog listing, and portal guidance. A drift issue never approves or rewrites a baseline automatically. See [Unraid Docker Prerelease Qualification](unraid-docker-prerelease-qualification.md) before changing the coexistence policy. diff --git a/docs/unraid-docker-prerelease-qualification.md b/docs/unraid-docker-prerelease-qualification.md index 9f51fa1f..927adf60 100644 --- a/docs/unraid-docker-prerelease-qualification.md +++ b/docs/unraid-docker-prerelease-qualification.md @@ -12,6 +12,8 @@ bash scripts/unraid_docker_upstream_monitor.sh --json Review the official Unraid release notes and generated API contract. If the monitor reports `active` or `unknown`, do not force legacy mode. Preserve native-page safe mode, update the reviewed schema baseline only after inspecting the upstream change, and add an isolated fixture that represents the new host or schema outcome. +The scheduled compatibility workflow also runs `scripts/unraid_compatibility_monitor.mjs` against `docs/unraid-compatibility-baseline.json`. Review every reported stable/prerelease OS, PHP, webGUI, plugin-manager, API, and Community Applications signal. Plugin-manager coverage includes the install, update, downgrade, removal, pre/post-check, and change-display paths that can affect FolderView Plus installation and updates. Baseline changes are human-reviewed repository updates; the workflow intentionally cannot approve upstream drift itself. + ## 2. Maintain the isolated profile matrix Profiles live in `tests/fixtures/unraid-api/`. Every profile is synthetic and must contain no real server, user, workload, path, address, URL, token, or cookie data. @@ -32,7 +34,25 @@ Profiles live in `tests/fixtures/unraid-api/`. Every profile is synthetic and mu When an upstream change cannot be expressed by an existing profile, add one minimal profile and a contract test before changing runtime behavior. -## 3. Validate legacy API-first reads +## 3. Validate supported PHP runtimes + +`scripts/php_runtime_compatibility.sh` syntax-checks every shipped PHP file and executes a standalone request-authority contract. The scheduled workflow runs it in three isolated container profiles matching the oldest supported Unraid release, the current stable release, and the current prerelease recorded in `docs/unraid-compatibility-baseline.json`. + +PHP patch changes in official Unraid release notes are review signals. Update the matrix only after the image exists, the full shipped PHP surface passes, and any new deprecation or behavior difference is understood. + +## 4. Validate Community Applications publication + +`scripts/community_applications_guard.mjs` compares the stable repository metadata and manifest with: + +- The official Community Applications repository XML guidance. +- The official starter plugin contract. +- The canonical `unraid-ca-templates` FolderView Plus entry. +- The public Community Applications feed entry. +- The version currently published through the stable plugin manifest. + +The interactive portal's authenticated **Validate** and **Scan** actions remain a manual release/submission check. CI uses only public inputs and never stores a Community Applications login or browser session. + +## 5. Validate legacy API-first reads The expected sequence on a legacy Docker table is: @@ -45,7 +65,7 @@ The expected sequence on a legacy Docker table is: API state may update running, paused, status, autostart, and only optional fields proven by introspection. PHP metadata such as paths, ports, mounts, shell, template links, project data, and fallback URLs must remain intact. -## 4. Validate lifecycle and mutation ownership +## 6. Validate lifecycle and mutation ownership On the legacy Docker page and Dashboard: @@ -57,7 +77,7 @@ On the legacy Docker page and Dashboard: A schema field existing is necessary but not sufficient to expose a persistent or destructive mutation. Such a feature needs its own isolated persistence, conflict, rollback, and failure fixtures plus explicit product approval. -## 5. Validate failures and lifecycle cleanup +## 7. Validate failures and lifecycle cleanup Confirm: @@ -68,7 +88,7 @@ Confirm: - Navigation and `pagehide` abort active requests, close subscriptions, cancel reconnect, and reject stale results. - Repeated Docker, Settings, and Dashboard navigation creates no duplicate handlers, observers, timers, requests, or rows. -## 6. Validate native coexistence +## 8. Validate native coexistence Against `future-native-host` and `future-docker-host.html`, confirm: @@ -80,7 +100,7 @@ Against `future-native-host` and `future-docker-host.html`, confirm: Any organizer migration requires a separately approved schema review, conflict model, backup/rollback design, explicit user confirmation, and isolated cross-version tests. -## 7. Validate diagnostics and privacy +## 9. Validate diagnostics and privacy Evidence may contain only: @@ -93,10 +113,11 @@ Evidence may contain only: It must never contain container, VM, or folder names/IDs; paths; IPs; URLs; labels; log content; CSRF values; cookies; tokens; GraphQL variables; raw schema responses; or raw server errors. -## 8. Run qualification +## 10. Run qualification ```bash -node --test tests/docker-runtime-api-coordinator.test.mjs tests/unraid-api-fixtures.test.mjs tests/unraid-docker-future-compatibility.test.mjs tests/unraid-upstream-monitor.test.mjs +node --test tests/docker-runtime-api-coordinator.test.mjs tests/unraid-api-fixtures.test.mjs tests/unraid-docker-future-compatibility.test.mjs tests/unraid-upstream-monitor.test.mjs tests/unraid-compatibility-monitor.test.mjs tests/community-applications-guard.test.mjs +sh scripts/php_runtime_compatibility.sh bash scripts/run_ci_suite.sh --lane lint --lane tests bash scripts/run_ci_suite.sh --lane workflow-tests --lane workflow-guards --lane docs-guards bash scripts/run_ci_suite.sh --lane fixture-browser @@ -109,7 +130,7 @@ bash scripts/install_smoke.sh `browser-smoke` is a deterministic Chromium fixture profile. `theme-matrix` runs the same local inventory across Chromium, Firefox, WebKit, light/dark color schemes, and desktop/smartphone viewports. Scheduled validation uses the isolated fixture suite and has no live-system secrets. -## 9. Make the release decision +## 11. Make the release decision Keep native Docker-page ownership with Unraid unless all of these are true: diff --git a/docs/unraid-docker-upstream-baseline.json b/docs/unraid-docker-upstream-baseline.json index ceb5ba19..5c3c6e0e 100644 --- a/docs/unraid-docker-upstream-baseline.json +++ b/docs/unraid-docker-upstream-baseline.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "reviewedAt": "2026-08-18", - "latestReviewedApiRelease": "v4.37.1", + "reviewedAt": "2026-08-21", + "latestReviewedApiRelease": "v4.37.2", "schemaSignature": "dc036a1ef8b075d8f193831554cb93c0b2bbce6e1bffb45752841ffaa2aa6d0f", "requiredTokens": [ "containers(skipCache: Boolean! = false @deprecated(reason: \"Caching has been removed; this parameter is now ignored\")): [DockerContainer!]!", diff --git a/scripts/classify_ci_changes.mjs b/scripts/classify_ci_changes.mjs index ccc21a39..6997f986 100644 --- a/scripts/classify_ci_changes.mjs +++ b/scripts/classify_ci_changes.mjs @@ -38,6 +38,10 @@ export const FILTERS = Object.freeze({ 'scripts/scheduled_workflow_health.mjs', 'scripts/codeql_alert_guard.mjs', 'scripts/unraid_docker_upstream_monitor.sh', + 'scripts/unraid_compatibility_monitor.mjs', + 'scripts/community_applications_guard.mjs', + 'scripts/php_runtime_compatibility.sh', + 'docs/unraid-compatibility-baseline.json', 'tests/ci-change-classifier.test.mjs', 'tests/versioning-guard.test.mjs' ], diff --git a/scripts/community_applications_guard.mjs b/scripts/community_applications_guard.mjs new file mode 100644 index 00000000..a1067087 --- /dev/null +++ b/scripts/community_applications_guard.mjs @@ -0,0 +1,220 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const readText = (file) => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); +const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); +const extractTag = (source, tag) => { + const match = String(source || '').match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'i')); + return match ? normalizeText(match[1]) : ''; +}; + +export const parsePluginMetadata = (source) => { + const fields = {}; + for (const tag of [ + 'Plugin', 'PluginURL', 'PluginAuthor', 'Beta', 'Category', 'Name', 'CA', 'Description', + 'Date', 'MinVer', 'ExtraSearchTerms', 'Support', 'Icon', 'Project' + ]) { + fields[tag] = extractTag(source, tag); + } + return fields; +}; + +export const findCatalogEntry = (payload, expectedName) => { + const target = String(expectedName || '').toLowerCase(); + const seen = new Set(); + let match = null; + const visit = (value) => { + if (match || !value || typeof value !== 'object' || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach(visit); + return; + } + if (String(value.Name || value.name || '').toLowerCase() === target && (value.PluginURL || value.pluginUrl)) { + match = value; + return; + } + Object.values(value).forEach(visit); + }; + visit(payload); + return match; +}; + +const manifestVersion = (source) => String(source || '').match(/ /^https:\/\//i.test(String(value || '')); +const rawGitHubBranch = (value) => { + try { + const url = new URL(String(value || '')); + if (url.hostname !== 'raw.githubusercontent.com') return ''; + return url.pathname.split('/').filter(Boolean)[2] || ''; + } catch { + return ''; + } +}; +const pushMismatch = (signals, reason, expected, actual, pathValue = '') => signals.push({ + category: 'community-applications', + reason, + path: pathValue, + expected: String(expected || ''), + actual: String(actual || '') +}); + +export const evaluateCommunityApplications = ({ + metadataSource, + manifestSource, + catalogTemplateSource, + feedPayload, + portalHelpSource, + starterPluginSource, + expectedBranch +}) => { + const reviewSignals = []; + const unknownSignals = []; + const metadata = parsePluginMetadata(metadataSource); + const catalogTemplate = parsePluginMetadata(catalogTemplateSource); + const requiredFields = ['PluginURL', 'Name', 'Description', 'Category', 'Support', 'Project', 'Icon', 'MinVer']; + requiredFields.forEach((field) => { + if (!metadata[field]) pushMismatch(reviewSignals, 'required-metadata-missing', field, 'missing', field); + }); + for (const field of ['PluginURL', 'Support', 'Project', 'Icon']) { + if (metadata[field] && !isHttps(metadata[field])) pushMismatch(reviewSignals, 'metadata-url-not-https', 'https URL', metadata[field], field); + } + if (String(metadata.Plugin).toLowerCase() !== 'true') { + pushMismatch(reviewSignals, 'plugin-marker-invalid', 'True', metadata.Plugin || 'missing', 'Plugin'); + } + if (String(metadata.Beta).toLowerCase() !== 'false') { + pushMismatch(reviewSignals, 'stable-listing-marked-beta', 'False', metadata.Beta || 'missing', 'Beta'); + } + if (expectedBranch && metadata.PluginURL && rawGitHubBranch(metadata.PluginURL) !== expectedBranch) { + pushMismatch(reviewSignals, 'plugin-url-branch-mismatch', expectedBranch, metadata.PluginURL, 'PluginURL'); + } + + for (const field of ['PluginURL', 'Name', 'Description', 'Category', 'Support', 'Project', 'Icon', 'MinVer', 'ExtraSearchTerms']) { + if (metadata[field] !== catalogTemplate[field]) { + pushMismatch(reviewSignals, 'catalog-template-drift', metadata[field], catalogTemplate[field], field); + } + } + + const version = manifestVersion(manifestSource); + if (!version) pushMismatch(unknownSignals, 'manifest-version-unavailable', 'version entity', 'missing', 'folderview.plus.plg'); + const entry = findCatalogEntry(feedPayload, metadata.Name || 'FolderView Plus'); + if (!entry) { + pushMismatch(unknownSignals, 'catalog-entry-not-found', metadata.Name || 'FolderView Plus', 'missing', 'applicationFeed.json'); + } else { + const feedFields = { + PluginURL: entry.PluginURL, + Name: entry.Name, + Support: entry.Support, + Project: entry.Project, + Icon: entry.Icon, + MinVer: entry.MinVer, + Description: entry.Overview + }; + for (const [field, actual] of Object.entries(feedFields)) { + if (normalizeText(actual) !== normalizeText(metadata[field])) { + pushMismatch(reviewSignals, 'published-feed-drift', metadata[field], actual, field); + } + } + if (version && String(entry.pluginVersion || '') !== version) { + pushMismatch(reviewSignals, 'published-version-drift', version, entry.pluginVersion || 'missing', 'pluginVersion'); + } + } + + const helpRequirements = ['PluginURL', 'Support', 'Project', 'Validate', 'Scan']; + helpRequirements.forEach((token) => { + if (!String(portalHelpSource || '').includes(token)) { + pushMismatch(unknownSignals, 'portal-help-contract-unavailable', token, 'missing', 'repository-xml'); + } + }); + for (const token of ['PluginURL', 'Support', 'Project', 'Overview', 'Category']) { + if (!String(starterPluginSource || '').includes(`<${token}>`)) { + pushMismatch(unknownSignals, 'starter-plugin-contract-unavailable', token, 'missing', 'plugins/example-plugin.xml'); + } + } + + const status = unknownSignals.length > 0 ? 'unknown' : (reviewSignals.length > 0 ? 'review' : 'matched'); + const reason = unknownSignals[0]?.reason || reviewSignals[0]?.reason || 'canonical-published-listing-matches'; + return { status, reason, version, metadata, reviewSignals, unknownSignals }; +}; + +const signalTable = (signals) => { + if (signals.length === 0) return 'None.'; + return [ + '| Reason | Field | Expected | Actual |', + '| --- | --- | --- | --- |', + ...signals.map((signal) => `| ${signal.reason} | ${signal.path || '—'} | \`${String(signal.expected).replaceAll('|', '\\|')}\` | \`${String(signal.actual).replaceAll('|', '\\|')}\` |`) + ].join('\n'); +}; + +export const buildCommunityApplicationsReport = (result) => [ + '# Community Applications validation', + '', + `- Status: \`${result.status}\``, + `- Reason: \`${result.reason}\``, + `- Published plugin version: \`${result.version || 'unknown'}\``, + '', + '## Review signals', + '', + signalTable(result.reviewSignals), + '', + '## Unknown or unavailable signals', + '', + signalTable(result.unknownSignals), + '' +].join('\n'); + +const parseArgs = (argv) => { + const options = { json: false }; + const values = new Set([ + '--metadata', '--manifest', '--catalog-template', '--feed', '--portal-help', '--starter-plugin', + '--expected-branch', '--report', '--github-output' + ]); + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--json') options.json = true; + else if (values.has(arg)) options[arg.slice(2).replaceAll('-', '_')] = String(argv[++index] || ''); + else throw new Error(`Unknown argument: ${arg}`); + } + for (const required of ['metadata', 'manifest', 'catalog_template', 'feed', 'portal_help', 'starter_plugin']) { + if (!options[required]) throw new Error(`--${required.replaceAll('_', '-')} is required.`); + } + return options; +}; + +const main = () => { + const options = parseArgs(process.argv.slice(2)); + const result = evaluateCommunityApplications({ + metadataSource: readText(options.metadata), + manifestSource: readText(options.manifest), + catalogTemplateSource: readText(options.catalog_template), + feedPayload: JSON.parse(readText(options.feed)), + portalHelpSource: readText(options.portal_help), + starterPluginSource: readText(options.starter_plugin), + expectedBranch: options.expected_branch + }); + const report = buildCommunityApplicationsReport(result); + if (options.report) fs.writeFileSync(options.report, report, 'utf8'); + if (options.github_output) { + fs.appendFileSync(options.github_output, [ + `status=${result.status}`, + `reason=${result.reason}`, + `version=${result.version || 'unknown'}`, + `review_count=${result.reviewSignals.length}`, + `unknown_count=${result.unknownSignals.length}` + ].join('\n') + '\n'); + } + if (options.json) process.stdout.write(`${JSON.stringify(result)}\n`); + else process.stdout.write(report); + process.exitCode = result.status === 'matched' ? 0 : (result.status === 'review' ? 20 : 21); +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + main(); + } catch (error) { + console.error(`Community Applications validation failed: ${error.message}`); + process.exitCode = 21; + } +} diff --git a/scripts/php_runtime_compatibility.sh b/scripts/php_runtime_compatibility.sh new file mode 100644 index 00000000..7ea10a63 --- /dev/null +++ b/scripts/php_runtime_compatibility.sh @@ -0,0 +1,31 @@ +#!/bin/sh +set -eu + +ROOT_DIR="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" +PHP_BIN="${FVPLUS_PHP_BIN:-php}" +EXPECTED_PREFIX="${FVPLUS_EXPECT_PHP_VERSION_PREFIX:-}" + +if ! command -v "${PHP_BIN}" >/dev/null 2>&1; then + echo "PHP runtime compatibility failed: ${PHP_BIN} is unavailable." >&2 + exit 1 +fi + +ACTUAL_VERSION="$(${PHP_BIN} -r 'echo PHP_MAJOR_VERSION, ".", PHP_MINOR_VERSION, ".", PHP_RELEASE_VERSION;')" +if [ -n "${EXPECTED_PREFIX}" ]; then + case "${ACTUAL_VERSION}" in + "${EXPECTED_PREFIX}"*) ;; + *) + echo "PHP runtime compatibility failed: expected ${EXPECTED_PREFIX}, received ${ACTUAL_VERSION}." >&2 + exit 1 + ;; + esac +fi + +COUNT=0 +find "${ROOT_DIR}/src/folderview.plus" -type f -name '*.php' -print | LC_ALL=C sort | while IFS= read -r file; do + "${PHP_BIN}" -l "${file}" >/dev/null +done +COUNT="$(find "${ROOT_DIR}/src/folderview.plus" -type f -name '*.php' | wc -l | tr -d '[:space:]')" +"${PHP_BIN}" "${ROOT_DIR}/tests/php-runtime-compatibility.php" + +echo "PHP runtime compatibility passed: php=${ACTUAL_VERSION}, files=${COUNT}." diff --git a/scripts/scheduled_workflow_health.mjs b/scripts/scheduled_workflow_health.mjs index 9e5baa8e..ae6d0a51 100644 --- a/scripts/scheduled_workflow_health.mjs +++ b/scripts/scheduled_workflow_health.mjs @@ -6,8 +6,8 @@ import { pathToFileURL } from 'node:url'; export const SCHEDULED_WORKFLOW_TARGETS = Object.freeze([ Object.freeze({ workflowFile: 'unraid-docker-upstream-monitor.yml', - label: 'Unraid Docker Upstream Monitor', - maximumSuccessAgeHours: 204 + label: 'Unraid Compatibility Monitor', + maximumSuccessAgeHours: 72 }), Object.freeze({ workflowFile: 'scheduled-validation.yml', @@ -18,6 +18,21 @@ export const SCHEDULED_WORKFLOW_TARGETS = Object.freeze([ workflowFile: 'clone-traffic-badge.yml', label: 'Rolling Clone Traffic Badge', maximumSuccessAgeHours: 72 + }), + Object.freeze({ + workflowFile: 'codeql.yml', + label: 'CodeQL', + maximumSuccessAgeHours: 204 + }), + Object.freeze({ + workflowFile: 'scorecard.yml', + label: 'OpenSSF Scorecard', + maximumSuccessAgeHours: 204 + }), + Object.freeze({ + workflowFile: 'dependency-vulnerability-scan.yml', + label: 'Dependency Vulnerability Scan', + maximumSuccessAgeHours: 204 }) ]); diff --git a/scripts/unraid_compatibility_monitor.mjs b/scripts/unraid_compatibility_monitor.mjs new file mode 100644 index 00000000..b29165a4 --- /dev/null +++ b/scripts/unraid_compatibility_monitor.mjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const readText = (file) => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); +const readJson = (file) => JSON.parse(readText(file)); + +export const gitBlobSha = (content) => { + const body = Buffer.isBuffer(content) ? content : Buffer.from(String(content), 'utf8'); + return crypto.createHash('sha1') + .update(Buffer.from(`blob ${body.length}\0`, 'utf8')) + .update(body) + .digest('hex'); +}; + +const releaseParts = (value) => { + const match = String(value || '').trim().match(/^(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)\.(\d+))?$/i); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + channel: String(match[4] || 'stable').toLowerCase(), + iteration: Number(match[5] || 0) + }; +}; + +const compareReleases = (left, right) => { + const a = releaseParts(left); + const b = releaseParts(right); + if (!a || !b) return String(left).localeCompare(String(right), 'en', { numeric: true }); + for (const key of ['major', 'minor', 'patch']) { + if (a[key] !== b[key]) return a[key] - b[key]; + } + const weight = { alpha: 0, beta: 1, rc: 2, stable: 3 }; + if (weight[a.channel] !== weight[b.channel]) return weight[a.channel] - weight[b.channel]; + return a.iteration - b.iteration; +}; + +const parsePhpVersion = (source) => { + const lines = String(source || '').split('\n').filter((line) => /^\s*[-*]\s*php:\s*version\s+/i.test(line)); + if (lines.length === 0) return 'unknown'; + const versions = Array.from(lines.at(-1).matchAll(/\d+\.\d+\.\d+/g), (match) => match[0]); + return versions.at(-1) || 'unknown'; +}; + +export const scanReleaseNotes = (releaseNotesDir) => { + const releases = []; + for (const entry of fs.readdirSync(releaseNotesDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const file = path.join(releaseNotesDir, entry.name); + const source = readText(file); + const match = source.match(/^#\s+Version\s+(\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.\d+)?)/im); + if (!match || !releaseParts(match[1])) continue; + releases.push({ + version: match[1], + file: entry.name, + blobSha: gitBlobSha(Buffer.from(source, 'utf8')), + phpVersion: parsePhpVersion(source) + }); + } + const stable = releases + .filter((entry) => releaseParts(entry.version)?.channel === 'stable') + .sort((left, right) => compareReleases(right.version, left.version))[0] || null; + const prerelease = releases + .filter((entry) => releaseParts(entry.version)?.channel !== 'stable') + .sort((left, right) => compareReleases(right.version, left.version))[0] || null; + return { stable, prerelease, count: releases.length }; +}; + +const compareFileBaseline = ({ root, expectedFiles, category, reviewSignals, unknownSignals }) => { + const actual = {}; + for (const [relativePath, expectedSha] of Object.entries(expectedFiles || {})) { + const file = path.join(root, ...relativePath.split('/')); + if (!fs.existsSync(file)) { + unknownSignals.push({ category, reason: 'upstream-file-missing', path: relativePath, expected: expectedSha, actual: 'missing' }); + continue; + } + const actualSha = gitBlobSha(fs.readFileSync(file)); + actual[relativePath] = actualSha; + if (actualSha !== expectedSha) { + reviewSignals.push({ category, reason: 'upstream-file-changed', path: relativePath, expected: expectedSha, actual: actualSha }); + } + } + return actual; +}; + +const compareRelease = ({ label, actual, expected, reviewSignals, unknownSignals }) => { + if (!actual) { + unknownSignals.push({ category: 'unraid-os', reason: `${label}-release-not-found`, expected: expected?.version || 'configured', actual: 'missing' }); + return; + } + if (actual.version !== expected?.version) { + reviewSignals.push({ category: 'unraid-os', reason: `new-${label}-release`, expected: expected?.version || 'none', actual: actual.version }); + return; + } + if (expected.releaseNoteBlobSha && actual.blobSha !== expected.releaseNoteBlobSha) { + reviewSignals.push({ category: 'unraid-os', reason: `${label}-release-notes-changed`, path: actual.file, expected: expected.releaseNoteBlobSha, actual: actual.blobSha }); + } + if (expected.phpVersion && actual.phpVersion !== expected.phpVersion) { + reviewSignals.push({ category: 'php-runtime', reason: `${label}-php-version-changed`, expected: expected.phpVersion, actual: actual.phpVersion }); + } +}; + +const markdownTable = (signals) => { + if (signals.length === 0) return 'None.'; + const lines = ['| Category | Reason | Path | Expected | Actual |', '| --- | --- | --- | --- | --- |']; + signals.forEach((signal) => { + const cell = (value) => String(value ?? '').replaceAll('|', '\\|').replaceAll('\n', ' '); + lines.push(`| ${cell(signal.category)} | ${cell(signal.reason)} | ${cell(signal.path || '—')} | \`${cell(signal.expected || '—')}\` | \`${cell(signal.actual || '—')}\` |`); + }); + return lines.join('\n'); +}; + +export const evaluateCompatibility = ({ baseline, releaseNotesDir, webguiDir, caStarterDir, dockerResult = {}, caResult = {} }) => { + const reviewSignals = []; + const unknownSignals = []; + const releases = scanReleaseNotes(releaseNotesDir); + compareRelease({ label: 'stable', actual: releases.stable, expected: baseline.unraidOs?.latestReviewedStable, reviewSignals, unknownSignals }); + compareRelease({ label: 'prerelease', actual: releases.prerelease, expected: baseline.unraidOs?.latestReviewedPrerelease, reviewSignals, unknownSignals }); + + const webguiFiles = compareFileBaseline({ + root: webguiDir, + expectedFiles: baseline.webgui?.files, + category: 'unraid-webgui', + reviewSignals, + unknownSignals + }); + const caStarterFiles = compareFileBaseline({ + root: caStarterDir, + expectedFiles: baseline.communityApplications?.files, + category: 'community-applications', + reviewSignals, + unknownSignals + }); + + if (!['dormant', 'matched'].includes(String(dockerResult.status || ''))) { + const target = String(dockerResult.status || '') === 'unknown' ? unknownSignals : reviewSignals; + target.push({ + category: 'unraid-api', + reason: String(dockerResult.reason || 'docker-monitor-unavailable'), + expected: 'reviewed Docker/API baseline', + actual: String(dockerResult.latestApiRelease || dockerResult.status || 'unknown') + }); + } + if (String(caResult.status || '') !== 'matched') { + const target = String(caResult.status || '') === 'unknown' ? unknownSignals : reviewSignals; + target.push({ + category: 'community-applications', + reason: String(caResult.reason || 'catalog-validation-unavailable'), + expected: 'canonical published listing', + actual: String(caResult.status || 'unknown') + }); + } + + const status = unknownSignals.length > 0 ? 'unknown' : (reviewSignals.length > 0 ? 'review' : 'matched'); + const reason = unknownSignals[0]?.reason || reviewSignals[0]?.reason || 'all-reviewed-baselines-match'; + return { + status, + reason, + reviewSignals, + unknownSignals, + releases, + webguiFiles, + caStarterFiles, + dockerResult, + caResult + }; +}; + +export const buildCompatibilityReport = (result, metadata = {}) => [ + '# Unraid compatibility monitor', + '', + `- Status: \`${result.status}\``, + `- Reason: \`${result.reason}\``, + `- Latest stable release: \`${result.releases.stable?.version || 'unknown'}\` (PHP \`${result.releases.stable?.phpVersion || 'unknown'}\`)`, + `- Latest prerelease: \`${result.releases.prerelease?.version || 'unknown'}\` (PHP \`${result.releases.prerelease?.phpVersion || 'unknown'}\`)`, + `- Unraid API release: \`${result.dockerResult.latestApiRelease || 'unknown'}\``, + `- Native Docker gate: \`${result.dockerResult.sourceGate || 'unknown'}\``, + `- Community Applications: \`${result.caResult.status || 'unknown'}\``, + metadata.webguiCommit ? `- Upstream webGUI commit: \`${metadata.webguiCommit}\`` : null, + metadata.caStarterCommit ? `- CA starter commit: \`${metadata.caStarterCommit}\`` : null, + '', + '## Review signals', + '', + markdownTable(result.reviewSignals), + '', + '## Unknown or unavailable signals', + '', + markdownTable(result.unknownSignals), + '', + '## Required response', + '', + result.status === 'matched' + ? 'All official upstream inputs match the human-reviewed baselines. No compatibility action is required.' + : 'Review the official upstream difference, update or add isolated fixtures, run the PHP and browser compatibility lanes, and update a baseline only after the change is understood. Do not add live-Unraid credentials to CI.', + '' +].filter((line) => line !== null).join('\n') + '\n'; + +const parseArgs = (argv) => { + const options = { json: false }; + const valueOptions = new Set([ + '--baseline', '--release-notes-dir', '--webgui-dir', '--webgui-commit', '--ca-starter-dir', + '--ca-starter-commit', '--docker-result', '--ca-result', '--report', '--github-output' + ]); + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--json') options.json = true; + else if (valueOptions.has(arg)) options[arg.slice(2).replaceAll('-', '_')] = String(argv[++index] || ''); + else throw new Error(`Unknown argument: ${arg}`); + } + for (const required of ['baseline', 'release_notes_dir', 'webgui_dir', 'ca_starter_dir', 'docker_result', 'ca_result']) { + if (!options[required]) throw new Error(`--${required.replaceAll('_', '-')} is required.`); + } + return options; +}; + +const main = () => { + const options = parseArgs(process.argv.slice(2)); + const result = evaluateCompatibility({ + baseline: readJson(options.baseline), + releaseNotesDir: options.release_notes_dir, + webguiDir: options.webgui_dir, + caStarterDir: options.ca_starter_dir, + dockerResult: readJson(options.docker_result), + caResult: readJson(options.ca_result) + }); + const report = buildCompatibilityReport(result, { + webguiCommit: options.webgui_commit, + caStarterCommit: options.ca_starter_commit + }); + if (options.report) fs.writeFileSync(options.report, report, 'utf8'); + if (options.github_output) { + fs.appendFileSync(options.github_output, [ + `status=${result.status}`, + `reason=${result.reason}`, + `review_count=${result.reviewSignals.length}`, + `unknown_count=${result.unknownSignals.length}`, + `latest_stable=${result.releases.stable?.version || 'unknown'}`, + `latest_prerelease=${result.releases.prerelease?.version || 'unknown'}`, + `webgui_commit=${options.webgui_commit || 'unknown'}`, + `ca_starter_commit=${options.ca_starter_commit || 'unknown'}` + ].join('\n') + '\n'); + } + if (options.json) process.stdout.write(`${JSON.stringify(result)}\n`); + else process.stdout.write(report); + process.exitCode = result.status === 'matched' ? 0 : (result.status === 'review' ? 20 : 21); +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + try { + main(); + } catch (error) { + console.error(`Unraid compatibility monitor failed: ${error.message}`); + process.exitCode = 21; + } +} diff --git a/scripts/workflow_self_check.sh b/scripts/workflow_self_check.sh index a14b5395..f6c9e193 100644 --- a/scripts/workflow_self_check.sh +++ b/scripts/workflow_self_check.sh @@ -31,6 +31,7 @@ for (const relativePath of [ '.github/workflows/release-on-main.yml', '.github/workflows/codeql.yml', '.github/workflows/dependency-review.yml', + '.github/workflows/dependency-vulnerability-scan.yml', '.github/workflows/scorecard.yml', '.github/workflows/clone-traffic-badge.yml', '.github/workflows/scheduled-validation.yml', @@ -49,6 +50,10 @@ for (const relativePath of [ 'scripts/runtime_perf_baseline.json', 'scripts/scheduled_workflow_health.mjs', 'scripts/codeql_alert_guard.mjs', + 'scripts/community_applications_guard.mjs', + 'scripts/php_runtime_compatibility.sh', + 'scripts/unraid_compatibility_monitor.mjs', + 'docs/unraid-compatibility-baseline.json', 'scripts/build_release_notes.sh', 'scripts/simulate_main_release.sh', 'scripts/docs_metadata_guard.sh', @@ -63,6 +68,7 @@ const releaseOnMainWorkflow = read('.github/workflows/release-on-main.yml'); const backmergeWorkflow = read('.github/workflows/backmerge-main-to-dev.yml'); const codeqlWorkflow = read('.github/workflows/codeql.yml'); const dependencyReviewWorkflow = read('.github/workflows/dependency-review.yml'); +const dependencyVulnerabilityScanWorkflow = read('.github/workflows/dependency-vulnerability-scan.yml'); const scorecardWorkflow = read('.github/workflows/scorecard.yml'); const cloneTrafficBadgeWorkflow = read('.github/workflows/clone-traffic-badge.yml'); const scheduledValidationWorkflow = read('.github/workflows/scheduled-validation.yml'); @@ -189,6 +195,15 @@ if (!/ossf\/scorecard-action@[0-9a-f]{40}\s+# v2\.4\.4/.test(scorecardWorkflow) || !/id-token:\s*write/.test(scorecardWorkflow)) { fail('OpenSSF Scorecard must publish signed results to GitHub code scanning with pinned actions.'); } +if (!/schedule:/.test(dependencyVulnerabilityScanWorkflow) + || !/workflow_dispatch:/.test(dependencyVulnerabilityScanWorkflow) + || !/google\/osv-scanner-action\/osv-scanner-action@[0-9a-f]{40}\s+# v2\.5\.0/.test(dependencyVulnerabilityScanWorkflow) + || !/google\/osv-scanner-action\/osv-reporter-action@[0-9a-f]{40}\s+# v2\.5\.0/.test(dependencyVulnerabilityScanWorkflow) + || !/--sbom=docs\/sbom\.cdx\.json/.test(dependencyVulnerabilityScanWorkflow) + || !/--fail-on-vuln=true/.test(dependencyVulnerabilityScanWorkflow) + || !/github\/codeql-action\/upload-sarif@[0-9a-f]{40}\s+# v4/.test(dependencyVulnerabilityScanWorkflow)) { + fail('Dependency vulnerability scanning must use pinned OSV actions, scan the generated SBOM, fail on vulnerabilities, and publish SARIF.'); +} if ((releaseOnMainWorkflow.match(/uses:\s*actions\/attest@[0-9a-f]{40}\s+# v4/g) || []).length !== 2 || !/Attest release archive provenance/.test(releaseOnMainWorkflow) || !/Attest release archive SBOM/.test(releaseOnMainWorkflow) || @@ -200,7 +215,13 @@ if (!/FVPLUS_BROWSER_SMOKE_BROWSERS:\s*chromium/.test(releaseOnMainWorkflow) || !/FVPLUS_THEME_VIEWPORTS:\s*'1180x720,390x844'/.test(releaseOnMainWorkflow)) { fail('Release On Main must run deterministic browser, theme, and responsive fixture coverage.'); } -const validationWorkflows = [ciWorkflow, backmergeWorkflow, releaseOnMainWorkflow, scheduledValidationWorkflow].join('\n'); +const validationWorkflows = [ + ciWorkflow, + backmergeWorkflow, + releaseOnMainWorkflow, + scheduledValidationWorkflow, + dependencyVulnerabilityScanWorkflow +].join('\n'); if (/FVPLUS_UNRAID_MATRIX|FVPLUS_BROWSER_SMOKE_URL|FVPLUS_THEME_MATRIX_URLS/.test(validationWorkflows)) { fail('Tracked validation workflows must not accept live-Unraid targets or secrets.'); } @@ -256,20 +277,44 @@ if (!/Back-merge follow-up required/.test(backmergeWorkflow) || if (/git push origin dev/.test(backmergeWorkflow)) { fail('Back-merge workflow must not push directly to protected dev.'); } -if (!/schedule:/.test(upstreamMonitorWorkflow) || !/workflow_dispatch:/.test(upstreamMonitorWorkflow)) { - fail('Unraid Docker upstream monitor must support scheduled and manual checks.'); +if (!/name:\s*Unraid Compatibility Monitor/.test(upstreamMonitorWorkflow) + || !/cron:\s*'43 9 \* \* \*'/.test(upstreamMonitorWorkflow) + || !/workflow_dispatch:/.test(upstreamMonitorWorkflow)) { + fail('Unraid compatibility monitor must run daily and support manual checks.'); } -if (!/scripts\/unraid_docker_upstream_monitor\.sh/.test(upstreamMonitorWorkflow)) { - fail('Unraid Docker upstream monitor workflow must use the repository monitor script.'); +if (!/scripts\/unraid_docker_upstream_monitor\.sh/.test(upstreamMonitorWorkflow) + || !/scripts\/unraid_compatibility_monitor\.mjs/.test(upstreamMonitorWorkflow) + || !/scripts\/community_applications_guard\.mjs/.test(upstreamMonitorWorkflow)) { + fail('Unraid compatibility monitor must evaluate Docker/API, OS/webGUI, and Community Applications contracts.'); } if (!/permissions:\s*\n\s*contents:\s*read/.test(upstreamMonitorWorkflow)) { - fail('Unraid Docker upstream monitor must keep repository contents read-only.'); + fail('Unraid compatibility monitor must keep repository contents read-only.'); } if (!/issues:\s*write/.test(upstreamMonitorWorkflow)) { - fail('Unraid Docker upstream monitor must be able to open a deduplicated compatibility alert.'); + fail('Unraid compatibility monitor must be able to open a deduplicated compatibility alert.'); +} +if (!/Close resolved compatibility review issues/.test(upstreamMonitorWorkflow) || !/gh issue close/.test(upstreamMonitorWorkflow)) { + fail('Unraid compatibility monitor must close its compatibility alert after a reviewed recovery.'); +} +if (!/Run isolated compatibility fixtures on upstream drift/.test(upstreamMonitorWorkflow) + || !/--lane tests --lane fixture-browser/.test(upstreamMonitorWorkflow) + || !/npx playwright install --with-deps chromium/.test(upstreamMonitorWorkflow)) { + fail('Upstream drift must run isolated contract and browser fixtures before review.'); +} +if (!/php-runtime-compatibility:/.test(upstreamMonitorWorkflow) + || !/php:8\.3\.8-cli-alpine/.test(upstreamMonitorWorkflow) + || !/php:8\.4\.23-cli-alpine/.test(upstreamMonitorWorkflow) + || !/php:8\.4\.24-cli-alpine/.test(upstreamMonitorWorkflow) + || !/scripts\/php_runtime_compatibility\.sh/.test(upstreamMonitorWorkflow)) { + fail('Unraid compatibility monitor must test the oldest, stable, and prerelease PHP runtime profiles in isolation.'); } -if (!/Close resolved compatibility alert/.test(upstreamMonitorWorkflow) || !/gh issue close/.test(upstreamMonitorWorkflow)) { - fail('Unraid Docker upstream monitor must close its compatibility alert after a reviewed recovery.'); +if (/FVPLUS_UNRAID_MATRIX|FVPLUS_BROWSER_SMOKE_URL|FVPLUS_THEME_MATRIX_URLS|live-unraid:|secrets\.[A-Za-z0-9_]*UNRAID/i.test(upstreamMonitorWorkflow)) { + fail('Unraid compatibility monitoring must not accept live-Unraid targets or secrets.'); +} +if (!/ca\.unraid\.net\/submit\/help\/repository-xml/.test(upstreamMonitorWorkflow) + || !/ca\.unraid\.net\/assets\/feed\/applicationFeed\.json/.test(upstreamMonitorWorkflow) + || !/unraid-community-apps-starter/.test(upstreamMonitorWorkflow)) { + fail('Community Applications validation must use the official portal guidance, public feed, and starter contract.'); } if (!/permissions:\s*\n\s*contents:\s*read/.test(scheduledValidationWorkflow) || /issues:\s*write/.test(scheduledValidationWorkflow)) { @@ -300,16 +345,23 @@ if (!/schedule:/.test(scheduledWorkflowHealthWorkflow) || !/Scheduled workflow health requires attention/.test(scheduledWorkflowHealthWorkflow)) { fail('Scheduled workflow health must check run freshness and maintain a deduplicated recovery alert.'); } +const scheduledWorkflowHealthScript = read('scripts/scheduled_workflow_health.mjs'); +for (const workflowFile of ['codeql.yml', 'scorecard.yml', 'dependency-vulnerability-scan.yml']) { + if (!scheduledWorkflowHealthScript.includes(`workflowFile: '${workflowFile}'`)) { + fail(`Scheduled workflow health must monitor ${workflowFile}.`); + } +} for (const [workflowName, workflow, jobNames] of [ ['release-on-main', releaseOnMainWorkflow, ['release']], ['backmerge-main-to-dev', backmergeWorkflow, ['backmerge']], ['codeql', codeqlWorkflow, ['analyze']], ['dependency-review', dependencyReviewWorkflow, ['dependency-review']], + ['dependency-vulnerability-scan', dependencyVulnerabilityScanWorkflow, ['scan']], ['scorecard', scorecardWorkflow, ['analysis']], ['clone-traffic-badge', cloneTrafficBadgeWorkflow, ['refresh']], ['scheduled-validation', scheduledValidationWorkflow, ['cross-browser-fixtures']], ['scheduled-workflow-health', scheduledWorkflowHealthWorkflow, ['watchdog']], - ['unraid-docker-upstream-monitor', upstreamMonitorWorkflow, ['monitor']] + ['unraid-compatibility-monitor', upstreamMonitorWorkflow, ['monitor', 'php-runtime-compatibility']] ]) { for (const jobName of jobNames) { if (!/timeout-minutes:\s*[1-9][0-9]*/.test(jobBlock(workflow, jobName))) { @@ -337,6 +389,7 @@ for (const workflowPath of [ '.github/workflows/release-on-main.yml', '.github/workflows/backmerge-main-to-dev.yml', '.github/workflows/clone-traffic-badge.yml', + '.github/workflows/dependency-vulnerability-scan.yml', '.github/workflows/scheduled-workflow-health.yml', '.github/workflows/unraid-docker-upstream-monitor.yml' ]) { diff --git a/tests/ci-change-classifier.test.mjs b/tests/ci-change-classifier.test.mjs index c40f9551..acfa7721 100644 --- a/tests/ci-change-classifier.test.mjs +++ b/tests/ci-change-classifier.test.mjs @@ -40,7 +40,7 @@ test('workflow-only changes use focused workflow validation', () => { test('workflow changes allow the generated SBOM as a focused validation companion', () => { const result = classifyPaths([ - '.github/workflows/codeql.yml', + '.github/workflows/dependency-vulnerability-scan.yml', 'docs/sbom.cdx.json' ]); assert.deepEqual(result.outputs, { @@ -52,6 +52,19 @@ test('workflow changes allow the generated SBOM as a focused validation companio }); }); +test('upstream compatibility monitor scripts and baseline use focused workflow validation', () => { + const result = classifyPaths([ + '.github/workflows/unraid-docker-upstream-monitor.yml', + 'scripts/unraid_compatibility_monitor.mjs', + 'scripts/community_applications_guard.mjs', + 'scripts/php_runtime_compatibility.sh', + 'docs/unraid-compatibility-baseline.json' + ]); + assert.equal(result.outputs.workflow_only, true); + assert.equal(result.outputs.needs_browser, false); + assert.equal(result.outputs.needs_theme, false); +}); + test('workflow changes mixed with ordinary documentation still use broad validation', () => { const result = classifyPaths([ '.github/workflows/codeql.yml', diff --git a/tests/community-applications-guard.test.mjs b/tests/community-applications-guard.test.mjs new file mode 100644 index 00000000..6e948ecf --- /dev/null +++ b/tests/community-applications-guard.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildCommunityApplicationsReport, + evaluateCommunityApplications, + findCatalogEntry, + parsePluginMetadata +} from '../scripts/community_applications_guard.mjs'; + +const metadata = ` + +True +https://raw.githubusercontent.com/example/plugin/main/plugin.plg +example +False +Tools:System +FolderView Plus +Organizes Docker, VM, and Dashboard views. +7.0.0 +folders docker vm +https://forums.example.test/support +https://raw.githubusercontent.com/example/plugin/main/icon.png +https://github.com/example/plugin +`; + +const feedEntry = { + Name: 'FolderView Plus', + PluginURL: 'https://raw.githubusercontent.com/example/plugin/main/plugin.plg', + Support: 'https://forums.example.test/support', + Project: 'https://github.com/example/plugin', + Icon: 'https://raw.githubusercontent.com/example/plugin/main/icon.png', + MinVer: '7.0.0', + Overview: 'Organizes Docker, VM, and Dashboard views.', + pluginVersion: '2026.08.21.01' +}; + +const evaluate = (overrides = {}) => evaluateCommunityApplications({ + metadataSource: metadata, + manifestSource: '', + catalogTemplateSource: metadata, + feedPayload: { apps: [feedEntry] }, + portalHelpSource: 'PluginURL Support Project Validate Scan', + starterPluginSource: 'xxxxx', + expectedBranch: 'main', + ...overrides +}); + +test('Community Applications guard validates metadata, catalog template, public feed, and portal contracts', () => { + const result = evaluate(); + assert.equal(result.status, 'matched'); + assert.equal(result.reason, 'canonical-published-listing-matches'); + assert.equal(parsePluginMetadata(metadata).MinVer, '7.0.0'); + assert.equal(findCatalogEntry({ nested: [feedEntry] }, 'FolderView Plus'), feedEntry); + assert.match(buildCommunityApplicationsReport(result), /Status: `matched`/); +}); + +test('Community Applications guard reports stale published versions without exposing feed internals', () => { + const result = evaluate({ feedPayload: { apps: [{ ...feedEntry, pluginVersion: '2026.08.20.01' }] } }); + assert.equal(result.status, 'review'); + assert.ok(result.reviewSignals.some((signal) => signal.reason === 'published-version-drift')); +}); + +test('Community Applications guard fails closed when official portal requirements are unavailable', () => { + const result = evaluate({ portalHelpSource: 'temporarily unavailable' }); + assert.equal(result.status, 'unknown'); + assert.ok(result.unknownSignals.some((signal) => signal.reason === 'portal-help-contract-unavailable')); +}); diff --git a/tests/php-runtime-compatibility.php b/tests/php-runtime-compatibility.php new file mode 100644 index 00000000..59496069 --- /dev/null +++ b/tests/php-runtime-compatibility.php @@ -0,0 +1,39 @@ + { const result = evaluateWorkflowRuns({ target, nowMs, - runs: [run({ createdAt: '2026-08-04T12:00:00Z' })] + runs: [run({ createdAt: '2026-08-08T12:01:00Z' })] }); assert.equal(result.healthy, true); assert.equal(result.reason, 'healthy'); assert.equal(result.latestSuccess.event, 'schedule'); }); -test('scheduled workflow health accepts a manual proof run until the weekly interval expires', () => { +test('scheduled workflow health accepts a manual proof run until the daily-monitor grace period expires', () => { const result = evaluateWorkflowRuns({ target, nowMs, @@ -39,6 +39,12 @@ test('scheduled workflow health accepts a manual proof run until the weekly inte assert.equal(result.latestSuccess.event, 'workflow_dispatch'); }); +test('scheduled workflow health identifies the daily Unraid compatibility monitor', () => { + assert.equal(target.workflowFile, 'unraid-docker-upstream-monitor.yml'); + assert.equal(target.label, 'Unraid Compatibility Monitor'); + assert.equal(target.maximumSuccessAgeHours, 72); +}); + test('scheduled workflow health monitors the daily clone badge with a bounded grace period', () => { const cloneBadgeTarget = SCHEDULED_WORKFLOW_TARGETS.find((candidate) => ( candidate.workflowFile === 'clone-traffic-badge.yml' @@ -63,6 +69,20 @@ test('scheduled workflow health monitors the daily clone badge with a bounded gr assert.equal(stale.reason, 'successful-run-stale'); }); +test('scheduled workflow health monitors weekly security workflows', () => { + const expected = new Map([ + ['codeql.yml', 'CodeQL'], + ['scorecard.yml', 'OpenSSF Scorecard'], + ['dependency-vulnerability-scan.yml', 'Dependency Vulnerability Scan'] + ]); + for (const [workflowFile, label] of expected) { + const securityTarget = SCHEDULED_WORKFLOW_TARGETS.find((candidate) => candidate.workflowFile === workflowFile); + assert.ok(securityTarget, `${workflowFile} must be monitored`); + assert.equal(securityTarget.label, label); + assert.equal(securityTarget.maximumSuccessAgeHours, 204); + } +}); + test('scheduled workflow health rejects missing, failed, and stale successes', () => { const missing = evaluateWorkflowRuns({ target, nowMs, runs: [] }); assert.equal(missing.healthy, false); diff --git a/tests/unraid-compatibility-monitor.test.mjs b/tests/unraid-compatibility-monitor.test.mjs new file mode 100644 index 00000000..00cc0bf4 --- /dev/null +++ b/tests/unraid-compatibility-monitor.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + buildCompatibilityReport, + evaluateCompatibility, + gitBlobSha, + scanReleaseNotes +} from '../scripts/unraid_compatibility_monitor.mjs'; + +const makeFixture = () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fvplus-unraid-monitor-')); + const releaseNotesDir = path.join(root, 'release-notes'); + const webguiDir = path.join(root, 'webgui'); + const caStarterDir = path.join(root, 'ca-starter'); + fs.mkdirSync(releaseNotesDir, { recursive: true }); + fs.mkdirSync(webguiDir, { recursive: true }); + fs.mkdirSync(caStarterDir, { recursive: true }); + + const stableSource = '# Version 7.3.2 2026-07-08\n\n* php: version 8.4.21 -> 8.4.23\n'; + const prereleaseSource = '# Version 7.4.0-beta.1 2026-08-14\n\n* php: version 8.4.23 -> 8.4.24\n'; + fs.writeFileSync(path.join(releaseNotesDir, '7.3.2.md'), stableSource); + fs.writeFileSync(path.join(releaseNotesDir, '7.4.0.md'), prereleaseSource); + fs.writeFileSync(path.join(webguiDir, 'DockerContainers.page'), 'docker host contract\n'); + fs.writeFileSync(path.join(caStarterDir, 'example-plugin.xml'), 'example\n'); + + const baseline = { + unraidOs: { + latestReviewedStable: { + version: '7.3.2', + releaseNoteBlobSha: gitBlobSha(Buffer.from(stableSource)), + phpVersion: '8.4.23' + }, + latestReviewedPrerelease: { + version: '7.4.0-beta.1', + releaseNoteBlobSha: gitBlobSha(Buffer.from(prereleaseSource)), + phpVersion: '8.4.24' + } + }, + webgui: { + files: { + 'DockerContainers.page': gitBlobSha(Buffer.from('docker host contract\n')) + } + }, + communityApplications: { + files: { + 'example-plugin.xml': gitBlobSha(Buffer.from('example\n')) + } + } + }; + const evaluate = () => evaluateCompatibility({ + baseline, + releaseNotesDir, + webguiDir, + caStarterDir, + dockerResult: { status: 'dormant', reason: 'upstream-shouldApply-false', latestApiRelease: 'v4.37.2', sourceGate: 'false' }, + caResult: { status: 'matched', reason: 'canonical-published-listing-matches' } + }); + return { root, releaseNotesDir, webguiDir, caStarterDir, baseline, evaluate }; +}; + +test('general compatibility monitor accepts reviewed stable, prerelease, webGUI, API, and CA contracts', (t) => { + const fixture = makeFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + const result = fixture.evaluate(); + assert.equal(result.status, 'matched'); + assert.equal(result.reason, 'all-reviewed-baselines-match'); + assert.equal(result.releases.stable.version, '7.3.2'); + assert.equal(result.releases.prerelease.version, '7.4.0-beta.1'); + assert.equal(result.releases.prerelease.phpVersion, '8.4.24'); + assert.match(buildCompatibilityReport(result), /All official upstream inputs match/); +}); + +test('general compatibility monitor detects new releases and relevant webGUI drift', (t) => { + const fixture = makeFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + fs.writeFileSync(path.join(fixture.releaseNotesDir, '7.3.3.md'), '# Version 7.3.3 2026-08-21\n\n* php: version 8.4.25\n'); + fs.writeFileSync(path.join(fixture.webguiDir, 'DockerContainers.page'), 'changed contract\n'); + const result = fixture.evaluate(); + assert.equal(result.status, 'review'); + assert.ok(result.reviewSignals.some((signal) => signal.reason === 'new-stable-release')); + assert.ok(result.reviewSignals.some((signal) => signal.reason === 'upstream-file-changed')); +}); + +test('general compatibility monitor fails closed when a tracked upstream file is unavailable', (t) => { + const fixture = makeFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + fs.rmSync(path.join(fixture.caStarterDir, 'example-plugin.xml')); + const result = fixture.evaluate(); + assert.equal(result.status, 'unknown'); + assert.equal(result.reason, 'upstream-file-missing'); +}); + +test('release scanner keeps stable and prerelease channels separate', (t) => { + const fixture = makeFixture(); + t.after(() => fs.rmSync(fixture.root, { recursive: true, force: true })); + fs.writeFileSync(path.join(fixture.releaseNotesDir, '7.5.0.md'), '# Version 7.5.0-rc.2 2026-09-01\n'); + const result = scanReleaseNotes(fixture.releaseNotesDir); + assert.equal(result.stable.version, '7.3.2'); + assert.equal(result.prerelease.version, '7.5.0-rc.2'); +}); + +test('reviewed webGUI baseline covers plugin installation and update contracts', () => { + const baseline = JSON.parse(fs.readFileSync(new URL('../docs/unraid-compatibility-baseline.json', import.meta.url), 'utf8')); + const files = Object.keys(baseline.webgui.files || {}); + const pluginManagerFiles = files.filter((file) => file.startsWith('emhttp/plugins/dynamix.plugin.manager/')); + assert.ok(pluginManagerFiles.length >= 15); + for (const required of [ + 'emhttp/plugins/dynamix.plugin.manager/PluginInstall.page', + 'emhttp/plugins/dynamix.plugin.manager/Plugins.page', + 'emhttp/plugins/dynamix.plugin.manager/include/Downgrade.php', + 'emhttp/plugins/dynamix.plugin.manager/pre-hooks/pre_plugin_checks', + 'emhttp/plugins/dynamix.plugin.manager/post-hooks/post_plugin_checks', + 'emhttp/plugins/dynamix.plugin.manager/scripts/PluginAPI.php', + 'emhttp/plugins/dynamix.plugin.manager/scripts/plugin', + 'emhttp/plugins/dynamix.plugin.manager/scripts/plugincheck' + ]) { + assert.match(baseline.webgui.files[required] || '', /^[0-9a-f]{40}$/, `${required} must have a reviewed Git blob SHA`); + } +}); diff --git a/tests/versioning-guard.test.mjs b/tests/versioning-guard.test.mjs index b1e4e8ee..2a15268a 100644 --- a/tests/versioning-guard.test.mjs +++ b/tests/versioning-guard.test.mjs @@ -16,6 +16,7 @@ const releaseMainWorkflowPath = path.join(repoRoot, '.github/workflows/release-m const releaseOnMainWorkflowPath = path.join(repoRoot, '.github/workflows/release-on-main.yml'); const scheduledValidationWorkflowPath = path.join(repoRoot, '.github/workflows/scheduled-validation.yml'); const scheduledWorkflowHealthPath = path.join(repoRoot, '.github/workflows/scheduled-workflow-health.yml'); +const dependencyVulnerabilityScanPath = path.join(repoRoot, '.github/workflows/dependency-vulnerability-scan.yml'); const setupCiEnvActionPath = path.join(repoRoot, '.github/actions/setup-ci-env/action.yml'); const browserSmokeShellPath = path.join(repoRoot, 'scripts/browser_smoke.sh'); const fixtureBrowserShellPath = path.join(repoRoot, 'scripts/fixture_browser_tests.sh'); @@ -89,6 +90,7 @@ const releaseNotesConsistencyGuard = fs.readFileSync(releaseNotesConsistencyGuar const runCiSuite = fs.readFileSync(runCiSuitePath, 'utf8'); const scheduledValidationWorkflow = fs.readFileSync(scheduledValidationWorkflowPath, 'utf8'); const scheduledWorkflowHealth = fs.readFileSync(scheduledWorkflowHealthPath, 'utf8'); +const dependencyVulnerabilityScan = fs.readFileSync(dependencyVulnerabilityScanPath, 'utf8'); const workflowSelfCheck = fs.readFileSync(path.join(repoRoot, 'scripts/workflow_self_check.sh'), 'utf8'); const syncMainToDev = fs.readFileSync(syncMainToDevPath, 'utf8'); const themeMatrixSmokeShell = fs.readFileSync(themeMatrixSmokeShellPath, 'utf8'); @@ -421,7 +423,7 @@ test('scheduled validation runs deterministic cross-browser fixtures without liv assert.doesNotMatch(scheduledValidationWorkflow, /gh issue/); }); -test('scheduled workflow watchdog alerts on missing weekly successes and closes recovered alerts', () => { +test('scheduled workflow watchdog alerts on missing expected successes and closes recovered alerts', () => { assert.match(scheduledWorkflowHealth, /schedule:/); assert.match(scheduledWorkflowHealth, /workflow_dispatch:/); assert.match(scheduledWorkflowHealth, /actions:\s*read/); @@ -431,6 +433,19 @@ test('scheduled workflow watchdog alerts on missing weekly successes and closes assert.match(scheduledWorkflowHealth, /gh issue close/); }); +test('scheduled dependency vulnerability scanning covers the generated SBOM', () => { + assert.match(dependencyVulnerabilityScan, /schedule:/); + assert.match(dependencyVulnerabilityScan, /workflow_dispatch:/); + assert.match(dependencyVulnerabilityScan, /permissions:\s*\n\s*contents:\s*read/); + assert.match(dependencyVulnerabilityScan, /google\/osv-scanner-action\/osv-scanner-action@[0-9a-f]{40}\s+# v2\.5\.0/); + assert.match(dependencyVulnerabilityScan, /google\/osv-scanner-action\/osv-reporter-action@[0-9a-f]{40}\s+# v2\.5\.0/); + assert.match(dependencyVulnerabilityScan, /--sbom=docs\/sbom\.cdx\.json/); + assert.match(dependencyVulnerabilityScan, /github\/codeql-action\/upload-sarif@[0-9a-f]{40}\s+# v4/); + assert.match(dependencyVulnerabilityScan, /--fail-on-vuln=true/); + assert.match(dependencyVulnerabilityScan, /Enforce clean vulnerability scan/); + assert.doesNotMatch(dependencyVulnerabilityScan, /secrets\.|live-unraid|FVPLUS_UNRAID/i); +}); + test('validation workflows delegate to the shared ci suite with dev coverage, fast lanes, caches, and release smoke enforcement', () => { assert.match(ciWorkflow, /push:\s*\n\s*branches:\s*\n\s*-\s*main\s*\n\s*-\s*dev\s*\n\s*-\s*reset-main/); assert.match(ciWorkflow, /detect-changes:/);