From fdf3e2bd16cc5a3e83456e38b1b5bcca5ab852cd Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:05:34 -0600 Subject: [PATCH 1/4] ci(build): consolidate pytest workflow steps --- .github/actions/run-pytest/action.yml | 51 +++++++++++++ .github/workflows/build.yml | 90 +++++++---------------- tests/unit/test_workflow_ci_efficiency.py | 40 ++++++++++ 3 files changed, 117 insertions(+), 64 deletions(-) create mode 100644 .github/actions/run-pytest/action.yml create mode 100644 tests/unit/test_workflow_ci_efficiency.py diff --git a/.github/actions/run-pytest/action.yml b/.github/actions/run-pytest/action.yml new file mode 100644 index 0000000..02d7f47 --- /dev/null +++ b/.github/actions/run-pytest/action.yml @@ -0,0 +1,51 @@ +name: Run pytest with Trunk upload +description: Run a pytest target with JUnit output and optional Trunk Flaky Tests upload. + +inputs: + junit-path: + description: Path to write the JUnit XML report. + required: true + pytest-args: + description: Arguments passed to pytest. + required: true + trunk-api-token: + description: Trunk API token. Leave empty to skip upload. + required: false + default: "" + trunk-org-slug: + description: Trunk organization URL slug. + required: true + +runs: + using: composite + steps: + - name: Run pytest + id: pytest + shell: bash + run: | + mkdir -p "$(dirname "${{ inputs.junit-path }}")" + rm -f "${{ inputs.junit-path }}" + set +e + python -m pytest \ + ${{ inputs.pytest-args }} \ + --junit-xml="${{ inputs.junit-path }}" \ + -o junit_family=xunit1 + status=$? + echo "exit_code=${status}" >> "${GITHUB_OUTPUT}" + exit 0 + + - name: Upload test results to Trunk + if: ${{ always() && inputs.trunk-api-token != '' }} + continue-on-error: true + uses: trunk-io/analytics-uploader@95a0fb8b29e45b6068304261fb518644b426a803 # v2.0.8 + env: + TRUNK_API_TOKEN: ${{ inputs.trunk-api-token }} + with: + junit-paths: ${{ inputs.junit-path }} + org-slug: ${{ inputs.trunk-org-slug }} + token: ${{ inputs.trunk-api-token }} + + - name: Fail if pytest failed + if: ${{ steps.pytest.outputs.exit_code != '0' }} + shell: bash + run: exit 1 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3340c5b..8eab48e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,7 @@ on: - scripts/** - tests/** - .trunk/** + - .github/actions/** - upstream.toml - sure-aio.xml - renovate.json @@ -29,6 +30,7 @@ on: - scripts/** - tests/** - .trunk/** + - .github/actions/** - upstream.toml - sure-aio.xml - renovate.json @@ -41,6 +43,7 @@ env: IMAGE_NAME: jsonbored/sure-aio PYTHON_VERSION: "3.13" TRUNK_ORG_URL_SLUG: aethereal + DOCKER_CACHE_SCOPE: sure-aio-image permissions: contents: read @@ -151,7 +154,7 @@ jobs: CHANGELOG.md|cliff.toml|scripts/*|.trunk/*) tooling_related=true ;; - .github/workflows/*) + .github/actions/*|.github/workflows/*) tooling_related=true workflow_related=true ;; @@ -216,12 +219,15 @@ jobs: import re import sys - workflow_dir = pathlib.Path(".github/workflows") + workflow_paths = [ + *pathlib.Path(".github/workflows").glob("*.yml"), + *pathlib.Path(".github/actions").glob("*/action.yml"), + ] pattern = re.compile(r"^\s*uses:\s*([^@\s]+)@([^\s#]+)") sha_pattern = re.compile(r"^[0-9a-f]{40}$") failures = [] - for path in sorted(workflow_dir.glob("*.yml")): + for path in sorted(workflow_paths): for lineno, line in enumerate(path.read_text().splitlines(), start=1): match = pattern.match(line) if not match: @@ -271,35 +277,13 @@ jobs: - name: Install pytest run: python -m pip install -r requirements-dev.txt - - name: Run pytest unit tests - id: unit_pytest - run: | - mkdir -p reports - rm -f reports/pytest-unit.xml - set +e - python -m pytest \ - tests/unit \ - tests/template \ - --junit-xml=reports/pytest-unit.xml \ - -o junit_family=xunit1 - status=$? - echo "exit_code=${status}" >> "${GITHUB_OUTPUT}" - exit 0 - - - name: Upload unit test results to Trunk - if: ${{ always() && env.TRUNK_API_TOKEN != '' }} - continue-on-error: true - uses: trunk-io/analytics-uploader@95a0fb8b29e45b6068304261fb518644b426a803 # v2.0.8 - env: - TRUNK_API_TOKEN: ${{ secrets.TRUNK_API_TOKEN }} + - name: Run unit and template tests + uses: ./.github/actions/run-pytest with: - junit-paths: reports/pytest-unit.xml - org-slug: ${{ env.TRUNK_ORG_URL_SLUG }} - token: ${{ env.TRUNK_API_TOKEN }} - - - name: Fail if unit tests failed - if: ${{ steps.unit_pytest.outputs.exit_code != '0' }} - run: exit 1 + junit-path: reports/pytest-unit.xml + pytest-args: tests/unit tests/template + trunk-api-token: ${{ env.TRUNK_API_TOKEN }} + trunk-org-slug: ${{ env.TRUNK_ORG_URL_SLUG }} integration-tests: if: ${{ needs.detect-changes.outputs.run_tests_requested == 'true' && (needs.detect-changes.outputs.build_related == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-changes.outputs.publish_requested == 'true')) }} @@ -330,51 +314,29 @@ jobs: platforms: linux/amd64 load: true tags: sure-aio:pytest - cache-from: type=gha,scope=sure-aio-pytest - cache-to: type=gha,mode=max,scope=sure-aio-pytest + cache-from: type=gha,scope=${{ env.DOCKER_CACHE_SCOPE }} + cache-to: type=gha,mode=max,scope=${{ env.DOCKER_CACHE_SCOPE }} - name: Install pytest run: python -m pip install -r requirements-dev.txt - - name: Run pytest integration tests - id: integration_pytest + - name: Run integration tests env: AIO_PYTEST_USE_PREBUILT_IMAGE: "true" - run: | - mkdir -p reports - rm -f reports/pytest-integration.xml - set +e - python -m pytest \ - tests/integration \ - -m integration \ - --junit-xml=reports/pytest-integration.xml \ - -o junit_family=xunit1 - status=$? - echo "exit_code=${status}" >> "${GITHUB_OUTPUT}" - exit 0 - - - name: Upload integration test results to Trunk - if: ${{ always() && env.TRUNK_API_TOKEN != '' }} - continue-on-error: true - uses: trunk-io/analytics-uploader@95a0fb8b29e45b6068304261fb518644b426a803 # v2.0.8 - env: - TRUNK_API_TOKEN: ${{ secrets.TRUNK_API_TOKEN }} + uses: ./.github/actions/run-pytest with: - junit-paths: reports/pytest-integration.xml - org-slug: ${{ env.TRUNK_ORG_URL_SLUG }} - token: ${{ env.TRUNK_API_TOKEN }} + junit-path: reports/pytest-integration.xml + pytest-args: tests/integration -m integration + trunk-api-token: ${{ env.TRUNK_API_TOKEN }} + trunk-org-slug: ${{ env.TRUNK_ORG_URL_SLUG }} - name: Dump Docker diagnostics - if: ${{ failure() || (steps.integration_pytest.conclusion == 'success' && steps.integration_pytest.outputs.exit_code != '0') }} + if: ${{ failure() }} run: | docker ps -a echo docker images - - name: Fail if integration tests failed - if: ${{ steps.integration_pytest.outputs.exit_code != '0' }} - run: exit 1 - publish: if: ${{ (needs.detect-changes.outputs.build_related == 'true' || needs.detect-changes.outputs.xml_related == 'true') && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.detect-changes.outputs.publish_requested == 'true' && needs.integration-tests.result == 'success' }} needs: @@ -506,8 +468,8 @@ jobs: context: . platforms: linux/amd64,linux/arm64 push: true - cache-from: type=gha,scope=sure-aio-publish - cache-to: type=gha,mode=max,scope=sure-aio-publish + cache-from: type=gha,scope=${{ env.DOCKER_CACHE_SCOPE }} + cache-to: type=gha,mode=max,scope=${{ env.DOCKER_CACHE_SCOPE }} tags: ${{ steps.prep.outputs.tags }} labels: | org.opencontainers.image.source=https://github.com/JSONbored/sure-aio diff --git a/tests/unit/test_workflow_ci_efficiency.py b/tests/unit/test_workflow_ci_efficiency.py new file mode 100644 index 0000000..d6e08ba --- /dev/null +++ b/tests/unit/test_workflow_ci_efficiency.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path + +BUILD_WORKFLOW = Path(".github/workflows/build.yml") +PYTEST_ACTION = Path(".github/actions/run-pytest/action.yml") + + +def test_pytest_jobs_use_shared_local_action() -> None: + workflow = BUILD_WORKFLOW.read_text() + + assert workflow.count("uses: ./.github/actions/run-pytest") == 2 # nosec B101 + assert "Upload unit test results to Trunk" not in workflow # nosec B101 + assert "Upload integration test results to Trunk" not in workflow # nosec B101 + assert "trunk-io/analytics-uploader@" in PYTEST_ACTION.read_text() # nosec B101 + + +def test_integration_and_publish_share_docker_cache_scope() -> None: + workflow = BUILD_WORKFLOW.read_text() + + assert "DOCKER_CACHE_SCOPE: sure-aio-image" in workflow # nosec B101 + assert ( # nosec B101 + workflow.count("cache-from: type=gha,scope=${{ env.DOCKER_CACHE_SCOPE }}") == 2 + ) + assert ( # nosec B101 + workflow.count( + "cache-to: type=gha,mode=max,scope=${{ env.DOCKER_CACHE_SCOPE }}" + ) + == 2 + ) + + +def test_local_actions_participate_in_ci_change_detection_and_pin_checks() -> None: + workflow = BUILD_WORKFLOW.read_text() + + assert "- .github/actions/**" in workflow # nosec B101 + assert ".github/actions/*|.github/workflows/*)" in workflow # nosec B101 + assert ( + 'pathlib.Path(".github/actions").glob("*/action.yml")' in workflow + ) # nosec B101 From aaa773ba7a4228ea972fe01106c67682b548b83d Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:50:36 -0600 Subject: [PATCH 2/4] test(ci): cover action and container contracts --- .github/workflows/build.yml | 2 +- Dockerfile | 4 +- tests/template/test_container_contract.py | 165 ++++++++++++++++++++++ tests/unit/test_workflow_ci_efficiency.py | 2 +- 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 tests/template/test_container_contract.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8eab48e..4981e2b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -154,7 +154,7 @@ jobs: CHANGELOG.md|cliff.toml|scripts/*|.trunk/*) tooling_related=true ;; - .github/actions/*|.github/workflows/*) + .github/actions/*/action.yml|.github/workflows/*) tooling_related=true workflow_related=true ;; diff --git a/Dockerfile b/Dockerfile index 3e164a8..5e1eb40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -81,10 +81,12 @@ RUN find /etc/s6-overlay/s6-rc.d -type f \( -name "run" -o -name "up" \) -exec c # 4. Expose the App Storage VOLUME ["/rails/storage", "/var/lib/postgresql/data", "/var/lib/redis"] +EXPOSE 3000 + ENV SKYLIGHT_ENABLED=false ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=300000 HEALTHCHECK --interval=30s --timeout=10s --start-period=180s --retries=3 \ - CMD curl -f http://localhost:3000/up || exit 1 + CMD curl -fsS http://localhost:3000/up >/dev/null || exit 1 ENTRYPOINT ["/init"] diff --git a/tests/template/test_container_contract.py b/tests/template/test_container_contract.py new file mode 100644 index 0000000..b8a3ee9 --- /dev/null +++ b/tests/template/test_container_contract.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +from defusedxml import ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] +DOCKERFILE = ROOT / "Dockerfile" + +SECRET_KEYWORDS = ( + "ACCESS_KEY", + "API_KEY", + "AUTH_TOKEN", + "CLIENT_SECRET", + "PASSWORD", + "PRIVATE_KEY", + "SECRET", + "TOKEN", +) + + +def _template_path() -> Path: + candidates = sorted(ROOT.glob("*.xml")) + assert candidates, "repository must include an Unraid XML template" # nosec B101 + return candidates[0] + + +def _template_root() -> ET.Element: + return ET.parse(_template_path()).getroot() + + +def _dockerfile_text() -> str: + return DOCKERFILE.read_text() + + +def _dockerfile_volumes() -> set[str]: + volumes: set[str] = set() + for match in re.finditer(r"(?m)^VOLUME\s+(\[[^\]]+\])", _dockerfile_text()): + volumes.update(json.loads(match.group(1))) + return volumes + + +def _exposed_ports() -> set[str]: + ports: set[str] = set() + for line in _dockerfile_text().splitlines(): + if not line.startswith("EXPOSE "): + continue + for token in line.split()[1:]: + ports.add(token.split("/", 1)[0]) + return ports + + +def _arg_defaults() -> dict[str, str]: + defaults: dict[str, str] = {} + for line in _dockerfile_text().splitlines(): + if not line.startswith("ARG ") or "=" not in line: + continue + name, value = line.removeprefix("ARG ").split("=", 1) + defaults[name] = value + return defaults + + +def _config_elements() -> list[ET.Element]: + return list(_template_root().findall("Config")) + + +def test_unraid_metadata_contract_is_complete_and_unprivileged() -> None: + root = _template_root() + + assert root.findtext("Privileged") == "false" # nosec B101 + for tag in ( + "Name", + "Repository", + "Support", + "Project", + "TemplateURL", + "Icon", + "WebUI", + ): + value = root.findtext(tag) + assert value and value.strip(), f"{tag} must be populated" # nosec B101 + assert ( + _config_elements() + ), "template must expose configurable settings" # nosec B101 + + +def test_secret_like_template_variables_are_masked() -> None: + for config in _config_elements(): + name = config.get("Name") or "" + target = config.get("Target") or "" + default = config.get("Default") or "" + if ( + target.endswith("_PATH") + or target.endswith("_ENABLED") + or target.startswith(("MAX_", "MIN_")) + or name.upper().endswith(" PATH") + or set(default.split("|")) == {"false", "true"} + ): + continue + haystack = " ".join(filter(None, (name, target))).upper() + if any(keyword in haystack for keyword in SECRET_KEYWORDS): + assert ( + config.get("Mask") == "true" + ), ( # nosec B101 + f"{config.get('Name') or config.get('Target')} should be masked" + ) + + +def test_required_appdata_paths_are_declared_as_container_volumes() -> None: + volumes = _dockerfile_volumes() + assert volumes, "Dockerfile must declare persistent volumes" # nosec B101 + + for config in _config_elements(): + if config.get("Type") != "Path" or config.get("Required") != "true": + continue + default = config.get("Default") or config.text or "" + target = config.get("Target") or "" + if not default.startswith("/mnt/user/appdata"): + continue + assert any( + target == volume or target.startswith(f"{volume.rstrip('/')}/") + for volume in volumes + ), f"{target} must be covered by a Dockerfile VOLUME" # nosec B101 + + +def test_template_ports_are_exposed_by_image() -> None: + exposed_ports = _exposed_ports() + assert exposed_ports, "Dockerfile must expose template ports" # nosec B101 + + for config in _config_elements(): + if config.get("Type") == "Port": + assert config.get("Target") in exposed_ports # nosec B101 + + +def test_dockerfile_has_runtime_safety_contract() -> None: + dockerfile = _dockerfile_text() + arg_defaults = _arg_defaults() + from_lines = [ + line.split()[1] for line in dockerfile.splitlines() if line.startswith("FROM ") + ] + + assert from_lines, "Dockerfile must declare at least one base image" # nosec B101 + for image in from_lines: + digest_arg = re.search(r"@\$\{([^}]+)\}", image) + assert "@sha256:" in image or ( # nosec B101 + digest_arg + and arg_defaults.get(digest_arg.group(1), "").startswith("sha256:") + ), f"{image} must be digest-pinned" + + assert "HEALTHCHECK" in dockerfile # nosec B101 + assert "curl -fsS" in dockerfile # nosec B101 + assert 'ENTRYPOINT ["/init"]' in dockerfile # nosec B101 + assert "S6_CMD_WAIT_FOR_SERVICES_MAXTIME" in dockerfile # nosec B101 + + +def test_docker_socket_mount_is_advanced_and_documented_when_present() -> None: + for config in _config_elements(): + if config.get("Target") != "/var/run/docker.sock": + continue + description = (config.get("Description") or "").lower() + assert config.get("Display") == "advanced" # nosec B101 + assert config.get("Required") == "false" # nosec B101 + assert "socket" in description and "security" in description # nosec B101 diff --git a/tests/unit/test_workflow_ci_efficiency.py b/tests/unit/test_workflow_ci_efficiency.py index d6e08ba..958f109 100644 --- a/tests/unit/test_workflow_ci_efficiency.py +++ b/tests/unit/test_workflow_ci_efficiency.py @@ -34,7 +34,7 @@ def test_local_actions_participate_in_ci_change_detection_and_pin_checks() -> No workflow = BUILD_WORKFLOW.read_text() assert "- .github/actions/**" in workflow # nosec B101 - assert ".github/actions/*|.github/workflows/*)" in workflow # nosec B101 + assert ".github/actions/*/action.yml|.github/workflows/*)" in workflow # nosec B101 assert ( 'pathlib.Path(".github/actions").glob("*/action.yml")' in workflow ) # nosec B101 From 253a344a68d8bee6799b8c2007a9108d73f72ee6 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:02:19 -0600 Subject: [PATCH 3/4] fix(ci): classify local action changes --- .github/workflows/build.yml | 2 +- tests/unit/test_workflow_ci_efficiency.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4981e2b..7109a9a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -154,7 +154,7 @@ jobs: CHANGELOG.md|cliff.toml|scripts/*|.trunk/*) tooling_related=true ;; - .github/actions/*/action.yml|.github/workflows/*) + .github/actions/**|.github/workflows/*) tooling_related=true workflow_related=true ;; diff --git a/tests/unit/test_workflow_ci_efficiency.py b/tests/unit/test_workflow_ci_efficiency.py index 958f109..af62667 100644 --- a/tests/unit/test_workflow_ci_efficiency.py +++ b/tests/unit/test_workflow_ci_efficiency.py @@ -34,7 +34,7 @@ def test_local_actions_participate_in_ci_change_detection_and_pin_checks() -> No workflow = BUILD_WORKFLOW.read_text() assert "- .github/actions/**" in workflow # nosec B101 - assert ".github/actions/*/action.yml|.github/workflows/*)" in workflow # nosec B101 + assert ".github/actions/**|.github/workflows/*)" in workflow # nosec B101 assert ( 'pathlib.Path(".github/actions").glob("*/action.yml")' in workflow ) # nosec B101 From 7576a0288eddf58dc8d637325a011408a3e1c620 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:28:49 -0600 Subject: [PATCH 4/4] fix(template): enforce init fail-fast and category metadata --- Dockerfile | 1 + scripts/validate-template.py | 4 ++-- sure-aio.xml | 2 +- tests/template/test_container_contract.py | 2 ++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5e1eb40..b3d015e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -85,6 +85,7 @@ EXPOSE 3000 ENV SKYLIGHT_ENABLED=false ENV S6_CMD_WAIT_FOR_SERVICES_MAXTIME=300000 +ENV S6_BEHAVIOUR_IF_STAGE2_FAILS=2 HEALTHCHECK --interval=30s --timeout=10s --start-period=180s --retries=3 \ CMD curl -fsS http://localhost:3000/up >/dev/null || exit 1 diff --git a/scripts/validate-template.py b/scripts/validate-template.py index e66db55..6ac557d 100644 --- a/scripts/validate-template.py +++ b/scripts/validate-template.py @@ -176,8 +176,8 @@ "Full changelog and release notes:", ) ALLOWED_CATEGORY_TOKENS = { - "Productivity", - "Tools-Utilities", + "Productivity:", + "Tools:Utilities", } diff --git a/sure-aio.xml b/sure-aio.xml index 81b730e..fd46477 100644 --- a/sure-aio.xml +++ b/sure-aio.xml @@ -45,7 +45,7 @@ - Always publish on manual main workflow dispatch - Let manual publish proceed when smoke test is skipped - Trigger package publish for aio tag alignment - Productivity Tools-Utilities + Productivity: Tools:Utilities http://[IP]:[PORT:3000] https://raw.githubusercontent.com/JSONbored/awesome-unraid/main/sure-aio.xml https://raw.githubusercontent.com/JSONbored/awesome-unraid/main/icons/sure.png diff --git a/tests/template/test_container_contract.py b/tests/template/test_container_contract.py index b8a3ee9..5111762 100644 --- a/tests/template/test_container_contract.py +++ b/tests/template/test_container_contract.py @@ -77,6 +77,7 @@ def test_unraid_metadata_contract_is_complete_and_unprivileged() -> None: "Project", "TemplateURL", "Icon", + "Category", "WebUI", ): value = root.findtext(tag) @@ -153,6 +154,7 @@ def test_dockerfile_has_runtime_safety_contract() -> None: assert "curl -fsS" in dockerfile # nosec B101 assert 'ENTRYPOINT ["/init"]' in dockerfile # nosec B101 assert "S6_CMD_WAIT_FOR_SERVICES_MAXTIME" in dockerfile # nosec B101 + assert "S6_BEHAVIOUR_IF_STAGE2_FAILS=2" in dockerfile # nosec B101 def test_docker_socket_mount_is_advanced_and_documented_when_present() -> None: