From af7416c0b669ac7a1db13e9f3422b90e877de45f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:55:54 -0300 Subject: [PATCH 1/9] fix(security): require PHP patched for critical RCEs Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/app/Dockerfile | 5 +++++ tests/app-image.bats | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/.docker/app/Dockerfile b/.docker/app/Dockerfile index b4a4a06..b4f3bf0 100644 --- a/.docker/app/Dockerfile +++ b/.docker/app/Dockerfile @@ -24,6 +24,11 @@ RUN set -eux; \ FROM ${NEXTCLOUD_SOURCE} AS runtime +# Stop builds before publication when upstream PHP still lacks the October 2026 RCE fixes. +# CVE-2026-109618 / CVE-2026-109619; unsupported PHP branches fail closed. +RUN php -r '$minimum = ["8.2" => "8.2.35", "8.3" => "8.3.36", "8.4" => "8.4.27", "8.5" => "8.5.12"]; $series = PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION; if (!isset($minimum[$series]) || version_compare(PHP_VERSION, $minimum[$series], "<")) { fwrite(STDERR, "Unpatched or unsupported PHP runtime: " . PHP_VERSION . PHP_EOL); exit(1); }' + + RUN apt-get update \ && apt-get upgrade -y \ && apt-get install -y --no-install-recommends \ diff --git a/tests/app-image.bats b/tests/app-image.bats index 6ba75d2..75926e2 100644 --- a/tests/app-image.bats +++ b/tests/app-image.bats @@ -137,6 +137,13 @@ fpm_ready() { grep -Eq '"installed"[[:space:]]*:[[:space:]]*true' <<<"$response" } +@test "app image uses a patched PHP runtime" { + run docker run --rm --network none --platform "$IMAGE_PLATFORM" \ + --entrypoint php "$APP_IMAGE" -r \ + '$minimum = ["8.2" => "8.2.35", "8.3" => "8.3.36", "8.4" => "8.4.27", "8.5" => "8.5.12"]; $series = PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION; exit(isset($minimum[$series]) && version_compare(PHP_VERSION, $minimum[$series], ">=") ? 0 : 1);' + [ "$status" -eq 0 ] +} + @test "app image installs Nextcloud and serves it through FPM" { echo "Testing $APP_IMAGE ($IMAGE_PLATFORM)" From 17cb25af98dc416a0ef77c8dcba68bde226e7ce5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:04:34 -0300 Subject: [PATCH 2/9] fix(ci): pull patched Nextcloud base images before validation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/actions/build-and-scan/action.yml | 2 ++ .github/workflows/nextcloud-development.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index 9128b2b..7bfdd52 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -37,6 +37,7 @@ runs: load: true build-args: | NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} + pull: true tags: scan/app:amd64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-amd64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-amd64 @@ -50,6 +51,7 @@ runs: load: true build-args: | NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} + pull: true tags: scan/app:arm64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-arm64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-arm64 diff --git a/.github/workflows/nextcloud-development.yml b/.github/workflows/nextcloud-development.yml index a97ac8c..66bc7fb 100644 --- a/.github/workflows/nextcloud-development.yml +++ b/.github/workflows/nextcloud-development.yml @@ -128,6 +128,7 @@ jobs: coop.librecode.nextcloud.source.sha512=${{ steps.upstream.outputs.source_sha512 }} coop.librecode.nextcloud.base.digest=${{ steps.upstream.outputs.base_digest }} coop.librecode.runtime.variant=fpm + pull: true tags: scan/master:${{ matrix.arch }} cache-from: type=gha,scope=master-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=master-${{ matrix.arch }} From eca3dda9b45732c48619638ad8483a7da93af601 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:32:27 -0300 Subject: [PATCH 3/9] refactor(security): validate PHP from upstream releases, not static CVE ranges Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/app/Dockerfile | 5 - .github/workflows/nextcloud-development.yml | 6 ++ Makefile | 1 + docs/maintenance.md | 25 +++++ scripts/check-php-runtime.py | 112 ++++++++++++++++++++ scripts/scan-images.sh | 8 ++ tests/app-image.bats | 7 -- tests/test-scan-images.sh | 39 +++++++ tests/test_php_runtime.py | 99 +++++++++++++++++ 9 files changed, 290 insertions(+), 12 deletions(-) create mode 100755 scripts/check-php-runtime.py create mode 100644 tests/test_php_runtime.py diff --git a/.docker/app/Dockerfile b/.docker/app/Dockerfile index b4f3bf0..b4a4a06 100644 --- a/.docker/app/Dockerfile +++ b/.docker/app/Dockerfile @@ -24,11 +24,6 @@ RUN set -eux; \ FROM ${NEXTCLOUD_SOURCE} AS runtime -# Stop builds before publication when upstream PHP still lacks the October 2026 RCE fixes. -# CVE-2026-109618 / CVE-2026-109619; unsupported PHP branches fail closed. -RUN php -r '$minimum = ["8.2" => "8.2.35", "8.3" => "8.3.36", "8.4" => "8.4.27", "8.5" => "8.5.12"]; $series = PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION; if (!isset($minimum[$series]) || version_compare(PHP_VERSION, $minimum[$series], "<")) { fwrite(STDERR, "Unpatched or unsupported PHP runtime: " . PHP_VERSION . PHP_EOL); exit(1); }' - - RUN apt-get update \ && apt-get upgrade -y \ && apt-get install -y --no-install-recommends \ diff --git a/.github/workflows/nextcloud-development.yml b/.github/workflows/nextcloud-development.yml index 66bc7fb..6b7a022 100644 --- a/.github/workflows/nextcloud-development.yml +++ b/.github/workflows/nextcloud-development.yml @@ -8,6 +8,8 @@ on: - '.docker/app/**' - 'tests/app-image.bats' - 'scripts/scan-images.sh' + - 'scripts/check-php-runtime.py' + - 'tests/test_php_runtime.py' - 'trivy.yaml' - '.github/workflows/nextcloud-development.yml' - 'scripts/resolve-nextcloud-upstream.sh' @@ -21,6 +23,8 @@ on: - '.docker/app/**' - 'tests/app-image.bats' - 'scripts/scan-images.sh' + - 'scripts/check-php-runtime.py' + - 'tests/test_php_runtime.py' - 'trivy.yaml' - '.github/workflows/nextcloud-development.yml' - 'scripts/resolve-nextcloud-upstream.sh' @@ -64,6 +68,8 @@ jobs: - name: Test development image scripts run: bats tests/nextcloud-development-image.bats + - name: Test upstream PHP runtime security policy + run: python3 -m unittest discover -s tests -p 'test_php_runtime.py' validate: needs: diff --git a/Makefile b/Makefile index 8f73fa7..ca4b787 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ test-scan-images: test-ci-scripts: bats tests/nextcloud-versions.bats tests/publish-images.bats + python3 -m unittest discover -s tests -p 'test_php_runtime.py' test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/docs/maintenance.md b/docs/maintenance.md index 550cad6..6c83901 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -94,3 +94,28 @@ Keep tag naming consistent with the official Nextcloud model. Do not introduce ` Dependabot manages Docker and GitHub Actions dependencies. Renovate is limited to regex-managed values Dependabot cannot discover (currently the explicitly pinned Trivy binary version), so the two bots do not manage the same dependency. The remaining implementation constraints tracked under #47 are to improve metadata when upstream identifiers become available, keep the stable and master publishing contracts aligned, preserve runtime and security gates, and maintain deployment compatibility until replacement approaches are validated. + +## PHP runtime security policy + +Image builds use complementary controls: Trivy scans OS and application +dependencies, while the PHP runtime policy checks the installed interpreter +against the PHP project's live JSON release feed: +https://www.php.net/releases/branches.php + +The image checker runs centrally via scripts/scan-images.sh for both linux/amd64 +and linux/arm64 application images. It rejects unsupported branches, PHP versions +older than the latest upstream patch, and upstream network failures. This also +covers the case where an official Nextcloud base contains a PHP binary built +outside Debian's package manager and therefore missed by package CVE scanners. + +This policy is intentionally more conservative than a CVE-only feed: even routine +PHP patch releases can block image publishing until the official Nextcloud parent +images catch up. It is designed for upstream-derived PHP images, not vendor +distro packages with independently attested security backports. Bats remains +responsible for functional acceptance of the Nextcloud image. + +Run offline policy tests with: + python3 -m unittest discover -s tests -p 'test_php_runtime.py' + +Run on a built image with: + scripts/check-php-runtime.py --image scan/app:amd64 --platform linux/amd64 diff --git a/scripts/check-php-runtime.py b/scripts/check-php-runtime.py new file mode 100755 index 0000000..446596d --- /dev/null +++ b/scripts/check-php-runtime.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Reject out-of-date or EOL official PHP runtimes before image publication. + +The upstream PHP release feed owns the version policy. This script does not +maintain CVE-specific version ranges and is intended for upstream-derived images. +""" + +import argparse +import json +import re +import subprocess +import sys +import urllib.request + +RELEASES_URL = "https://www.php.net/releases/branches.php" +VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") +SUPPORTED_STATES = frozenset(("stable", "security")) + + +class SecurityPolicyError(Exception): + pass + + +def parse_version(value): + if not isinstance(value, str) or not VERSION_RE.fullmatch(value): + raise SecurityPolicyError(f"Invalid stable PHP version: {value!r}") + return tuple(int(part) for part in value.split(".")) + + +def check_release(version, releases): + current = parse_version(version) + if not isinstance(releases, list) or not releases: + raise SecurityPolicyError("PHP release feed is empty or invalid") + + branch_name = f"{current[0]}.{current[1]}" + matching = [r for r in releases if isinstance(r, dict) and r.get("branch") == branch_name] + if len(matching) != 1: + raise SecurityPolicyError(f"PHP {branch_name} is missing or duplicated in the upstream release feed") + + record = matching[0] + if record.get("state") not in SUPPORTED_STATES: + raise SecurityPolicyError(f"PHP {branch_name} has no upstream security support (state: {record.get('state')!r})") + + latest = parse_version(record.get("latest")) + if latest[:2] != current[:2]: + raise SecurityPolicyError(f"Inconsistent PHP release feed for {branch_name}: {record.get('latest')!r}") + + if current < latest: + raise SecurityPolicyError( + f"PHP {version} is older than the upstream {branch_name} release {record['latest']}" + ) + return f"PHP {version}: supported branch and current upstream patch release" + + +def fetch_releases(): + req = urllib.request.Request( + RELEASES_URL, + headers={"Accept": "application/json", "User-Agent": "LibreCode-PHP-Runtime-Check/1"}, + ) + try: + with urllib.request.urlopen(req, timeout=15) as response: + return json.load(response) + except (OSError, ValueError) as exc: + raise SecurityPolicyError(f"Cannot read upstream PHP release feed: {exc}") from exc + + +def container_php_version(image, platform): + if platform not in ("linux/amd64", "linux/arm64"): + raise SecurityPolicyError(f"Unsupported image platform: {platform!r}") + if not image or image.startswith("-"): + raise SecurityPolicyError("An existing, fully named Docker image is required") + command = [ + "docker", "run", "--rm", "--network", "none", "--read-only", + "--platform", platform, "--entrypoint", "php", image, "-v", + ] + try: + result = subprocess.run( + command, capture_output=True, text=True, timeout=45, check=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise SecurityPolicyError(f"Cannot determine PHP version in {image}: {exc}") from exc + for line in result.stdout.splitlines(): + match = re.match(r"^PHP ([0-9]+\.[0-9]+\.[0-9]+)(?:\s|$)", line.strip()) + if match: + return match.group(1) + raise SecurityPolicyError(f"Cannot parse PHP version from {image}: {result.stdout[:120]!r}") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--image", help="Locally built Docker image") + source.add_argument("--version", help="PHP version (for tests and diagnostics)") + parser.add_argument("--platform", default="linux/amd64", choices=("linux/amd64", "linux/arm64")) + parser.add_argument("--releases-file", help="Use a local PHP release feed (offline tests)") + args = parser.parse_args(argv) + try: + version = args.version if args.version is not None else container_php_version(args.image, args.platform) + if args.releases_file: + with open(args.releases_file, encoding="utf-8") as handle: + releases = json.load(handle) + else: + releases = fetch_releases() + print(check_release(version, releases)) + return 0 + except (SecurityPolicyError, OSError, ValueError) as exc: + print(f"PHP runtime security check failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/scan-images.sh b/scripts/scan-images.sh index b115716..e986d7a 100644 --- a/scripts/scan-images.sh +++ b/scripts/scan-images.sh @@ -18,6 +18,7 @@ fi script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd -- "${script_dir}/.." && pwd)" +php_runtime_checker="${PHP_RUNTIME_CHECK_BIN:-${repo_root}/scripts/check-php-runtime.py}" config="${repo_root}/trivy.yaml" report_dir="${TRIVY_REPORT_DIR:-${repo_root}/trivy-results}" @@ -52,6 +53,13 @@ for image_spec in "$@"; do exit 2 fi + if [[ "$label" == app ]]; then + printf '\nChecking upstream PHP runtime security: %s (%s)\n' "$platform" "$image" + if ! "$php_runtime_checker" --image "$image" --platform "$platform"; then + scan_status=1 + fi + fi + printf '\nScanning %s for %s (%s), table output\n' "$label" "$platform" "$image" if ! "$trivy_bin" image --config "$trivy_config" --platform "$platform" --format table "$image"; then scan_status=1 diff --git a/tests/app-image.bats b/tests/app-image.bats index 75926e2..6ba75d2 100644 --- a/tests/app-image.bats +++ b/tests/app-image.bats @@ -137,13 +137,6 @@ fpm_ready() { grep -Eq '"installed"[[:space:]]*:[[:space:]]*true' <<<"$response" } -@test "app image uses a patched PHP runtime" { - run docker run --rm --network none --platform "$IMAGE_PLATFORM" \ - --entrypoint php "$APP_IMAGE" -r \ - '$minimum = ["8.2" => "8.2.35", "8.3" => "8.3.36", "8.4" => "8.4.27", "8.5" => "8.5.12"]; $series = PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION; exit(isset($minimum[$series]) && version_compare(PHP_VERSION, $minimum[$series], ">=") ? 0 : 1);' - [ "$status" -eq 0 ] -} - @test "app image installs Nextcloud and serves it through FPM" { echo "Testing $APP_IMAGE ($IMAGE_PLATFORM)" diff --git a/tests/test-scan-images.sh b/tests/test-scan-images.sh index f551dfe..1d9c1bb 100644 --- a/tests/test-scan-images.sh +++ b/tests/test-scan-images.sh @@ -56,6 +56,28 @@ fi EOF chmod +x "$bin_dir/trivy" +cat > "$bin_dir/php-runtime-check" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +image='' +platform='' +while (( $# )); do + case "$1" in + --image) image=$2; shift 2 ;; + --platform) platform=$2; shift 2 ;; + *) printf 'Unexpected PHP checker argument: %s\n' "$1" >&2; exit 2 ;; + esac +done +printf '%s|%s\n' "$platform" "$image" >> "$PHP_RUNTIME_TEST_LOG" +if [[ -n "${PHP_RUNTIME_FAIL_IMAGE:-}" && "$image" == "$PHP_RUNTIME_FAIL_IMAGE" ]]; then + exit 1 +fi +EOF +chmod +x "$bin_dir/php-runtime-check" + +export PHP_RUNTIME_CHECK_BIN="$bin_dir/php-runtime-check" +export PHP_RUNTIME_TEST_LOG="$tmp_root/php-runtime.log" + export TRIVY_BIN="$bin_dir/trivy" export TRIVY_TEST_LOG="$log_file" export TRIVY_REPORT_DIR="$report_dir" @@ -66,6 +88,7 @@ bash "$repo_root/scripts/scan-images.sh" \ test -f "$report_dir/app-linux-amd64.sarif" grep -Fqx 'linux/amd64|table|example/app:amd64' "$log_file" grep -Fqx 'linux/amd64|sarif|example/app:amd64' "$log_file" +grep -Fqx 'linux/amd64|example/app:amd64' "$PHP_RUNTIME_TEST_LOG" export TRIVY_EXPECTED_PLATFORM=linux/arm64 bash "$repo_root/scripts/scan-images.sh" \ @@ -73,6 +96,7 @@ bash "$repo_root/scripts/scan-images.sh" \ test -f "$report_dir/app-linux-arm64.sarif" grep -Fqx 'linux/arm64|table|example/app:arm64' "$log_file" grep -Fqx 'linux/arm64|sarif|example/app:arm64' "$log_file" +grep -Fqx 'linux/arm64|example/app:arm64' "$PHP_RUNTIME_TEST_LOG" if TRIVY_EXPECTED_PLATFORM=linux/arm64 TRIVY_FAIL_IMAGE=example/app:arm64 \ bash "$repo_root/scripts/scan-images.sh" \ @@ -88,4 +112,19 @@ if TRIVY_EXPECTED_PLATFORM=linux/amd64 \ exit 1 fi +# The PHP release policy must block publication even when Trivy succeeds. +if PHP_RUNTIME_FAIL_IMAGE=example/app:arm64 TRIVY_EXPECTED_PLATFORM=linux/arm64 \ + bash "$repo_root/scripts/scan-images.sh" \ + 'app@linux/arm64=example/app:arm64'; then + printf 'expected the runtime policy failure to reject the image\n' >&2 + exit 1 +fi + +# Nginx web images contain no PHP runtime. +before=$(wc -l < "$PHP_RUNTIME_TEST_LOG") +TRIVY_EXPECTED_PLATFORM=linux/amd64 bash "$repo_root/scripts/scan-images.sh" \ + 'web@linux/amd64=example/web:amd64' +after=$(wc -l < "$PHP_RUNTIME_TEST_LOG") +test "$before" -eq "$after" + echo 'scan-images tests passed' diff --git a/tests/test_php_runtime.py b/tests/test_php_runtime.py new file mode 100644 index 0000000..3944b5c --- /dev/null +++ b/tests/test_php_runtime.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Offline regression tests for the dynamic upstream PHP release policy.""" + +import importlib.util +import io +import json +import pathlib +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location("php_runtime_policy", ROOT / "scripts/check-php-runtime.py") +policy = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(policy) + +RELEASES = [ + {"branch": "8.3", "latest": "8.3.36", "state": "security"}, + {"branch": "8.4", "latest": "8.4.27", "state": "stable"}, + {"branch": "8.5", "latest": "8.5.12", "state": "stable"}, + {"branch": "8.1", "latest": "8.1.34", "state": "eol"}, +] + + +class PhpRuntimePolicyTests(unittest.TestCase): + def test_stale_php_is_blocked(self): + with self.assertRaisesRegex(policy.SecurityPolicyError, "older"): + policy.check_release("8.3.35", RELEASES) + + def test_current_php_passes(self): + self.assertIn("supported", policy.check_release("8.3.36", RELEASES)) + self.assertIn("supported", policy.check_release("8.4.27", RELEASES)) + + def test_future_series_requires_no_code_update(self): + releases = RELEASES + [{"branch": "8.6", "latest": "8.6.0", "state": "stable"}] + self.assertIn("supported", policy.check_release("8.6.0", releases)) + + def test_eol_is_blocked_even_at_last_patch(self): + with self.assertRaisesRegex(policy.SecurityPolicyError, "no upstream security support"): + policy.check_release("8.1.34", RELEASES) + + def test_unknown_series_is_blocked(self): + with self.assertRaisesRegex(policy.SecurityPolicyError, "missing or duplicated"): + policy.check_release("9.0.0", RELEASES) + + def test_invalid_feed_is_blocked(self): + for invalid in ([], {}, None): + with self.subTest(feed=invalid), self.assertRaises(policy.SecurityPolicyError): + policy.check_release("8.3.36", invalid) + + def test_bad_release_metadata_is_blocked(self): + with self.assertRaisesRegex(policy.SecurityPolicyError, "Invalid"): + policy.check_release("8.3.36", [{"branch": "8.3", "state": "security", "latest": "8.3.bad"}]) + + def test_nonrelease_and_untrusted_versions_are_blocked(self): + for version in ("8.3.36RC1", "8.3.36-dev", "8.3", "8.3.36\nX"): + with self.subTest(version=version), self.assertRaises(policy.SecurityPolicyError): + policy.check_release(version, RELEASES) + + def test_exact_runtime_is_read_from_image(self): + fake = mock.Mock(stdout="PHP 8.3.36 (cli) (built: Oct 21 2026) (NTS)\n") + with mock.patch.object(policy.subprocess, "run", return_value=fake) as run: + self.assertEqual(policy.container_php_version("scan/app:arm64", "linux/arm64"), "8.3.36") + cmd = run.call_args.args[0] + self.assertEqual(cmd[0], "docker") + self.assertIn("--network", cmd) + self.assertIn("none", cmd) + self.assertIn("--read-only", cmd) + self.assertIn("linux/arm64", cmd) + self.assertIn("scan/app:arm64", cmd) + + def test_malformed_runtime_output_is_blocked(self): + with mock.patch.object(policy.subprocess, "run", return_value=mock.Mock(stdout="not PHP")): + with self.assertRaisesRegex(policy.SecurityPolicyError, "Cannot parse"): + policy.container_php_version("scan/app:amd64", "linux/amd64") + + def test_failed_image_probe_is_blocked(self): + with mock.patch.object(policy.subprocess, "run", side_effect=OSError("docker unavailable")): + with self.assertRaisesRegex(policy.SecurityPolicyError, "Cannot determine"): + policy.container_php_version("scan/app:amd64", "linux/amd64") + + def test_feed_network_failure_is_blocked(self): + with mock.patch.object(policy.urllib.request, "urlopen", side_effect=OSError("network unavailable")): + with self.assertRaisesRegex(policy.SecurityPolicyError, "Cannot read"): + policy.fetch_releases() + + def test_cli_uses_feed_without_any_version_matrix(self): + with tempfile.TemporaryDirectory() as d: + path = pathlib.Path(d) / "releases.json" + path.write_text(json.dumps(RELEASES)) + with redirect_stderr(io.StringIO()): + self.assertEqual(policy.main(["--version", "8.3.35", "--releases-file", str(path)]), 1) + with redirect_stdout(io.StringIO()): + self.assertEqual(policy.main(["--version", "8.3.36", "--releases-file", str(path)]), 0) + + +if __name__ == "__main__": + unittest.main() From cad27109d443dbe1004d51de62d00f00ebb9ccdd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:05:28 -0300 Subject: [PATCH 4/9] feat(ci): monitor upstream image digests and rebuild on security updates Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/actions/build-and-scan/action.yml | 29 ++- .github/workflows/monitor-upstream-images.yml | 93 ++++++++ Makefile | 1 + docs/maintenance.md | 25 ++ scripts/monitor-upstream-images.py | 216 ++++++++++++++++++ tests/test_upstream_monitor.py | 170 ++++++++++++++ 6 files changed, 532 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/monitor-upstream-images.yml create mode 100644 scripts/monitor-upstream-images.py create mode 100644 tests/test_upstream_monitor.py diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index 7bfdd52..990fa70 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -28,6 +28,21 @@ runs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Resolve immutable official Nextcloud base + if: inputs.component == 'app' + id: upstream + shell: bash + run: | + set -euo pipefail + image="nextcloud:${{ inputs.nextcloud_version }}" + digest="$(docker buildx imagetools inspect "$image" --format '{{.Manifest.Digest}}')" + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Could not resolve immutable digest for $image" >&2 + exit 1 + fi + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "image=nextcloud@$digest" >> "$GITHUB_OUTPUT" + - name: Build app image (linux/amd64) if: inputs.component == 'app' uses: docker/build-push-action@v6 @@ -36,7 +51,12 @@ runs: platforms: linux/amd64 load: true build-args: | - NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} + NEXTCLOUD_BASE_IMAGE=${{ steps.upstream.outputs.image }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.base.name=nextcloud:${{ inputs.nextcloud_version }} + coop.librecode.nextcloud.base.digest=${{ steps.upstream.outputs.digest }} pull: true tags: scan/app:amd64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-amd64 @@ -50,7 +70,12 @@ runs: platforms: linux/arm64 load: true build-args: | - NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} + NEXTCLOUD_BASE_IMAGE=${{ steps.upstream.outputs.image }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.base.name=nextcloud:${{ inputs.nextcloud_version }} + coop.librecode.nextcloud.base.digest=${{ steps.upstream.outputs.digest }} pull: true tags: scan/app:arm64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-arm64 diff --git a/.github/workflows/monitor-upstream-images.yml b/.github/workflows/monitor-upstream-images.yml new file mode 100644 index 0000000..7ffbe52 --- /dev/null +++ b/.github/workflows/monitor-upstream-images.yml @@ -0,0 +1,93 @@ +name: Monitor Nextcloud upstream images + +on: + schedule: + # Detect patched official images within six hours without rebuilding unchanged inputs. + - cron: '37 */6 * * *' + workflow_dispatch: + +permissions: + contents: read + actions: write + issues: write + +concurrency: + group: nextcloud-upstream-image-monitor + cancel-in-progress: false + +jobs: + watch: + name: Detect base image changes and request rebuild + runs-on: ubuntu-latest + steps: + - name: Checkout reviewed repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Test upstream monitor + run: | + python3 -m unittest discover -s tests -p 'test_upstream_monitor.py' + python3 -m unittest discover -s tests -p 'test_php_runtime.py' + + - name: Verify reviewed Nextcloud release manifest + run: bash scripts/nextcloud-versions.sh matrix > /dev/null + + - name: Compare upstream PHP, base digests and published GHCR images + id: upstream + run: | + python3 scripts/monitor-upstream-images.py \ + --revision "$GITHUB_SHA" \ + --output "$GITHUB_OUTPUT" \ + --summary "$GITHUB_STEP_SUMMARY" \ + --report "$RUNNER_TEMP/nextcloud-upstream-security.md" + + - name: Open or update upstream security blocker + if: github.ref == 'refs/heads/main' && steps.upstream.outputs.blocked == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + title='security: upstream Nextcloud images need updated PHP' + body="$RUNNER_TEMP/nextcloud-upstream-security.md" + number="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search 'in:title "upstream Nextcloud images need updated PHP"' \ + --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)" + if [[ -z "$number" ]]; then + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body" + else + current="$(gh issue view "$number" --repo "$GITHUB_REPOSITORY" --json body --jq .body)" + if [[ "$current" != "$(cat "$body")" ]]; then + gh issue edit "$number" --repo "$GITHUB_REPOSITORY" --body-file "$body" + fi + fi + + - name: Close resolved upstream blocker + if: github.ref == 'refs/heads/main' && steps.upstream.outputs.blocked == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + title='security: upstream Nextcloud images need updated PHP' + number="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search 'in:title "upstream Nextcloud images need updated PHP"' \ + --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)" + if [[ -n "$number" ]]; then + gh issue close "$number" --repo "$GITHUB_REPOSITORY" \ + --comment 'The upstream PHP runtime is current in all reviewed Nextcloud base images.' + fi + + # workflow_dispatch explicitly creates a new workflow run, including + # when invoked using GITHUB_TOKEN in this repository. + - name: Trigger image build after verified upstream change + if: github.ref == 'refs/heads/main' && steps.upstream.outputs.rebuild == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh workflow run docker-image.yml --repo "$GITHUB_REPOSITORY" --ref main + echo 'Requested automatic build through docker-image.yml.' >> "$GITHUB_STEP_SUMMARY" diff --git a/Makefile b/Makefile index ca4b787..e2e17fe 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,7 @@ test-scan-images: test-ci-scripts: bats tests/nextcloud-versions.bats tests/publish-images.bats python3 -m unittest discover -s tests -p 'test_php_runtime.py' + python3 -m unittest discover -s tests -p 'test_upstream_monitor.py' test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/docs/maintenance.md b/docs/maintenance.md index 6c83901..8a38127 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -119,3 +119,28 @@ Run offline policy tests with: Run on a built image with: scripts/check-php-runtime.py --image scan/app:amd64 --platform linux/amd64 + +## Automatic upstream rebuild detection + +The scheduled monitor workflow checks the reviewed Nextcloud base tags every six +hours. It compares their remote OCI manifest digests with the digest and Git +revision recorded in both the amd64 and arm64 images currently published on GHCR. + +When the official base is updated and both architectures contain a supported, +current PHP security patch, the monitor requests a new run of +docker-image.yml using workflow_dispatch. The build pins the exact upstream +digest resolved at build start and only promotes images after the existing +Trivy scan and Nextcloud runtime tests succeed. If nothing changed, it skips +the build. If the base still ships outdated PHP, it opens or updates a single +security tracking issue and checks again at the next interval. The issue +is closed automatically when all reviewed bases have current PHP runtimes. + +A missing GHCR image or provenance label also requires a rebuild; this ensures +existing images without embedded base digests are refreshed once after rollout. +Network or registry inspection failures fail the monitor rather than treating +unknown status as a clean result. The existing weekly full build remains as a +separate safety net, while release discovery continues to propose reviewed +changes for new Nextcloud versions. + +This workflow runs only after changes are merged to main. Running the monitor +on an unmerged branch never dispatches a production image build or modifies issues. diff --git a/scripts/monitor-upstream-images.py b/scripts/monitor-upstream-images.py new file mode 100644 index 0000000..20ad551 --- /dev/null +++ b/scripts/monitor-upstream-images.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Watch Nextcloud base digests and published GHCR provenance. + +Only suggest a rebuild once every maintained upstream base has a supported, +up-to-date PHP release. Published amd64 and arm64 images must identify the +exact upstream base digest and source revision used in their builds. +""" + +import argparse +import importlib.util +import json +import os +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +POLICY_SPEC = importlib.util.spec_from_file_location( + "php_runtime_policy", ROOT / "scripts/check-php-runtime.py" +) +policy = importlib.util.module_from_spec(POLICY_SPEC) +POLICY_SPEC.loader.exec_module(policy) + +PLATFORMS = ("linux/amd64", "linux/arm64") +DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") +APP_IMAGE = "ghcr.io/librecodecoop/nextcloud-docker-app" + + +class InspectionError(Exception): + pass + + +class MissingPlatformError(InspectionError): + pass + + +def inspect(reference, template): + command = ["docker", "buildx", "imagetools", "inspect", reference, "--format", template] + try: + result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=45) + except (OSError, subprocess.SubprocessError) as exc: + raise InspectionError(f"Cannot inspect {reference}: {exc}") from exc + if result.returncode: + message = result.stderr.strip() or result.stdout.strip() + # Only a missing published image is a first-build condition. + if re.search(r"(?:manifest unknown|not found|404 Not Found)", message, re.I): + raise FileNotFoundError(reference) from None + raise InspectionError(f"Cannot inspect {reference}: {message[:250]}") + try: + return json.loads(result.stdout) + except ValueError as exc: + raise InspectionError(f"Invalid OCI metadata for {reference}") from exc + + +def manifest_digest(reference): + document = inspect(reference, "{{json .Manifest}}") + digest = document.get("digest") if isinstance(document, dict) else None + if not isinstance(digest, str) or not DIGEST_PATTERN.fullmatch(digest): + raise InspectionError(f"Missing valid digest for {reference}") + return digest + + +def image_config(reference): + document = inspect(reference, "{{json .Image}}") + if not isinstance(document, dict): + raise InspectionError(f"Missing image configuration for {reference}") + configs = {} + for platform in PLATFORMS: + value = document.get(platform) + if not isinstance(value, dict) or not isinstance(value.get("config"), dict): + raise MissingPlatformError(f"{reference} has no {platform} image configuration") + configs[platform] = value["config"] + return configs + + +def php_version(config): + variables = config.get("Env") + if not isinstance(variables, list): + raise InspectionError("Image configuration has no environment variables") + versions = [part.removeprefix("PHP_VERSION=") for part in variables + if isinstance(part, str) and part.startswith("PHP_VERSION=")] + if len(versions) != 1: + raise InspectionError("Could not determine a unique PHP_VERSION from official base metadata") + policy.parse_version(versions[0]) + return versions[0] + + +def published_status(reference, upstream_digest, revision): + try: + configs = image_config(reference) + except FileNotFoundError: + return ["published image missing"] + except MissingPlatformError as exc: + return [str(exc)] + reasons = [] + for platform, config in configs.items(): + labels = config.get("Labels") or {} + if labels.get("coop.librecode.nextcloud.base.digest") != upstream_digest: + reasons.append(f"{platform}: upstream base digest differs or was not recorded") + if labels.get("org.opencontainers.image.revision") != revision: + reasons.append(f"{platform}: published Git revision differs or was not recorded") + return reasons + + +def evaluate(versions, releases, revision, image_prefix=APP_IMAGE): + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise InspectionError("Expected the reviewed 40-digit Git revision") + if not isinstance(versions, dict) or not isinstance(versions.get("versions"), list): + raise InspectionError("Invalid reviewed Nextcloud version manifest") + entries = versions["versions"] + if not entries: + raise InspectionError("The reviewed Nextcloud version manifest is empty") + + rows = [] + blocked = False + rebuild = False + for entry in entries: + if not isinstance(entry, dict) or not isinstance(entry.get("major"), int): + raise InspectionError("Malformed Nextcloud version entry") + major = entry["major"] + release = entry.get("release") + if not isinstance(release, str) or not re.fullmatch(rf"{major}\.[0-9]+\.[0-9]+", release): + raise InspectionError(f"Invalid release for Nextcloud {major}") + upstream = f"nextcloud:{release}-fpm" + published = f"{image_prefix}:{major}-fpm" + digest = manifest_digest(upstream) + configs = image_config(f"nextcloud@{digest}") + php_versions = {platform: php_version(config) for platform, config in configs.items()} + problems = [] + for platform, version in php_versions.items(): + try: + policy.check_release(version, releases) + except policy.SecurityPolicyError as exc: + problems.append(f"{platform}: {exc}") + if problems: + blocked = True + status = "Awaiting patched upstream" + reason = "; ".join(problems) + else: + differences = published_status(published, digest, revision) + if differences: + rebuild = True + status = "Rebuild required" + reason = "; ".join(differences) + else: + status = "Up to date" + reason = "Both published platforms match the upstream digest and Git revision" + rows.append({ + "major": major, "release": release, "upstream_digest": digest, + "php": php_versions, "status": status, "reason": reason, + }) + # Publishing stable aliases requires all maintained versions to pass. + return {"rebuild": rebuild and not blocked, "blocked": blocked, "rows": rows} + + +def render_summary(result): + lines = [ + "## Nextcloud upstream image monitor", + "", + "Compares reviewed upstream Docker image digests and actual PHP versions " + "against the metadata of both published GHCR architectures.", + "", + "| Nextcloud | PHP amd64 / arm64 | Status | Details |", + "| --- | --- | --- | --- |", + ] + for row in result["rows"]: + versions = " / ".join(row["php"][platform] for platform in PLATFORMS) + detail = row["reason"].replace("|", "/").replace("\n", " ") + lines.append(f"| {row['release']} | {versions} | {row['status']} | {detail} |") + lines.extend([ + "", f"**Blocked by upstream PHP:** {result['blocked']}", + f"**Rebuild requested:** {result['rebuild']}", "", + "If PHP has a new upstream release but Nextcloud still embeds the old " + "interpreter, wait for the base to change. Rebuilding that same " + "unpatched base would not resolve the vulnerability.", + ]) + return "\n".join(lines) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--versions", default=".github/nextcloud-supported.json") + parser.add_argument("--revision", default=os.environ.get("GITHUB_SHA", "")) + parser.add_argument("--output", help="GitHub Actions output file") + parser.add_argument("--summary", help="GitHub Actions step summary") + parser.add_argument("--report", help="Write Markdown for a tracking issue") + parser.add_argument("--releases-file", help="Use a checked offline release fixture") + args = parser.parse_args(argv) + try: + with open(args.versions, encoding="utf-8") as stream: + versions = json.load(stream) + if args.releases_file: + with open(args.releases_file, encoding="utf-8") as stream: + releases = json.load(stream) + else: + releases = policy.fetch_releases() + result = evaluate(versions, releases, args.revision) + summary = render_summary(result) + print(summary) + if args.output: + with open(args.output, "a", encoding="utf-8") as stream: + stream.write(f"rebuild={str(result['rebuild']).lower()}\n") + stream.write(f"blocked={str(result['blocked']).lower()}\n") + for path in (args.summary, args.report): + if path: + with open(path, "a", encoding="utf-8") as stream: + stream.write(summary) + return 0 + except (OSError, ValueError, InspectionError, policy.SecurityPolicyError) as exc: + print(f"Upstream image inspection failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_upstream_monitor.py b/tests/test_upstream_monitor.py new file mode 100644 index 0000000..5cf3666 --- /dev/null +++ b/tests/test_upstream_monitor.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Offline unit tests for automatic rebuild decisions.""" + +import importlib.util +import json +import pathlib +import unittest +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location( + "upstream_image_monitor", ROOT / "scripts/monitor-upstream-images.py" +) +monitor = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(monitor) + +SHA = "e" * 40 +DIGEST = "sha256:" + "a" * 64 +NEW_DIGEST = "sha256:" + "b" * 64 +VERSIONS = {"versions": [ + {"major": 33, "release": "33.0.10"}, + {"major": 34, "release": "34.0.5"}, +]} +RELEASES = [ + {"branch": "8.4", "latest": "8.4.27", "state": "stable"}, + {"branch": "8.5", "latest": "8.5.12", "state": "stable"}, + {"branch": "8.1", "latest": "8.1.34", "state": "eol"}, +] + + +def configs(php, *, digest=DIGEST, revision=SHA): + return { + arch: { + "Env": ["PHP_VERSION=" + php], + "Labels": { + "coop.librecode.nextcloud.base.digest": digest, + "org.opencontainers.image.revision": revision, + }, + } + for arch in monitor.PLATFORMS + } + + +class MonitoringTests(unittest.TestCase): + def setUp(self): + self.digest = mock.patch.object(monitor, "manifest_digest", return_value=DIGEST) + self.images = mock.patch.object(monitor, "image_config") + self.digest_mock = self.digest.start() + self.images_mock = self.images.start() + self.addCleanup(self.digest.stop) + self.addCleanup(self.images.stop) + + def configure(self, *, base="8.4.27", published=None): + if published is None: + published = configs(base) + + def get_image(ref): + if ref.startswith("nextcloud:") or ref.startswith("nextcloud@"): + return configs(base) + if published == "missing": + raise FileNotFoundError(ref) + return published + self.images_mock.side_effect = get_image + + def test_matching_manifest_and_revision_does_not_rebuild(self): + self.configure() + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + self.assertFalse(result["rebuild"]) + self.assertFalse(result["blocked"]) + self.assertEqual(result["rows"][0]["status"], "Up to date") + + def test_changed_upstream_digest_requests_rebuild(self): + self.configure(published=configs("8.4.27", digest=NEW_DIGEST)) + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + self.assertTrue(result["rebuild"]) + self.assertFalse(result["blocked"]) + + def test_changed_code_revision_retries_failed_publication(self): + self.configure(published=configs("8.4.27", revision="f" * 40)) + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + self.assertTrue(result["rebuild"]) + + def test_missing_published_tag_requests_rebuild(self): + self.configure(published="missing") + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + self.assertTrue(result["rebuild"]) + + def test_legacy_image_without_provenance_requests_rebuild(self): + self.configure(published={arch: {"Env": ["PHP_VERSION=8.4.27"], "Labels": {}} + for arch in monitor.PLATFORMS}) + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + self.assertTrue(result["rebuild"]) + + def test_outdated_upstream_php_blocks_rebuild(self): + self.configure(base="8.4.26") + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + self.assertTrue(result["blocked"]) + self.assertFalse(result["rebuild"]) + self.assertNotIn("ghcr.io", str(self.images_mock.call_args_list)) + + def test_eol_upstream_php_blocks_rebuild(self): + self.configure(base="8.1.34") + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + self.assertTrue(result["blocked"]) + self.assertFalse(result["rebuild"]) + + def test_any_blocked_channel_prevents_partial_alias_promotion(self): + self.digest_mock.side_effect = lambda ref: DIGEST if "33.0.10" in ref else NEW_DIGEST + def get_image(ref): + if ref == "nextcloud@" + DIGEST: + return configs("8.4.27") + if ref == "nextcloud@" + NEW_DIGEST: + return configs("8.5.11") + return configs("8.4.27", digest=NEW_DIGEST) + self.images_mock.side_effect = get_image + result = monitor.evaluate(VERSIONS, RELEASES, SHA) + self.assertTrue(result["blocked"]) + self.assertFalse(result["rebuild"]) + self.assertEqual(result["rows"][0]["status"], "Rebuild required") + self.assertEqual(result["rows"][1]["status"], "Awaiting patched upstream") + + def test_missing_published_platform_requests_rebuild(self): + self.configure() + self.images_mock.side_effect = lambda ref: configs("8.4.27") if ref.startswith("nextcloud:") or ref.startswith("nextcloud@") else self._missing() + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + self.assertTrue(result["rebuild"]) + + @staticmethod + def _missing(): + raise monitor.MissingPlatformError("published image missing linux/arm64") + + def test_missing_arm64_config_is_an_error_not_noop(self): + self.images.stop() + with mock.patch.object(monitor, "inspect", return_value={"linux/amd64": {"config": configs("8.4.27")["linux/amd64"]}}): + with self.assertRaisesRegex(monitor.InspectionError, "linux/arm64"): + monitor.image_config("nextcloud:33.0.10-fpm") + + def test_invalid_manifest_is_an_error_not_noop(self): + for wrong in ({"versions": []}, {"versions": [{"major": 33, "release": "34.0.1"}]}, {}): + with self.subTest(wrong=wrong), self.assertRaises(monitor.InspectionError): + monitor.evaluate(wrong, RELEASES, SHA) + + def test_invalid_git_revision_is_an_error(self): + with self.assertRaisesRegex(monitor.InspectionError, "40-digit"): + monitor.evaluate(VERSIONS, RELEASES, "main") + + def test_summary_has_actionable_reasons(self): + self.configure(base="8.4.26") + result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + rendered = monitor.render_summary(result) + self.assertIn("Awaiting patched upstream", rendered) + self.assertIn("8.4.26", rendered) + self.assertIn("Rebuild requested:** False", rendered) + + def test_missing_registry_or_network_fails_closed(self): + self.images_mock.side_effect = monitor.InspectionError("registry unreachable") + with self.assertRaises(monitor.InspectionError): + monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) + + def test_manifest_digest_is_taken_from_upstream_index(self): + self.digest.stop() + with mock.patch.object(monitor, "inspect", return_value={"digest": DIGEST}): + self.assertEqual(monitor.manifest_digest("nextcloud:33.0.10-fpm"), DIGEST) + with mock.patch.object(monitor, "inspect", return_value={"digest": "invalid"}): + with self.assertRaises(monitor.InspectionError): + monitor.manifest_digest("nextcloud:33.0.10-fpm") + + +if __name__ == "__main__": + unittest.main() From b57976eaf2a98ac33284268f00c958bc60c9c92e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:20:46 -0300 Subject: [PATCH 5/9] refactor(ci): use Dependabot-managed Nextcloud base image digests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/nextcloud-bases/33/Dockerfile | 1 + .docker/nextcloud-bases/34/Dockerfile | 1 + .docker/nextcloud-bases/35/Dockerfile | 1 + .github/actions/build-and-scan/action.yml | 32 +-- .github/dependabot.yml | 11 + .../workflows/discover-nextcloud-versions.yml | 33 ++- .github/workflows/docker-image.yml | 3 +- .github/workflows/monitor-upstream-images.yml | 93 -------- .github/workflows/nextcloud-development.yml | 7 - Makefile | 4 +- docs/maintenance.md | 66 ++---- scripts/check-php-runtime.py | 112 --------- scripts/monitor-upstream-images.py | 216 ------------------ scripts/scan-images.sh | 8 - scripts/select-nextcloud-base.sh | 19 ++ tests/nextcloud-base.bats | 28 +++ tests/test-scan-images.sh | 39 ---- tests/test_php_runtime.py | 99 -------- tests/test_upstream_monitor.py | 170 -------------- 19 files changed, 119 insertions(+), 824 deletions(-) create mode 100644 .docker/nextcloud-bases/33/Dockerfile create mode 100644 .docker/nextcloud-bases/34/Dockerfile create mode 100644 .docker/nextcloud-bases/35/Dockerfile delete mode 100644 .github/workflows/monitor-upstream-images.yml delete mode 100755 scripts/check-php-runtime.py delete mode 100644 scripts/monitor-upstream-images.py create mode 100755 scripts/select-nextcloud-base.sh create mode 100644 tests/nextcloud-base.bats delete mode 100644 tests/test_php_runtime.py delete mode 100644 tests/test_upstream_monitor.py diff --git a/.docker/nextcloud-bases/33/Dockerfile b/.docker/nextcloud-bases/33/Dockerfile new file mode 100644 index 0000000..69ca651 --- /dev/null +++ b/.docker/nextcloud-bases/33/Dockerfile @@ -0,0 +1 @@ +FROM nextcloud:33.0.10-fpm@sha256:5263d2ecf8745ba3599e7d0323bbf3ec4758ce0273a47a1989abfa144bbfb94f diff --git a/.docker/nextcloud-bases/34/Dockerfile b/.docker/nextcloud-bases/34/Dockerfile new file mode 100644 index 0000000..5afd76e --- /dev/null +++ b/.docker/nextcloud-bases/34/Dockerfile @@ -0,0 +1 @@ +FROM nextcloud:34.0.5-fpm@sha256:e16fd63e5ce16bbdd64c640e97119dd5c1997c1de0255b59851f144fc9952f07 diff --git a/.docker/nextcloud-bases/35/Dockerfile b/.docker/nextcloud-bases/35/Dockerfile new file mode 100644 index 0000000..1803742 --- /dev/null +++ b/.docker/nextcloud-bases/35/Dockerfile @@ -0,0 +1 @@ +FROM nextcloud:35.0.2-fpm@sha256:763a5ad2d98e58ab3b4517eb469301e0483b239edebcdc93997da7f61e89b5b4 diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index 990fa70..42923d9 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -28,20 +28,16 @@ runs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Resolve immutable official Nextcloud base + - name: Select Dependabot-managed Nextcloud base image if: inputs.component == 'app' - id: upstream + id: base shell: bash + env: + NEXTCLOUD_VERSION: ${{ inputs.nextcloud_version }} run: | set -euo pipefail - image="nextcloud:${{ inputs.nextcloud_version }}" - digest="$(docker buildx imagetools inspect "$image" --format '{{.Manifest.Digest}}')" - if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "Could not resolve immutable digest for $image" >&2 - exit 1 - fi - echo "digest=$digest" >> "$GITHUB_OUTPUT" - echo "image=nextcloud@$digest" >> "$GITHUB_OUTPUT" + base_image="$(bash scripts/select-nextcloud-base.sh "$NEXTCLOUD_VERSION")" + echo "image=$base_image" >> "$GITHUB_OUTPUT" - name: Build app image (linux/amd64) if: inputs.component == 'app' @@ -51,13 +47,7 @@ runs: platforms: linux/amd64 load: true build-args: | - NEXTCLOUD_BASE_IMAGE=${{ steps.upstream.outputs.image }} - labels: | - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.base.name=nextcloud:${{ inputs.nextcloud_version }} - coop.librecode.nextcloud.base.digest=${{ steps.upstream.outputs.digest }} - pull: true + NEXTCLOUD_BASE_IMAGE=${{ steps.base.outputs.image }} tags: scan/app:amd64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-amd64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-amd64 @@ -70,13 +60,7 @@ runs: platforms: linux/arm64 load: true build-args: | - NEXTCLOUD_BASE_IMAGE=${{ steps.upstream.outputs.image }} - labels: | - org.opencontainers.image.source=https://github.com/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.base.name=nextcloud:${{ inputs.nextcloud_version }} - coop.librecode.nextcloud.base.digest=${{ steps.upstream.outputs.digest }} - pull: true + NEXTCLOUD_BASE_IMAGE=${{ steps.base.outputs.image }} tags: scan/app:arm64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-arm64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-arm64 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c8a80f..2c08278 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,6 +15,17 @@ updates: cooldown: default-days: 7 + # Dependabot updates the explicit tag and digest in these real base files. + - package-ecosystem: docker + directories: + - /.docker/nextcloud-bases/* + schedule: + interval: daily + ignore: + - dependency-name: nextcloud + update-types: + - version-update:semver-major + - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/discover-nextcloud-versions.yml b/.github/workflows/discover-nextcloud-versions.yml index ca6ff2d..9affd7f 100644 --- a/.github/workflows/discover-nextcloud-versions.yml +++ b/.github/workflows/discover-nextcloud-versions.yml @@ -42,11 +42,40 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: bash scripts/nextcloud-versions.sh discover + - name: Pin bases for newly approved Nextcloud releases + shell: bash + run: | + set -euo pipefail + while IFS=$'\t' read -r major release; do + file=".docker/nextcloud-bases/${major}/Dockerfile" + tag="nextcloud:${release}-fpm" + if [[ -f "$file" ]] && grep -Fq "FROM ${tag}@sha256:" "$file"; then + continue + fi + digest="$(docker buildx imagetools inspect "$tag" --format '{{.Manifest.Digest}}')" + [[ $digest =~ ^sha256:[0-9a-f]{64}$ ]] || exit 1 + mkdir -p "$(dirname "$file")" + printf 'FROM %s@%s\n' "$tag" "$digest" > "$file" + done < <(jq -r '.versions[] | [.major,.release] | @tsv' .github/nextcloud-supported.json) + for file in .docker/nextcloud-bases/*/Dockerfile; do + major="${file%/Dockerfile}" + major="${major##*/}" + if ! jq -e --argjson major "$major" '.versions | any(.[]; .major == $major)' .github/nextcloud-supported.json > /dev/null; then + rm -- "$file" + rmdir -- "$(dirname "$file")" + fi + done + + - name: Verify approved image base pins + run: bats tests/nextcloud-base.bats + - name: Propose reviewed manifest update id: pull_request uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - add-paths: .github/nextcloud-supported.json + add-paths: | + .github/nextcloud-supported.json + .docker/nextcloud-bases/**/Dockerfile branch: automation/nextcloud-supported-versions delete-branch: true signoff: true @@ -55,7 +84,7 @@ jobs: title: 'chore(ci): synchronize maintained Nextcloud versions' body: | Sync the reviewed Nextcloud version manifest with the upstream - Maintenance and Release Schedule wiki. + Maintenance and Release Schedule wiki, including pinned base images. The discovery job requires official stable release artifacts and amd64/arm64 FPM images. Review version additions/removals, patch diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index cbc458f..29d6bb5 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -48,7 +48,8 @@ jobs: - name: ShellCheck image publication scripts run: | - shellcheck scripts/nextcloud-versions.sh scripts/publish-images.sh \ + shellcheck scripts/nextcloud-versions.sh scripts/select-nextcloud-base.sh \ + scripts/publish-images.sh \ scripts/publish-manifest.sh scripts/push-architecture.sh \ scripts/publish-master-manifest.sh scripts/push-master-architecture.sh diff --git a/.github/workflows/monitor-upstream-images.yml b/.github/workflows/monitor-upstream-images.yml deleted file mode 100644 index 7ffbe52..0000000 --- a/.github/workflows/monitor-upstream-images.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Monitor Nextcloud upstream images - -on: - schedule: - # Detect patched official images within six hours without rebuilding unchanged inputs. - - cron: '37 */6 * * *' - workflow_dispatch: - -permissions: - contents: read - actions: write - issues: write - -concurrency: - group: nextcloud-upstream-image-monitor - cancel-in-progress: false - -jobs: - watch: - name: Detect base image changes and request rebuild - runs-on: ubuntu-latest - steps: - - name: Checkout reviewed repository - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Test upstream monitor - run: | - python3 -m unittest discover -s tests -p 'test_upstream_monitor.py' - python3 -m unittest discover -s tests -p 'test_php_runtime.py' - - - name: Verify reviewed Nextcloud release manifest - run: bash scripts/nextcloud-versions.sh matrix > /dev/null - - - name: Compare upstream PHP, base digests and published GHCR images - id: upstream - run: | - python3 scripts/monitor-upstream-images.py \ - --revision "$GITHUB_SHA" \ - --output "$GITHUB_OUTPUT" \ - --summary "$GITHUB_STEP_SUMMARY" \ - --report "$RUNNER_TEMP/nextcloud-upstream-security.md" - - - name: Open or update upstream security blocker - if: github.ref == 'refs/heads/main' && steps.upstream.outputs.blocked == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - title='security: upstream Nextcloud images need updated PHP' - body="$RUNNER_TEMP/nextcloud-upstream-security.md" - number="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ - --search 'in:title "upstream Nextcloud images need updated PHP"' \ - --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)" - if [[ -z "$number" ]]; then - gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body" - else - current="$(gh issue view "$number" --repo "$GITHUB_REPOSITORY" --json body --jq .body)" - if [[ "$current" != "$(cat "$body")" ]]; then - gh issue edit "$number" --repo "$GITHUB_REPOSITORY" --body-file "$body" - fi - fi - - - name: Close resolved upstream blocker - if: github.ref == 'refs/heads/main' && steps.upstream.outputs.blocked == 'false' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - title='security: upstream Nextcloud images need updated PHP' - number="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ - --search 'in:title "upstream Nextcloud images need updated PHP"' \ - --json number,title --jq ".[] | select(.title == \"$title\") | .number" | head -n1)" - if [[ -n "$number" ]]; then - gh issue close "$number" --repo "$GITHUB_REPOSITORY" \ - --comment 'The upstream PHP runtime is current in all reviewed Nextcloud base images.' - fi - - # workflow_dispatch explicitly creates a new workflow run, including - # when invoked using GITHUB_TOKEN in this repository. - - name: Trigger image build after verified upstream change - if: github.ref == 'refs/heads/main' && steps.upstream.outputs.rebuild == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh workflow run docker-image.yml --repo "$GITHUB_REPOSITORY" --ref main - echo 'Requested automatic build through docker-image.yml.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/nextcloud-development.yml b/.github/workflows/nextcloud-development.yml index 6b7a022..a97ac8c 100644 --- a/.github/workflows/nextcloud-development.yml +++ b/.github/workflows/nextcloud-development.yml @@ -8,8 +8,6 @@ on: - '.docker/app/**' - 'tests/app-image.bats' - 'scripts/scan-images.sh' - - 'scripts/check-php-runtime.py' - - 'tests/test_php_runtime.py' - 'trivy.yaml' - '.github/workflows/nextcloud-development.yml' - 'scripts/resolve-nextcloud-upstream.sh' @@ -23,8 +21,6 @@ on: - '.docker/app/**' - 'tests/app-image.bats' - 'scripts/scan-images.sh' - - 'scripts/check-php-runtime.py' - - 'tests/test_php_runtime.py' - 'trivy.yaml' - '.github/workflows/nextcloud-development.yml' - 'scripts/resolve-nextcloud-upstream.sh' @@ -68,8 +64,6 @@ jobs: - name: Test development image scripts run: bats tests/nextcloud-development-image.bats - - name: Test upstream PHP runtime security policy - run: python3 -m unittest discover -s tests -p 'test_php_runtime.py' validate: needs: @@ -134,7 +128,6 @@ jobs: coop.librecode.nextcloud.source.sha512=${{ steps.upstream.outputs.source_sha512 }} coop.librecode.nextcloud.base.digest=${{ steps.upstream.outputs.base_digest }} coop.librecode.runtime.variant=fpm - pull: true tags: scan/master:${{ matrix.arch }} cache-from: type=gha,scope=master-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=master-${{ matrix.arch }} diff --git a/Makefile b/Makefile index e2e17fe..cb3f713 100644 --- a/Makefile +++ b/Makefile @@ -31,9 +31,7 @@ test-scan-images: bash tests/test-scan-images.sh test-ci-scripts: - bats tests/nextcloud-versions.bats tests/publish-images.bats - python3 -m unittest discover -s tests -p 'test_php_runtime.py' - python3 -m unittest discover -s tests -p 'test_upstream_monitor.py' + bats tests/nextcloud-versions.bats tests/publish-images.bats tests/nextcloud-base.bats test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/docs/maintenance.md b/docs/maintenance.md index 8a38127..52fb876 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -91,56 +91,22 @@ Image metadata should use standard OCI labels wherever possible, including the s Keep tag naming consistent with the official Nextcloud model. Do not introduce `:dev`, `:dev-`, bare `:`, or `:nc-` as new public conventions. The legacy development tag `:35` is not a required compatibility alias; see the consumer analysis in #52. -Dependabot manages Docker and GitHub Actions dependencies. Renovate is limited to regex-managed values Dependabot cannot discover (currently the explicitly pinned Trivy binary version), so the two bots do not manage the same dependency. +Dependabot manages Docker and GitHub Actions dependencies, including pinned Nextcloud base images under .docker/nextcloud-bases/. Renovate is limited to regex-managed values Dependabot cannot discover (currently the explicitly pinned Trivy binary version), so the two bots do not manage the same dependency. The remaining implementation constraints tracked under #47 are to improve metadata when upstream identifiers become available, keep the stable and master publishing contracts aligned, preserve runtime and security gates, and maintain deployment compatibility until replacement approaches are validated. -## PHP runtime security policy - -Image builds use complementary controls: Trivy scans OS and application -dependencies, while the PHP runtime policy checks the installed interpreter -against the PHP project's live JSON release feed: -https://www.php.net/releases/branches.php - -The image checker runs centrally via scripts/scan-images.sh for both linux/amd64 -and linux/arm64 application images. It rejects unsupported branches, PHP versions -older than the latest upstream patch, and upstream network failures. This also -covers the case where an official Nextcloud base contains a PHP binary built -outside Debian's package manager and therefore missed by package CVE scanners. - -This policy is intentionally more conservative than a CVE-only feed: even routine -PHP patch releases can block image publishing until the official Nextcloud parent -images catch up. It is designed for upstream-derived PHP images, not vendor -distro packages with independently attested security backports. Bats remains -responsible for functional acceptance of the Nextcloud image. - -Run offline policy tests with: - python3 -m unittest discover -s tests -p 'test_php_runtime.py' - -Run on a built image with: - scripts/check-php-runtime.py --image scan/app:amd64 --platform linux/amd64 - -## Automatic upstream rebuild detection - -The scheduled monitor workflow checks the reviewed Nextcloud base tags every six -hours. It compares their remote OCI manifest digests with the digest and Git -revision recorded in both the amd64 and arm64 images currently published on GHCR. - -When the official base is updated and both architectures contain a supported, -current PHP security patch, the monitor requests a new run of -docker-image.yml using workflow_dispatch. The build pins the exact upstream -digest resolved at build start and only promotes images after the existing -Trivy scan and Nextcloud runtime tests succeed. If nothing changed, it skips -the build. If the base still ships outdated PHP, it opens or updates a single -security tracking issue and checks again at the next interval. The issue -is closed automatically when all reviewed bases have current PHP runtimes. - -A missing GHCR image or provenance label also requires a rebuild; this ensures -existing images without embedded base digests are refreshed once after rollout. -Network or registry inspection failures fail the monitor rather than treating -unknown status as a clean result. The existing weekly full build remains as a -separate safety net, while release discovery continues to propose reviewed -changes for new Nextcloud versions. - -This workflow runs only after changes are merged to main. Running the monitor -on an unmerged branch never dispatches a production image build or modifies issues. +### Automatic image-base updates + +Each maintained Nextcloud version has a single-line Dockerfile under +.docker/nextcloud-bases//Dockerfile. The image is pinned to an official +release tag and immutable OCI sha256 digest. Dependabot checks these files +daily and proposes reviewable updates, including a changed digest under the +same tag. Existing CI runs on each PR and publishes only after merge. + +The common application Dockerfile remains shared. The +scripts/select-nextcloud-base.sh helper passes the approved pinned reference +to the build and fails if it differs from the reviewed release manifest. +When a new official Nextcloud patch is approved, the existing release-discovery +workflow also updates the corresponding base pin in its reviewable PR. +It removes obsolete base pins when a maintained Nextcloud major reaches EOL. +Dependabot owns same-tag digest updates; no custom monitoring service runs. diff --git a/scripts/check-php-runtime.py b/scripts/check-php-runtime.py deleted file mode 100755 index 446596d..0000000 --- a/scripts/check-php-runtime.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -"""Reject out-of-date or EOL official PHP runtimes before image publication. - -The upstream PHP release feed owns the version policy. This script does not -maintain CVE-specific version ranges and is intended for upstream-derived images. -""" - -import argparse -import json -import re -import subprocess -import sys -import urllib.request - -RELEASES_URL = "https://www.php.net/releases/branches.php" -VERSION_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") -SUPPORTED_STATES = frozenset(("stable", "security")) - - -class SecurityPolicyError(Exception): - pass - - -def parse_version(value): - if not isinstance(value, str) or not VERSION_RE.fullmatch(value): - raise SecurityPolicyError(f"Invalid stable PHP version: {value!r}") - return tuple(int(part) for part in value.split(".")) - - -def check_release(version, releases): - current = parse_version(version) - if not isinstance(releases, list) or not releases: - raise SecurityPolicyError("PHP release feed is empty or invalid") - - branch_name = f"{current[0]}.{current[1]}" - matching = [r for r in releases if isinstance(r, dict) and r.get("branch") == branch_name] - if len(matching) != 1: - raise SecurityPolicyError(f"PHP {branch_name} is missing or duplicated in the upstream release feed") - - record = matching[0] - if record.get("state") not in SUPPORTED_STATES: - raise SecurityPolicyError(f"PHP {branch_name} has no upstream security support (state: {record.get('state')!r})") - - latest = parse_version(record.get("latest")) - if latest[:2] != current[:2]: - raise SecurityPolicyError(f"Inconsistent PHP release feed for {branch_name}: {record.get('latest')!r}") - - if current < latest: - raise SecurityPolicyError( - f"PHP {version} is older than the upstream {branch_name} release {record['latest']}" - ) - return f"PHP {version}: supported branch and current upstream patch release" - - -def fetch_releases(): - req = urllib.request.Request( - RELEASES_URL, - headers={"Accept": "application/json", "User-Agent": "LibreCode-PHP-Runtime-Check/1"}, - ) - try: - with urllib.request.urlopen(req, timeout=15) as response: - return json.load(response) - except (OSError, ValueError) as exc: - raise SecurityPolicyError(f"Cannot read upstream PHP release feed: {exc}") from exc - - -def container_php_version(image, platform): - if platform not in ("linux/amd64", "linux/arm64"): - raise SecurityPolicyError(f"Unsupported image platform: {platform!r}") - if not image or image.startswith("-"): - raise SecurityPolicyError("An existing, fully named Docker image is required") - command = [ - "docker", "run", "--rm", "--network", "none", "--read-only", - "--platform", platform, "--entrypoint", "php", image, "-v", - ] - try: - result = subprocess.run( - command, capture_output=True, text=True, timeout=45, check=True, - ) - except (OSError, subprocess.SubprocessError) as exc: - raise SecurityPolicyError(f"Cannot determine PHP version in {image}: {exc}") from exc - for line in result.stdout.splitlines(): - match = re.match(r"^PHP ([0-9]+\.[0-9]+\.[0-9]+)(?:\s|$)", line.strip()) - if match: - return match.group(1) - raise SecurityPolicyError(f"Cannot parse PHP version from {image}: {result.stdout[:120]!r}") - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - source = parser.add_mutually_exclusive_group(required=True) - source.add_argument("--image", help="Locally built Docker image") - source.add_argument("--version", help="PHP version (for tests and diagnostics)") - parser.add_argument("--platform", default="linux/amd64", choices=("linux/amd64", "linux/arm64")) - parser.add_argument("--releases-file", help="Use a local PHP release feed (offline tests)") - args = parser.parse_args(argv) - try: - version = args.version if args.version is not None else container_php_version(args.image, args.platform) - if args.releases_file: - with open(args.releases_file, encoding="utf-8") as handle: - releases = json.load(handle) - else: - releases = fetch_releases() - print(check_release(version, releases)) - return 0 - except (SecurityPolicyError, OSError, ValueError) as exc: - print(f"PHP runtime security check failed: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/monitor-upstream-images.py b/scripts/monitor-upstream-images.py deleted file mode 100644 index 20ad551..0000000 --- a/scripts/monitor-upstream-images.py +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env python3 -"""Watch Nextcloud base digests and published GHCR provenance. - -Only suggest a rebuild once every maintained upstream base has a supported, -up-to-date PHP release. Published amd64 and arm64 images must identify the -exact upstream base digest and source revision used in their builds. -""" - -import argparse -import importlib.util -import json -import os -import pathlib -import re -import subprocess -import sys - -ROOT = pathlib.Path(__file__).resolve().parents[1] -POLICY_SPEC = importlib.util.spec_from_file_location( - "php_runtime_policy", ROOT / "scripts/check-php-runtime.py" -) -policy = importlib.util.module_from_spec(POLICY_SPEC) -POLICY_SPEC.loader.exec_module(policy) - -PLATFORMS = ("linux/amd64", "linux/arm64") -DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") -APP_IMAGE = "ghcr.io/librecodecoop/nextcloud-docker-app" - - -class InspectionError(Exception): - pass - - -class MissingPlatformError(InspectionError): - pass - - -def inspect(reference, template): - command = ["docker", "buildx", "imagetools", "inspect", reference, "--format", template] - try: - result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=45) - except (OSError, subprocess.SubprocessError) as exc: - raise InspectionError(f"Cannot inspect {reference}: {exc}") from exc - if result.returncode: - message = result.stderr.strip() or result.stdout.strip() - # Only a missing published image is a first-build condition. - if re.search(r"(?:manifest unknown|not found|404 Not Found)", message, re.I): - raise FileNotFoundError(reference) from None - raise InspectionError(f"Cannot inspect {reference}: {message[:250]}") - try: - return json.loads(result.stdout) - except ValueError as exc: - raise InspectionError(f"Invalid OCI metadata for {reference}") from exc - - -def manifest_digest(reference): - document = inspect(reference, "{{json .Manifest}}") - digest = document.get("digest") if isinstance(document, dict) else None - if not isinstance(digest, str) or not DIGEST_PATTERN.fullmatch(digest): - raise InspectionError(f"Missing valid digest for {reference}") - return digest - - -def image_config(reference): - document = inspect(reference, "{{json .Image}}") - if not isinstance(document, dict): - raise InspectionError(f"Missing image configuration for {reference}") - configs = {} - for platform in PLATFORMS: - value = document.get(platform) - if not isinstance(value, dict) or not isinstance(value.get("config"), dict): - raise MissingPlatformError(f"{reference} has no {platform} image configuration") - configs[platform] = value["config"] - return configs - - -def php_version(config): - variables = config.get("Env") - if not isinstance(variables, list): - raise InspectionError("Image configuration has no environment variables") - versions = [part.removeprefix("PHP_VERSION=") for part in variables - if isinstance(part, str) and part.startswith("PHP_VERSION=")] - if len(versions) != 1: - raise InspectionError("Could not determine a unique PHP_VERSION from official base metadata") - policy.parse_version(versions[0]) - return versions[0] - - -def published_status(reference, upstream_digest, revision): - try: - configs = image_config(reference) - except FileNotFoundError: - return ["published image missing"] - except MissingPlatformError as exc: - return [str(exc)] - reasons = [] - for platform, config in configs.items(): - labels = config.get("Labels") or {} - if labels.get("coop.librecode.nextcloud.base.digest") != upstream_digest: - reasons.append(f"{platform}: upstream base digest differs or was not recorded") - if labels.get("org.opencontainers.image.revision") != revision: - reasons.append(f"{platform}: published Git revision differs or was not recorded") - return reasons - - -def evaluate(versions, releases, revision, image_prefix=APP_IMAGE): - if not re.fullmatch(r"[0-9a-f]{40}", revision): - raise InspectionError("Expected the reviewed 40-digit Git revision") - if not isinstance(versions, dict) or not isinstance(versions.get("versions"), list): - raise InspectionError("Invalid reviewed Nextcloud version manifest") - entries = versions["versions"] - if not entries: - raise InspectionError("The reviewed Nextcloud version manifest is empty") - - rows = [] - blocked = False - rebuild = False - for entry in entries: - if not isinstance(entry, dict) or not isinstance(entry.get("major"), int): - raise InspectionError("Malformed Nextcloud version entry") - major = entry["major"] - release = entry.get("release") - if not isinstance(release, str) or not re.fullmatch(rf"{major}\.[0-9]+\.[0-9]+", release): - raise InspectionError(f"Invalid release for Nextcloud {major}") - upstream = f"nextcloud:{release}-fpm" - published = f"{image_prefix}:{major}-fpm" - digest = manifest_digest(upstream) - configs = image_config(f"nextcloud@{digest}") - php_versions = {platform: php_version(config) for platform, config in configs.items()} - problems = [] - for platform, version in php_versions.items(): - try: - policy.check_release(version, releases) - except policy.SecurityPolicyError as exc: - problems.append(f"{platform}: {exc}") - if problems: - blocked = True - status = "Awaiting patched upstream" - reason = "; ".join(problems) - else: - differences = published_status(published, digest, revision) - if differences: - rebuild = True - status = "Rebuild required" - reason = "; ".join(differences) - else: - status = "Up to date" - reason = "Both published platforms match the upstream digest and Git revision" - rows.append({ - "major": major, "release": release, "upstream_digest": digest, - "php": php_versions, "status": status, "reason": reason, - }) - # Publishing stable aliases requires all maintained versions to pass. - return {"rebuild": rebuild and not blocked, "blocked": blocked, "rows": rows} - - -def render_summary(result): - lines = [ - "## Nextcloud upstream image monitor", - "", - "Compares reviewed upstream Docker image digests and actual PHP versions " - "against the metadata of both published GHCR architectures.", - "", - "| Nextcloud | PHP amd64 / arm64 | Status | Details |", - "| --- | --- | --- | --- |", - ] - for row in result["rows"]: - versions = " / ".join(row["php"][platform] for platform in PLATFORMS) - detail = row["reason"].replace("|", "/").replace("\n", " ") - lines.append(f"| {row['release']} | {versions} | {row['status']} | {detail} |") - lines.extend([ - "", f"**Blocked by upstream PHP:** {result['blocked']}", - f"**Rebuild requested:** {result['rebuild']}", "", - "If PHP has a new upstream release but Nextcloud still embeds the old " - "interpreter, wait for the base to change. Rebuilding that same " - "unpatched base would not resolve the vulnerability.", - ]) - return "\n".join(lines) + "\n" - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--versions", default=".github/nextcloud-supported.json") - parser.add_argument("--revision", default=os.environ.get("GITHUB_SHA", "")) - parser.add_argument("--output", help="GitHub Actions output file") - parser.add_argument("--summary", help="GitHub Actions step summary") - parser.add_argument("--report", help="Write Markdown for a tracking issue") - parser.add_argument("--releases-file", help="Use a checked offline release fixture") - args = parser.parse_args(argv) - try: - with open(args.versions, encoding="utf-8") as stream: - versions = json.load(stream) - if args.releases_file: - with open(args.releases_file, encoding="utf-8") as stream: - releases = json.load(stream) - else: - releases = policy.fetch_releases() - result = evaluate(versions, releases, args.revision) - summary = render_summary(result) - print(summary) - if args.output: - with open(args.output, "a", encoding="utf-8") as stream: - stream.write(f"rebuild={str(result['rebuild']).lower()}\n") - stream.write(f"blocked={str(result['blocked']).lower()}\n") - for path in (args.summary, args.report): - if path: - with open(path, "a", encoding="utf-8") as stream: - stream.write(summary) - return 0 - except (OSError, ValueError, InspectionError, policy.SecurityPolicyError) as exc: - print(f"Upstream image inspection failed: {exc}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/scan-images.sh b/scripts/scan-images.sh index e986d7a..b115716 100644 --- a/scripts/scan-images.sh +++ b/scripts/scan-images.sh @@ -18,7 +18,6 @@ fi script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd -- "${script_dir}/.." && pwd)" -php_runtime_checker="${PHP_RUNTIME_CHECK_BIN:-${repo_root}/scripts/check-php-runtime.py}" config="${repo_root}/trivy.yaml" report_dir="${TRIVY_REPORT_DIR:-${repo_root}/trivy-results}" @@ -53,13 +52,6 @@ for image_spec in "$@"; do exit 2 fi - if [[ "$label" == app ]]; then - printf '\nChecking upstream PHP runtime security: %s (%s)\n' "$platform" "$image" - if ! "$php_runtime_checker" --image "$image" --platform "$platform"; then - scan_status=1 - fi - fi - printf '\nScanning %s for %s (%s), table output\n' "$label" "$platform" "$image" if ! "$trivy_bin" image --config "$trivy_config" --platform "$platform" --format table "$image"; then scan_status=1 diff --git a/scripts/select-nextcloud-base.sh b/scripts/select-nextcloud-base.sh new file mode 100755 index 0000000..2a7b77d --- /dev/null +++ b/scripts/select-nextcloud-base.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag=${1:?Usage: select-nextcloud-base.sh -fpm} +[[ $tag =~ ^([0-9]{2,3})[.][0-9]+[.][0-9]+-fpm$ ]] || { + echo "Invalid Nextcloud FPM release tag: $tag" >&2 + exit 2 +} +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +file="$root/.docker/nextcloud-bases/${BASH_REMATCH[1]}/Dockerfile" +[[ -f $file ]] || { echo "No pinned base for $tag" >&2; exit 1; } + +base=$(awk 'NR == 1 && $1 == "FROM" && NF == 2 {print $2}' "$file") +digest="${base##*@sha256:}" +[[ $base == "nextcloud:$tag@sha256:$digest" && $digest =~ ^[a-f0-9]{64}$ ]] || { + echo "Pinned base in $file does not match reviewed release $tag" >&2 + exit 1 +} +printf '%s\n' "$base" diff --git a/tests/nextcloud-base.bats b/tests/nextcloud-base.bats new file mode 100644 index 0000000..26237e8 --- /dev/null +++ b/tests/nextcloud-base.bats @@ -0,0 +1,28 @@ +#!/usr/bin/env bats + +setup() { + cd "$BATS_TEST_DIRNAME/.." +} + +@test "each reviewed Nextcloud release has a pinned, readable base" { + while IFS=$'\t' read -r major release; do + run bash scripts/select-nextcloud-base.sh "${release}-fpm" + [ "$status" -eq 0 ] + [[ "$output" =~ ^nextcloud:${release}-fpm@sha256:[a-f0-9]{64}$ ]] + [ -f ".docker/nextcloud-bases/$major/Dockerfile" ] + [ "$(head -n1 ".docker/nextcloud-bases/$major/Dockerfile")" = "FROM $output" ] + done < <(jq -r '.versions[] | [.major,.release] | @tsv' .github/nextcloud-supported.json) +} + +@test "invalid release tags and unsupported majors are rejected" { + run bash scripts/select-nextcloud-base.sh '35.0.2-fpm;echo INJECTED' + [ "$status" -ne 0 ] + run bash scripts/select-nextcloud-base.sh 36.0.0-fpm + [ "$status" -ne 0 ] +} + +@test "a different patch release cannot silently reuse the previous digest" { + run bash scripts/select-nextcloud-base.sh 35.0.99-fpm + [ "$status" -ne 0 ] + [[ "$output" == *'does not match reviewed release'* ]] +} diff --git a/tests/test-scan-images.sh b/tests/test-scan-images.sh index 1d9c1bb..f551dfe 100644 --- a/tests/test-scan-images.sh +++ b/tests/test-scan-images.sh @@ -56,28 +56,6 @@ fi EOF chmod +x "$bin_dir/trivy" -cat > "$bin_dir/php-runtime-check" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail -image='' -platform='' -while (( $# )); do - case "$1" in - --image) image=$2; shift 2 ;; - --platform) platform=$2; shift 2 ;; - *) printf 'Unexpected PHP checker argument: %s\n' "$1" >&2; exit 2 ;; - esac -done -printf '%s|%s\n' "$platform" "$image" >> "$PHP_RUNTIME_TEST_LOG" -if [[ -n "${PHP_RUNTIME_FAIL_IMAGE:-}" && "$image" == "$PHP_RUNTIME_FAIL_IMAGE" ]]; then - exit 1 -fi -EOF -chmod +x "$bin_dir/php-runtime-check" - -export PHP_RUNTIME_CHECK_BIN="$bin_dir/php-runtime-check" -export PHP_RUNTIME_TEST_LOG="$tmp_root/php-runtime.log" - export TRIVY_BIN="$bin_dir/trivy" export TRIVY_TEST_LOG="$log_file" export TRIVY_REPORT_DIR="$report_dir" @@ -88,7 +66,6 @@ bash "$repo_root/scripts/scan-images.sh" \ test -f "$report_dir/app-linux-amd64.sarif" grep -Fqx 'linux/amd64|table|example/app:amd64' "$log_file" grep -Fqx 'linux/amd64|sarif|example/app:amd64' "$log_file" -grep -Fqx 'linux/amd64|example/app:amd64' "$PHP_RUNTIME_TEST_LOG" export TRIVY_EXPECTED_PLATFORM=linux/arm64 bash "$repo_root/scripts/scan-images.sh" \ @@ -96,7 +73,6 @@ bash "$repo_root/scripts/scan-images.sh" \ test -f "$report_dir/app-linux-arm64.sarif" grep -Fqx 'linux/arm64|table|example/app:arm64' "$log_file" grep -Fqx 'linux/arm64|sarif|example/app:arm64' "$log_file" -grep -Fqx 'linux/arm64|example/app:arm64' "$PHP_RUNTIME_TEST_LOG" if TRIVY_EXPECTED_PLATFORM=linux/arm64 TRIVY_FAIL_IMAGE=example/app:arm64 \ bash "$repo_root/scripts/scan-images.sh" \ @@ -112,19 +88,4 @@ if TRIVY_EXPECTED_PLATFORM=linux/amd64 \ exit 1 fi -# The PHP release policy must block publication even when Trivy succeeds. -if PHP_RUNTIME_FAIL_IMAGE=example/app:arm64 TRIVY_EXPECTED_PLATFORM=linux/arm64 \ - bash "$repo_root/scripts/scan-images.sh" \ - 'app@linux/arm64=example/app:arm64'; then - printf 'expected the runtime policy failure to reject the image\n' >&2 - exit 1 -fi - -# Nginx web images contain no PHP runtime. -before=$(wc -l < "$PHP_RUNTIME_TEST_LOG") -TRIVY_EXPECTED_PLATFORM=linux/amd64 bash "$repo_root/scripts/scan-images.sh" \ - 'web@linux/amd64=example/web:amd64' -after=$(wc -l < "$PHP_RUNTIME_TEST_LOG") -test "$before" -eq "$after" - echo 'scan-images tests passed' diff --git a/tests/test_php_runtime.py b/tests/test_php_runtime.py deleted file mode 100644 index 3944b5c..0000000 --- a/tests/test_php_runtime.py +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env python3 -"""Offline regression tests for the dynamic upstream PHP release policy.""" - -import importlib.util -import io -import json -import pathlib -import tempfile -import unittest -from contextlib import redirect_stderr, redirect_stdout -from unittest import mock - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SPEC = importlib.util.spec_from_file_location("php_runtime_policy", ROOT / "scripts/check-php-runtime.py") -policy = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(policy) - -RELEASES = [ - {"branch": "8.3", "latest": "8.3.36", "state": "security"}, - {"branch": "8.4", "latest": "8.4.27", "state": "stable"}, - {"branch": "8.5", "latest": "8.5.12", "state": "stable"}, - {"branch": "8.1", "latest": "8.1.34", "state": "eol"}, -] - - -class PhpRuntimePolicyTests(unittest.TestCase): - def test_stale_php_is_blocked(self): - with self.assertRaisesRegex(policy.SecurityPolicyError, "older"): - policy.check_release("8.3.35", RELEASES) - - def test_current_php_passes(self): - self.assertIn("supported", policy.check_release("8.3.36", RELEASES)) - self.assertIn("supported", policy.check_release("8.4.27", RELEASES)) - - def test_future_series_requires_no_code_update(self): - releases = RELEASES + [{"branch": "8.6", "latest": "8.6.0", "state": "stable"}] - self.assertIn("supported", policy.check_release("8.6.0", releases)) - - def test_eol_is_blocked_even_at_last_patch(self): - with self.assertRaisesRegex(policy.SecurityPolicyError, "no upstream security support"): - policy.check_release("8.1.34", RELEASES) - - def test_unknown_series_is_blocked(self): - with self.assertRaisesRegex(policy.SecurityPolicyError, "missing or duplicated"): - policy.check_release("9.0.0", RELEASES) - - def test_invalid_feed_is_blocked(self): - for invalid in ([], {}, None): - with self.subTest(feed=invalid), self.assertRaises(policy.SecurityPolicyError): - policy.check_release("8.3.36", invalid) - - def test_bad_release_metadata_is_blocked(self): - with self.assertRaisesRegex(policy.SecurityPolicyError, "Invalid"): - policy.check_release("8.3.36", [{"branch": "8.3", "state": "security", "latest": "8.3.bad"}]) - - def test_nonrelease_and_untrusted_versions_are_blocked(self): - for version in ("8.3.36RC1", "8.3.36-dev", "8.3", "8.3.36\nX"): - with self.subTest(version=version), self.assertRaises(policy.SecurityPolicyError): - policy.check_release(version, RELEASES) - - def test_exact_runtime_is_read_from_image(self): - fake = mock.Mock(stdout="PHP 8.3.36 (cli) (built: Oct 21 2026) (NTS)\n") - with mock.patch.object(policy.subprocess, "run", return_value=fake) as run: - self.assertEqual(policy.container_php_version("scan/app:arm64", "linux/arm64"), "8.3.36") - cmd = run.call_args.args[0] - self.assertEqual(cmd[0], "docker") - self.assertIn("--network", cmd) - self.assertIn("none", cmd) - self.assertIn("--read-only", cmd) - self.assertIn("linux/arm64", cmd) - self.assertIn("scan/app:arm64", cmd) - - def test_malformed_runtime_output_is_blocked(self): - with mock.patch.object(policy.subprocess, "run", return_value=mock.Mock(stdout="not PHP")): - with self.assertRaisesRegex(policy.SecurityPolicyError, "Cannot parse"): - policy.container_php_version("scan/app:amd64", "linux/amd64") - - def test_failed_image_probe_is_blocked(self): - with mock.patch.object(policy.subprocess, "run", side_effect=OSError("docker unavailable")): - with self.assertRaisesRegex(policy.SecurityPolicyError, "Cannot determine"): - policy.container_php_version("scan/app:amd64", "linux/amd64") - - def test_feed_network_failure_is_blocked(self): - with mock.patch.object(policy.urllib.request, "urlopen", side_effect=OSError("network unavailable")): - with self.assertRaisesRegex(policy.SecurityPolicyError, "Cannot read"): - policy.fetch_releases() - - def test_cli_uses_feed_without_any_version_matrix(self): - with tempfile.TemporaryDirectory() as d: - path = pathlib.Path(d) / "releases.json" - path.write_text(json.dumps(RELEASES)) - with redirect_stderr(io.StringIO()): - self.assertEqual(policy.main(["--version", "8.3.35", "--releases-file", str(path)]), 1) - with redirect_stdout(io.StringIO()): - self.assertEqual(policy.main(["--version", "8.3.36", "--releases-file", str(path)]), 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_upstream_monitor.py b/tests/test_upstream_monitor.py deleted file mode 100644 index 5cf3666..0000000 --- a/tests/test_upstream_monitor.py +++ /dev/null @@ -1,170 +0,0 @@ -#!/usr/bin/env python3 -"""Offline unit tests for automatic rebuild decisions.""" - -import importlib.util -import json -import pathlib -import unittest -from unittest import mock - -ROOT = pathlib.Path(__file__).resolve().parents[1] -SPEC = importlib.util.spec_from_file_location( - "upstream_image_monitor", ROOT / "scripts/monitor-upstream-images.py" -) -monitor = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(monitor) - -SHA = "e" * 40 -DIGEST = "sha256:" + "a" * 64 -NEW_DIGEST = "sha256:" + "b" * 64 -VERSIONS = {"versions": [ - {"major": 33, "release": "33.0.10"}, - {"major": 34, "release": "34.0.5"}, -]} -RELEASES = [ - {"branch": "8.4", "latest": "8.4.27", "state": "stable"}, - {"branch": "8.5", "latest": "8.5.12", "state": "stable"}, - {"branch": "8.1", "latest": "8.1.34", "state": "eol"}, -] - - -def configs(php, *, digest=DIGEST, revision=SHA): - return { - arch: { - "Env": ["PHP_VERSION=" + php], - "Labels": { - "coop.librecode.nextcloud.base.digest": digest, - "org.opencontainers.image.revision": revision, - }, - } - for arch in monitor.PLATFORMS - } - - -class MonitoringTests(unittest.TestCase): - def setUp(self): - self.digest = mock.patch.object(monitor, "manifest_digest", return_value=DIGEST) - self.images = mock.patch.object(monitor, "image_config") - self.digest_mock = self.digest.start() - self.images_mock = self.images.start() - self.addCleanup(self.digest.stop) - self.addCleanup(self.images.stop) - - def configure(self, *, base="8.4.27", published=None): - if published is None: - published = configs(base) - - def get_image(ref): - if ref.startswith("nextcloud:") or ref.startswith("nextcloud@"): - return configs(base) - if published == "missing": - raise FileNotFoundError(ref) - return published - self.images_mock.side_effect = get_image - - def test_matching_manifest_and_revision_does_not_rebuild(self): - self.configure() - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - self.assertFalse(result["rebuild"]) - self.assertFalse(result["blocked"]) - self.assertEqual(result["rows"][0]["status"], "Up to date") - - def test_changed_upstream_digest_requests_rebuild(self): - self.configure(published=configs("8.4.27", digest=NEW_DIGEST)) - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - self.assertTrue(result["rebuild"]) - self.assertFalse(result["blocked"]) - - def test_changed_code_revision_retries_failed_publication(self): - self.configure(published=configs("8.4.27", revision="f" * 40)) - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - self.assertTrue(result["rebuild"]) - - def test_missing_published_tag_requests_rebuild(self): - self.configure(published="missing") - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - self.assertTrue(result["rebuild"]) - - def test_legacy_image_without_provenance_requests_rebuild(self): - self.configure(published={arch: {"Env": ["PHP_VERSION=8.4.27"], "Labels": {}} - for arch in monitor.PLATFORMS}) - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - self.assertTrue(result["rebuild"]) - - def test_outdated_upstream_php_blocks_rebuild(self): - self.configure(base="8.4.26") - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - self.assertTrue(result["blocked"]) - self.assertFalse(result["rebuild"]) - self.assertNotIn("ghcr.io", str(self.images_mock.call_args_list)) - - def test_eol_upstream_php_blocks_rebuild(self): - self.configure(base="8.1.34") - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - self.assertTrue(result["blocked"]) - self.assertFalse(result["rebuild"]) - - def test_any_blocked_channel_prevents_partial_alias_promotion(self): - self.digest_mock.side_effect = lambda ref: DIGEST if "33.0.10" in ref else NEW_DIGEST - def get_image(ref): - if ref == "nextcloud@" + DIGEST: - return configs("8.4.27") - if ref == "nextcloud@" + NEW_DIGEST: - return configs("8.5.11") - return configs("8.4.27", digest=NEW_DIGEST) - self.images_mock.side_effect = get_image - result = monitor.evaluate(VERSIONS, RELEASES, SHA) - self.assertTrue(result["blocked"]) - self.assertFalse(result["rebuild"]) - self.assertEqual(result["rows"][0]["status"], "Rebuild required") - self.assertEqual(result["rows"][1]["status"], "Awaiting patched upstream") - - def test_missing_published_platform_requests_rebuild(self): - self.configure() - self.images_mock.side_effect = lambda ref: configs("8.4.27") if ref.startswith("nextcloud:") or ref.startswith("nextcloud@") else self._missing() - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - self.assertTrue(result["rebuild"]) - - @staticmethod - def _missing(): - raise monitor.MissingPlatformError("published image missing linux/arm64") - - def test_missing_arm64_config_is_an_error_not_noop(self): - self.images.stop() - with mock.patch.object(monitor, "inspect", return_value={"linux/amd64": {"config": configs("8.4.27")["linux/amd64"]}}): - with self.assertRaisesRegex(monitor.InspectionError, "linux/arm64"): - monitor.image_config("nextcloud:33.0.10-fpm") - - def test_invalid_manifest_is_an_error_not_noop(self): - for wrong in ({"versions": []}, {"versions": [{"major": 33, "release": "34.0.1"}]}, {}): - with self.subTest(wrong=wrong), self.assertRaises(monitor.InspectionError): - monitor.evaluate(wrong, RELEASES, SHA) - - def test_invalid_git_revision_is_an_error(self): - with self.assertRaisesRegex(monitor.InspectionError, "40-digit"): - monitor.evaluate(VERSIONS, RELEASES, "main") - - def test_summary_has_actionable_reasons(self): - self.configure(base="8.4.26") - result = monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - rendered = monitor.render_summary(result) - self.assertIn("Awaiting patched upstream", rendered) - self.assertIn("8.4.26", rendered) - self.assertIn("Rebuild requested:** False", rendered) - - def test_missing_registry_or_network_fails_closed(self): - self.images_mock.side_effect = monitor.InspectionError("registry unreachable") - with self.assertRaises(monitor.InspectionError): - monitor.evaluate({"versions": [VERSIONS["versions"][0]]}, RELEASES, SHA) - - def test_manifest_digest_is_taken_from_upstream_index(self): - self.digest.stop() - with mock.patch.object(monitor, "inspect", return_value={"digest": DIGEST}): - self.assertEqual(monitor.manifest_digest("nextcloud:33.0.10-fpm"), DIGEST) - with mock.patch.object(monitor, "inspect", return_value={"digest": "invalid"}): - with self.assertRaises(monitor.InspectionError): - monitor.manifest_digest("nextcloud:33.0.10-fpm") - - -if __name__ == "__main__": - unittest.main() From 815f3a0b7f2ce1800da06f22586fe83ff354f8b2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:48:56 -0300 Subject: [PATCH 6/9] refactor(ci): rebuild maintained images daily with fresh bases Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/nextcloud-bases/33/Dockerfile | 1 - .docker/nextcloud-bases/34/Dockerfile | 1 - .docker/nextcloud-bases/35/Dockerfile | 1 - .github/actions/build-and-scan/action.yml | 19 ++++------- .github/dependabot.yml | 11 ------- .../workflows/discover-nextcloud-versions.yml | 33 ++----------------- .github/workflows/docker-image.yml | 5 ++- Makefile | 2 +- docs/maintenance.md | 18 +--------- scripts/select-nextcloud-base.sh | 19 ----------- tests/nextcloud-base.bats | 28 ---------------- 11 files changed, 12 insertions(+), 126 deletions(-) delete mode 100644 .docker/nextcloud-bases/33/Dockerfile delete mode 100644 .docker/nextcloud-bases/34/Dockerfile delete mode 100644 .docker/nextcloud-bases/35/Dockerfile delete mode 100755 scripts/select-nextcloud-base.sh delete mode 100644 tests/nextcloud-base.bats diff --git a/.docker/nextcloud-bases/33/Dockerfile b/.docker/nextcloud-bases/33/Dockerfile deleted file mode 100644 index 69ca651..0000000 --- a/.docker/nextcloud-bases/33/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -FROM nextcloud:33.0.10-fpm@sha256:5263d2ecf8745ba3599e7d0323bbf3ec4758ce0273a47a1989abfa144bbfb94f diff --git a/.docker/nextcloud-bases/34/Dockerfile b/.docker/nextcloud-bases/34/Dockerfile deleted file mode 100644 index 5afd76e..0000000 --- a/.docker/nextcloud-bases/34/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -FROM nextcloud:34.0.5-fpm@sha256:e16fd63e5ce16bbdd64c640e97119dd5c1997c1de0255b59851f144fc9952f07 diff --git a/.docker/nextcloud-bases/35/Dockerfile b/.docker/nextcloud-bases/35/Dockerfile deleted file mode 100644 index 1803742..0000000 --- a/.docker/nextcloud-bases/35/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -FROM nextcloud:35.0.2-fpm@sha256:763a5ad2d98e58ab3b4517eb469301e0483b239edebcdc93997da7f61e89b5b4 diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index 42923d9..e593a35 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -28,17 +28,6 @@ runs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Select Dependabot-managed Nextcloud base image - if: inputs.component == 'app' - id: base - shell: bash - env: - NEXTCLOUD_VERSION: ${{ inputs.nextcloud_version }} - run: | - set -euo pipefail - base_image="$(bash scripts/select-nextcloud-base.sh "$NEXTCLOUD_VERSION")" - echo "image=$base_image" >> "$GITHUB_OUTPUT" - - name: Build app image (linux/amd64) if: inputs.component == 'app' uses: docker/build-push-action@v6 @@ -46,8 +35,9 @@ runs: context: .docker/app platforms: linux/amd64 load: true + pull: true build-args: | - NEXTCLOUD_BASE_IMAGE=${{ steps.base.outputs.image }} + NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} tags: scan/app:amd64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-amd64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-amd64 @@ -59,8 +49,9 @@ runs: context: .docker/app platforms: linux/arm64 load: true + pull: true build-args: | - NEXTCLOUD_BASE_IMAGE=${{ steps.base.outputs.image }} + NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} tags: scan/app:arm64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-arm64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-arm64 @@ -72,6 +63,7 @@ runs: context: .docker/web platforms: linux/amd64 load: true + pull: true tags: scan/web:amd64 cache-from: type=gha,scope=web-amd64 cache-to: type=gha,mode=max,scope=web-amd64 @@ -83,6 +75,7 @@ runs: context: .docker/web platforms: linux/arm64 load: true + pull: true tags: scan/web:arm64 cache-from: type=gha,scope=web-arm64 cache-to: type=gha,mode=max,scope=web-arm64 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2c08278..8c8a80f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,17 +15,6 @@ updates: cooldown: default-days: 7 - # Dependabot updates the explicit tag and digest in these real base files. - - package-ecosystem: docker - directories: - - /.docker/nextcloud-bases/* - schedule: - interval: daily - ignore: - - dependency-name: nextcloud - update-types: - - version-update:semver-major - - package-ecosystem: github-actions directory: / schedule: diff --git a/.github/workflows/discover-nextcloud-versions.yml b/.github/workflows/discover-nextcloud-versions.yml index 9affd7f..ca6ff2d 100644 --- a/.github/workflows/discover-nextcloud-versions.yml +++ b/.github/workflows/discover-nextcloud-versions.yml @@ -42,40 +42,11 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: bash scripts/nextcloud-versions.sh discover - - name: Pin bases for newly approved Nextcloud releases - shell: bash - run: | - set -euo pipefail - while IFS=$'\t' read -r major release; do - file=".docker/nextcloud-bases/${major}/Dockerfile" - tag="nextcloud:${release}-fpm" - if [[ -f "$file" ]] && grep -Fq "FROM ${tag}@sha256:" "$file"; then - continue - fi - digest="$(docker buildx imagetools inspect "$tag" --format '{{.Manifest.Digest}}')" - [[ $digest =~ ^sha256:[0-9a-f]{64}$ ]] || exit 1 - mkdir -p "$(dirname "$file")" - printf 'FROM %s@%s\n' "$tag" "$digest" > "$file" - done < <(jq -r '.versions[] | [.major,.release] | @tsv' .github/nextcloud-supported.json) - for file in .docker/nextcloud-bases/*/Dockerfile; do - major="${file%/Dockerfile}" - major="${major##*/}" - if ! jq -e --argjson major "$major" '.versions | any(.[]; .major == $major)' .github/nextcloud-supported.json > /dev/null; then - rm -- "$file" - rmdir -- "$(dirname "$file")" - fi - done - - - name: Verify approved image base pins - run: bats tests/nextcloud-base.bats - - name: Propose reviewed manifest update id: pull_request uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - add-paths: | - .github/nextcloud-supported.json - .docker/nextcloud-bases/**/Dockerfile + add-paths: .github/nextcloud-supported.json branch: automation/nextcloud-supported-versions delete-branch: true signoff: true @@ -84,7 +55,7 @@ jobs: title: 'chore(ci): synchronize maintained Nextcloud versions' body: | Sync the reviewed Nextcloud version manifest with the upstream - Maintenance and Release Schedule wiki, including pinned base images. + Maintenance and Release Schedule wiki. The discovery job requires official stable release artifacts and amd64/arm64 FPM images. Review version additions/removals, patch diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 29d6bb5..b90c096 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -9,7 +9,7 @@ on: - main workflow_dispatch: schedule: - - cron: '13 4 * * 2' + - cron: '13 4 * * *' permissions: contents: read @@ -48,8 +48,7 @@ jobs: - name: ShellCheck image publication scripts run: | - shellcheck scripts/nextcloud-versions.sh scripts/select-nextcloud-base.sh \ - scripts/publish-images.sh \ + shellcheck scripts/nextcloud-versions.sh scripts/publish-images.sh \ scripts/publish-manifest.sh scripts/push-architecture.sh \ scripts/publish-master-manifest.sh scripts/push-master-architecture.sh diff --git a/Makefile b/Makefile index cb3f713..8f73fa7 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ test-scan-images: bash tests/test-scan-images.sh test-ci-scripts: - bats tests/nextcloud-versions.bats tests/publish-images.bats tests/nextcloud-base.bats + bats tests/nextcloud-versions.bats tests/publish-images.bats test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/docs/maintenance.md b/docs/maintenance.md index 52fb876..550cad6 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -91,22 +91,6 @@ Image metadata should use standard OCI labels wherever possible, including the s Keep tag naming consistent with the official Nextcloud model. Do not introduce `:dev`, `:dev-`, bare `:`, or `:nc-` as new public conventions. The legacy development tag `:35` is not a required compatibility alias; see the consumer analysis in #52. -Dependabot manages Docker and GitHub Actions dependencies, including pinned Nextcloud base images under .docker/nextcloud-bases/. Renovate is limited to regex-managed values Dependabot cannot discover (currently the explicitly pinned Trivy binary version), so the two bots do not manage the same dependency. +Dependabot manages Docker and GitHub Actions dependencies. Renovate is limited to regex-managed values Dependabot cannot discover (currently the explicitly pinned Trivy binary version), so the two bots do not manage the same dependency. The remaining implementation constraints tracked under #47 are to improve metadata when upstream identifiers become available, keep the stable and master publishing contracts aligned, preserve runtime and security gates, and maintain deployment compatibility until replacement approaches are validated. - -### Automatic image-base updates - -Each maintained Nextcloud version has a single-line Dockerfile under -.docker/nextcloud-bases//Dockerfile. The image is pinned to an official -release tag and immutable OCI sha256 digest. Dependabot checks these files -daily and proposes reviewable updates, including a changed digest under the -same tag. Existing CI runs on each PR and publishes only after merge. - -The common application Dockerfile remains shared. The -scripts/select-nextcloud-base.sh helper passes the approved pinned reference -to the build and fails if it differs from the reviewed release manifest. -When a new official Nextcloud patch is approved, the existing release-discovery -workflow also updates the corresponding base pin in its reviewable PR. -It removes obsolete base pins when a maintained Nextcloud major reaches EOL. -Dependabot owns same-tag digest updates; no custom monitoring service runs. diff --git a/scripts/select-nextcloud-base.sh b/scripts/select-nextcloud-base.sh deleted file mode 100755 index 2a7b77d..0000000 --- a/scripts/select-nextcloud-base.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -tag=${1:?Usage: select-nextcloud-base.sh -fpm} -[[ $tag =~ ^([0-9]{2,3})[.][0-9]+[.][0-9]+-fpm$ ]] || { - echo "Invalid Nextcloud FPM release tag: $tag" >&2 - exit 2 -} -root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) -file="$root/.docker/nextcloud-bases/${BASH_REMATCH[1]}/Dockerfile" -[[ -f $file ]] || { echo "No pinned base for $tag" >&2; exit 1; } - -base=$(awk 'NR == 1 && $1 == "FROM" && NF == 2 {print $2}' "$file") -digest="${base##*@sha256:}" -[[ $base == "nextcloud:$tag@sha256:$digest" && $digest =~ ^[a-f0-9]{64}$ ]] || { - echo "Pinned base in $file does not match reviewed release $tag" >&2 - exit 1 -} -printf '%s\n' "$base" diff --git a/tests/nextcloud-base.bats b/tests/nextcloud-base.bats deleted file mode 100644 index 26237e8..0000000 --- a/tests/nextcloud-base.bats +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bats - -setup() { - cd "$BATS_TEST_DIRNAME/.." -} - -@test "each reviewed Nextcloud release has a pinned, readable base" { - while IFS=$'\t' read -r major release; do - run bash scripts/select-nextcloud-base.sh "${release}-fpm" - [ "$status" -eq 0 ] - [[ "$output" =~ ^nextcloud:${release}-fpm@sha256:[a-f0-9]{64}$ ]] - [ -f ".docker/nextcloud-bases/$major/Dockerfile" ] - [ "$(head -n1 ".docker/nextcloud-bases/$major/Dockerfile")" = "FROM $output" ] - done < <(jq -r '.versions[] | [.major,.release] | @tsv' .github/nextcloud-supported.json) -} - -@test "invalid release tags and unsupported majors are rejected" { - run bash scripts/select-nextcloud-base.sh '35.0.2-fpm;echo INJECTED' - [ "$status" -ne 0 ] - run bash scripts/select-nextcloud-base.sh 36.0.0-fpm - [ "$status" -ne 0 ] -} - -@test "a different patch release cannot silently reuse the previous digest" { - run bash scripts/select-nextcloud-base.sh 35.0.99-fpm - [ "$status" -ne 0 ] - [[ "$output" == *'does not match reviewed release'* ]] -} From b2db507f89733896f58370e57d3741b3b6ae1e82 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:10:22 -0300 Subject: [PATCH 7/9] feat(ci): skip unchanged upstream images before scheduled builds Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/web/Dockerfile | 3 +- .github/actions/build-and-scan/action.yml | 38 +++++++++++- .github/workflows/docker-image.yml | 41 +++++++++++-- Makefile | 2 +- docs/maintenance.md | 4 +- scripts/needs-rebuild.sh | 39 ++++++++++++ tests/needs-rebuild.bats | 75 +++++++++++++++++++++++ 7 files changed, 191 insertions(+), 11 deletions(-) create mode 100755 scripts/needs-rebuild.sh create mode 100644 tests/needs-rebuild.bats diff --git a/.docker/web/Dockerfile b/.docker/web/Dockerfile index b719fcc..530415d 100644 --- a/.docker/web/Dockerfile +++ b/.docker/web/Dockerfile @@ -1,4 +1,5 @@ -FROM nginx:alpine +ARG NGINX_BASE_IMAGE=nginx:alpine +FROM ${NGINX_BASE_IMAGE} RUN apk upgrade --no-cache diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index e593a35..fea64a3 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -28,6 +28,24 @@ runs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Resolve immutable upstream base + id: base + shell: bash + env: + COMPONENT: ${{ inputs.component }} + NEXTCLOUD_VERSION: ${{ inputs.nextcloud_version }} + run: | + set -euo pipefail + if [[ "$COMPONENT" == app ]]; then + image="nextcloud:$NEXTCLOUD_VERSION" + else + image=nginx:alpine + fi + digest="$(docker buildx imagetools inspect "$image" --format '{{.Manifest.Digest}}')" + [[ $digest =~ ^sha256:[a-f0-9]{64}$ ]] || exit 1 + echo "image=${image%%:*}@$digest" >> "$GITHUB_OUTPUT" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + - name: Build app image (linux/amd64) if: inputs.component == 'app' uses: docker/build-push-action@v6 @@ -37,7 +55,10 @@ runs: load: true pull: true build-args: | - NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} + NEXTCLOUD_BASE_IMAGE=${{ steps.base.outputs.image }} + labels: | + org.opencontainers.image.base.digest=${{ steps.base.outputs.digest }} + org.opencontainers.image.revision=${{ github.sha }} tags: scan/app:amd64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-amd64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-amd64 @@ -51,7 +72,10 @@ runs: load: true pull: true build-args: | - NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} + NEXTCLOUD_BASE_IMAGE=${{ steps.base.outputs.image }} + labels: | + org.opencontainers.image.base.digest=${{ steps.base.outputs.digest }} + org.opencontainers.image.revision=${{ github.sha }} tags: scan/app:arm64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-arm64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-arm64 @@ -64,6 +88,11 @@ runs: platforms: linux/amd64 load: true pull: true + build-args: | + NGINX_BASE_IMAGE=${{ steps.base.outputs.image }} + labels: | + org.opencontainers.image.base.digest=${{ steps.base.outputs.digest }} + org.opencontainers.image.revision=${{ github.sha }} tags: scan/web:amd64 cache-from: type=gha,scope=web-amd64 cache-to: type=gha,mode=max,scope=web-amd64 @@ -76,6 +105,11 @@ runs: platforms: linux/arm64 load: true pull: true + build-args: | + NGINX_BASE_IMAGE=${{ steps.base.outputs.image }} + labels: | + org.opencontainers.image.base.digest=${{ steps.base.outputs.digest }} + org.opencontainers.image.revision=${{ github.sha }} tags: scan/web:arm64 cache-from: type=gha,scope=web-arm64 cache-to: type=gha,mode=max,scope=web-arm64 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index b90c096..e0d3492 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -9,7 +9,8 @@ on: - main workflow_dispatch: schedule: - - cron: '13 4 * * *' + - cron: '13 4 * * 0,1,3,4,5,6' + - cron: '13 4 * * 2' permissions: contents: read @@ -23,6 +24,31 @@ env: WEB_IMAGE: ghcr.io/librecodecoop/nextcloud-docker-web jobs: + changes: + name: Check upstream image changes + runs-on: ubuntu-latest + outputs: + build: ${{ steps.decision.outputs.build }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@v3 + - name: Check published image provenance + id: decision + env: + APP_IMAGE: ${{ env.APP_IMAGE }} + WEB_IMAGE: ${{ env.WEB_IMAGE }} + SCHEDULE: ${{ github.event.schedule }} + run: | + if [[ "$GITHUB_EVENT_NAME" != schedule || "$SCHEDULE" == '13 4 * * 2' ]]; then + build=true + else + build="$(bash scripts/needs-rebuild.sh "$APP_IMAGE" "$WEB_IMAGE" "$GITHUB_SHA")" + fi + echo "build=$build" >> "$GITHUB_OUTPUT" + echo "Build required: $build" >> "$GITHUB_STEP_SUMMARY" + script-tests: name: Test version and publication scripts runs-on: ubuntu-latest @@ -48,7 +74,8 @@ jobs: - name: ShellCheck image publication scripts run: | - shellcheck scripts/nextcloud-versions.sh scripts/publish-images.sh \ + shellcheck scripts/nextcloud-versions.sh scripts/needs-rebuild.sh \ + scripts/publish-images.sh \ scripts/publish-manifest.sh scripts/push-architecture.sh \ scripts/publish-master-manifest.sh scripts/push-master-architecture.sh @@ -70,7 +97,8 @@ jobs: app: name: app ${{ matrix.tag }} - needs: [script-tests, versions] + needs: [changes, script-tests, versions] + if: needs.changes.outputs.build == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -126,7 +154,8 @@ jobs: web: name: web runtime - needs: script-tests + needs: [changes, script-tests] + if: needs.changes.outputs.build == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -178,8 +207,8 @@ jobs: promote: name: Publish versioned manifests and moving aliases - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: [script-tests, versions, app, web] + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && needs.changes.outputs.build == 'true' + needs: [changes, script-tests, versions, app, web] runs-on: ubuntu-latest permissions: contents: read diff --git a/Makefile b/Makefile index 8f73fa7..5bc8f20 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ test-scan-images: bash tests/test-scan-images.sh test-ci-scripts: - bats tests/nextcloud-versions.bats tests/publish-images.bats + bats tests/nextcloud-versions.bats tests/publish-images.bats tests/needs-rebuild.bats test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/docs/maintenance.md b/docs/maintenance.md index 550cad6..518f7bf 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -59,7 +59,9 @@ Publication produces: Expired EOL months in the approved JSON stop the release workflow instead of silently rebuilding an unsupported version. Existing EOL tags are **not deleted automatically**; they stop being rebuilt after the reviewed inventory changes. -Stable publication runs on pushes to `main`, a weekly rebuild schedule, or manual dispatch on `main`. Pull requests and non-`main` dispatches validate without publishing. +Stable publication runs on pushes to `main`, reviewed workflow dispatches, or a scheduled update. Pull requests and non-`main` dispatches validate without publishing. + +Daily scheduled runs first compare the official Nextcloud and Nginx base image digests with the OCI provenance labels in the published AMD64 and ARM64 GHCR images. If all bases and the Git revision match, the application builds and publication are skipped. On Tuesdays a full rebuild still runs to pick up Debian/Alpine security package updates from `apt-get upgrade` and `apk upgrade`, which do not necessarily change the base digest. All scheduled builds still pass the existing Trivy and Bats release gates. The first run refreshes images lacking provenance labels. Full weekly rebuilds can still result in identical image contents; this check intentionally avoids daily waste without creating a second publication system. ## Development image `master-fpm` diff --git a/scripts/needs-rebuild.sh b/scripts/needs-rebuild.sh new file mode 100755 index 0000000..240554a --- /dev/null +++ b/scripts/needs-rebuild.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Emit true only when a published image is missing or its upstream base changed. +set -euo pipefail + +app_image=${1:?Usage: needs-rebuild.sh } +web_image=${2:?Missing web image} +revision=${3:?Missing Git revision} +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + +[[ $revision =~ ^[a-f0-9]{40}$ ]] || exit 2 + +matches_published() { + local base=$1 target=$2 upstream metadata + upstream=$(docker buildx imagetools inspect "$base" --format '{{.Manifest.Digest}}') || exit 2 + [[ $upstream =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2 + + # Old images without provenance are rebuilt once. Both architectures must match. + metadata=$(docker buildx imagetools inspect "$target" --format '{{json .Image}}' 2>/dev/null) || return 1 + jq -e --arg digest "$upstream" --arg revision "$revision" ' + . as $images | + all(["linux/amd64", "linux/arm64"][]; + $images[.].config.Labels["org.opencontainers.image.base.digest"] == $digest and + $images[.].config.Labels["org.opencontainers.image.revision"] == $revision) + ' <<< "$metadata" > /dev/null +} + +while IFS=$'\t' read -r major release; do + if ! matches_published "nextcloud:${release}-fpm" "${app_image}:${major}-fpm"; then + echo true + exit 0 + fi +done < <(jq -r '.versions[] | [.major, .release] | @tsv' "$root/.github/nextcloud-supported.json") + +if ! matches_published nginx:alpine "${web_image}:main"; then + echo true + exit 0 +fi + +echo false diff --git a/tests/needs-rebuild.bats b/tests/needs-rebuild.bats new file mode 100644 index 0000000..86cda61 --- /dev/null +++ b/tests/needs-rebuild.bats @@ -0,0 +1,75 @@ +#!/usr/bin/env bats + +setup() { + cd "$BATS_TEST_DIRNAME/.." + TEST_DIR="$(mktemp -d)" + export PATH="$TEST_DIR:$PATH" + export CHECK_MODE=matching + SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + cat > "$TEST_DIR/docker" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +image="$4" +if [[ "$6" == '{{.Manifest.Digest}}' ]]; then + [[ "${CHECK_MODE:-}" == registry-failure ]] && exit 1 + printf 'sha256:%064d\n' 1 + exit 0 +fi +[[ "${CHECK_MODE:-}" == missing && "$image" == *35-fpm ]] && exit 1 +digest="sha256:$(printf '%064d' 1)" +revision="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +if [[ "${CHECK_MODE:-}" == outdated && "$image" == *34-fpm ]]; then + digest="sha256:$(printf '%064d' 2)" +fi +if [[ "${CHECK_MODE:-}" == old-revision ]]; then + revision="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +fi +if [[ "${CHECK_MODE:-}" == missing-arm64 ]]; then + platforms='["linux/amd64"]' +else + platforms='["linux/amd64","linux/arm64"]' +fi +jq -cn --arg d "$digest" --arg r "$revision" --argjson p "$platforms" ' + reduce $p[] as $platform ({}; .[$platform] = { + config: {Labels: {"org.opencontainers.image.base.digest": $d, + "org.opencontainers.image.revision": $r}} + })' +MOCK + chmod +x "$TEST_DIR/docker" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +@test "unchanged bases and published architectures skip the build" { + run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" + [ "$status" -eq 0 ] + [ "$output" = false ] +} + +@test "updated base digest requests a rebuild" { + CHECK_MODE=outdated run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" + [ "$status" -eq 0 ] + [ "$output" = true ] +} + +@test "missing or incomplete GHCR images request a rebuild" { + CHECK_MODE=missing run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" + [ "$status" -eq 0 ] + [ "$output" = true ] + CHECK_MODE=missing-arm64 run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" + [ "$status" -eq 0 ] + [ "$output" = true ] +} + +@test "unpublished Git revision requests a rebuild" { + CHECK_MODE=old-revision run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" + [ "$status" -eq 0 ] + [ "$output" = true ] +} + +@test "upstream inspection errors do not report an unchanged image" { + CHECK_MODE=registry-failure run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" + [ "$status" -ne 0 ] +} From 018bafb11cc562e9e9171193e2736d5ce287b911 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:17:10 -0300 Subject: [PATCH 8/9] refactor(ci): update pinned Nextcloud images through Renovate Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/web/Dockerfile | 3 +- .github/actions/build-and-scan/action.yml | 48 +++------------ .github/dependabot.yml | 4 +- .github/nextcloud-supported.json | 6 +- .github/workflows/docker-image.yml | 42 ++----------- Makefile | 2 +- docs/maintenance.md | 21 ++++++- renovate.json | 26 ++++++++ scripts/needs-rebuild.sh | 39 ------------ scripts/nextcloud-versions.sh | 22 ++++--- scripts/publish-images.sh | 2 +- tests/needs-rebuild.bats | 75 ----------------------- tests/nextcloud-versions.bats | 21 ++++++- tests/publish-images.bats | 2 +- 14 files changed, 99 insertions(+), 214 deletions(-) delete mode 100755 scripts/needs-rebuild.sh delete mode 100644 tests/needs-rebuild.bats diff --git a/.docker/web/Dockerfile b/.docker/web/Dockerfile index 530415d..3f634f7 100644 --- a/.docker/web/Dockerfile +++ b/.docker/web/Dockerfile @@ -1,5 +1,4 @@ -ARG NGINX_BASE_IMAGE=nginx:alpine -FROM ${NGINX_BASE_IMAGE} +FROM nginx:alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95a8c2d33d RUN apk upgrade --no-cache diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index fea64a3..558ea74 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -5,7 +5,11 @@ inputs: description: Either app or web required: true nextcloud_version: - description: Official Nextcloud release tag for app builds (e.g. 35.0.2-fpm) + description: Official Nextcloud release tag for cache keys (e.g. 35.0.2-fpm) + required: false + default: '' + nextcloud_base_image: + description: Immutable approved Nextcloud image (tag and digest) required: false default: '' runs: @@ -28,24 +32,6 @@ runs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Resolve immutable upstream base - id: base - shell: bash - env: - COMPONENT: ${{ inputs.component }} - NEXTCLOUD_VERSION: ${{ inputs.nextcloud_version }} - run: | - set -euo pipefail - if [[ "$COMPONENT" == app ]]; then - image="nextcloud:$NEXTCLOUD_VERSION" - else - image=nginx:alpine - fi - digest="$(docker buildx imagetools inspect "$image" --format '{{.Manifest.Digest}}')" - [[ $digest =~ ^sha256:[a-f0-9]{64}$ ]] || exit 1 - echo "image=${image%%:*}@$digest" >> "$GITHUB_OUTPUT" - echo "digest=$digest" >> "$GITHUB_OUTPUT" - - name: Build app image (linux/amd64) if: inputs.component == 'app' uses: docker/build-push-action@v6 @@ -53,12 +39,8 @@ runs: context: .docker/app platforms: linux/amd64 load: true - pull: true build-args: | - NEXTCLOUD_BASE_IMAGE=${{ steps.base.outputs.image }} - labels: | - org.opencontainers.image.base.digest=${{ steps.base.outputs.digest }} - org.opencontainers.image.revision=${{ github.sha }} + NEXTCLOUD_BASE_IMAGE=${{ inputs.nextcloud_base_image }} tags: scan/app:amd64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-amd64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-amd64 @@ -70,12 +52,8 @@ runs: context: .docker/app platforms: linux/arm64 load: true - pull: true build-args: | - NEXTCLOUD_BASE_IMAGE=${{ steps.base.outputs.image }} - labels: | - org.opencontainers.image.base.digest=${{ steps.base.outputs.digest }} - org.opencontainers.image.revision=${{ github.sha }} + NEXTCLOUD_BASE_IMAGE=${{ inputs.nextcloud_base_image }} tags: scan/app:arm64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-arm64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-arm64 @@ -87,12 +65,6 @@ runs: context: .docker/web platforms: linux/amd64 load: true - pull: true - build-args: | - NGINX_BASE_IMAGE=${{ steps.base.outputs.image }} - labels: | - org.opencontainers.image.base.digest=${{ steps.base.outputs.digest }} - org.opencontainers.image.revision=${{ github.sha }} tags: scan/web:amd64 cache-from: type=gha,scope=web-amd64 cache-to: type=gha,mode=max,scope=web-amd64 @@ -104,12 +76,6 @@ runs: context: .docker/web platforms: linux/arm64 load: true - pull: true - build-args: | - NGINX_BASE_IMAGE=${{ steps.base.outputs.image }} - labels: | - org.opencontainers.image.base.digest=${{ steps.base.outputs.digest }} - org.opencontainers.image.revision=${{ github.sha }} tags: scan/web:arm64 cache-from: type=gha,scope=web-arm64 cache-to: type=gha,mode=max,scope=web-arm64 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c8a80f..10e5da3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,9 +11,7 @@ updates: - package-ecosystem: docker directory: /.docker/web schedule: - interval: weekly - cooldown: - default-days: 7 + interval: daily - package-ecosystem: github-actions directory: / diff --git a/.github/nextcloud-supported.json b/.github/nextcloud-supported.json index 7c73f88..845f8d3 100644 --- a/.github/nextcloud-supported.json +++ b/.github/nextcloud-supported.json @@ -4,17 +4,17 @@ "versions": [ { "major": 33, - "release": "33.0.10", + "image": "nextcloud:33.0.10-fpm@sha256:9b564a1f88b8ab80319fcdcb3982de3063d8c66e136121f2ed03274126d6858e", "eol": "2027-02" }, { "major": 34, - "release": "34.0.5", + "image": "nextcloud:34.0.5-fpm@sha256:debf3a90c0efc3a31f3c48e3c211ee3148983361c3d10a6a6bb8c13d7610463d", "eol": "2027-06" }, { "major": 35, - "release": "35.0.2", + "image": "nextcloud:35.0.2-fpm@sha256:2556e045d57fff50d2b50ddd160d2d0730e6d5b6f71c33b903d491baa8444f03", "eol": "2027-09" } ] diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index e0d3492..02784bf 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -8,9 +8,6 @@ on: branches: - main workflow_dispatch: - schedule: - - cron: '13 4 * * 0,1,3,4,5,6' - - cron: '13 4 * * 2' permissions: contents: read @@ -24,31 +21,6 @@ env: WEB_IMAGE: ghcr.io/librecodecoop/nextcloud-docker-web jobs: - changes: - name: Check upstream image changes - runs-on: ubuntu-latest - outputs: - build: ${{ steps.decision.outputs.build }} - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - uses: docker/setup-buildx-action@v3 - - name: Check published image provenance - id: decision - env: - APP_IMAGE: ${{ env.APP_IMAGE }} - WEB_IMAGE: ${{ env.WEB_IMAGE }} - SCHEDULE: ${{ github.event.schedule }} - run: | - if [[ "$GITHUB_EVENT_NAME" != schedule || "$SCHEDULE" == '13 4 * * 2' ]]; then - build=true - else - build="$(bash scripts/needs-rebuild.sh "$APP_IMAGE" "$WEB_IMAGE" "$GITHUB_SHA")" - fi - echo "build=$build" >> "$GITHUB_OUTPUT" - echo "Build required: $build" >> "$GITHUB_STEP_SUMMARY" - script-tests: name: Test version and publication scripts runs-on: ubuntu-latest @@ -74,8 +46,7 @@ jobs: - name: ShellCheck image publication scripts run: | - shellcheck scripts/nextcloud-versions.sh scripts/needs-rebuild.sh \ - scripts/publish-images.sh \ + shellcheck scripts/nextcloud-versions.sh scripts/publish-images.sh \ scripts/publish-manifest.sh scripts/push-architecture.sh \ scripts/publish-master-manifest.sh scripts/push-master-architecture.sh @@ -97,8 +68,7 @@ jobs: app: name: app ${{ matrix.tag }} - needs: [changes, script-tests, versions] - if: needs.changes.outputs.build == 'true' + needs: [script-tests, versions] runs-on: ubuntu-latest strategy: fail-fast: false @@ -118,6 +88,7 @@ jobs: with: component: app nextcloud_version: ${{ matrix.release }}-fpm + nextcloud_base_image: ${{ matrix.image }} - name: Upload amd64 security findings if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' @@ -154,8 +125,7 @@ jobs: web: name: web runtime - needs: [changes, script-tests] - if: needs.changes.outputs.build == 'true' + needs: script-tests runs-on: ubuntu-latest permissions: contents: read @@ -207,8 +177,8 @@ jobs: promote: name: Publish versioned manifests and moving aliases - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && needs.changes.outputs.build == 'true' - needs: [changes, script-tests, versions, app, web] + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: [script-tests, versions, app, web] runs-on: ubuntu-latest permissions: contents: read diff --git a/Makefile b/Makefile index 5bc8f20..8f73fa7 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ test-scan-images: bash tests/test-scan-images.sh test-ci-scripts: - bats tests/nextcloud-versions.bats tests/publish-images.bats tests/needs-rebuild.bats + bats tests/nextcloud-versions.bats tests/publish-images.bats test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/docs/maintenance.md b/docs/maintenance.md index 518f7bf..e05e51c 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -59,9 +59,7 @@ Publication produces: Expired EOL months in the approved JSON stop the release workflow instead of silently rebuilding an unsupported version. Existing EOL tags are **not deleted automatically**; they stop being rebuilt after the reviewed inventory changes. -Stable publication runs on pushes to `main`, reviewed workflow dispatches, or a scheduled update. Pull requests and non-`main` dispatches validate without publishing. - -Daily scheduled runs first compare the official Nextcloud and Nginx base image digests with the OCI provenance labels in the published AMD64 and ARM64 GHCR images. If all bases and the Git revision match, the application builds and publication are skipped. On Tuesdays a full rebuild still runs to pick up Debian/Alpine security package updates from `apt-get upgrade` and `apk upgrade`, which do not necessarily change the base digest. All scheduled builds still pass the existing Trivy and Bats release gates. The first run refreshes images lacking provenance labels. Full weekly rebuilds can still result in identical image contents; this check intentionally avoids daily waste without creating a second publication system. +Stable publication runs on pushes to `main` or manual dispatch on `main`. Pull requests and non-`main` dispatches validate without publishing. ## Development image `master-fpm` @@ -96,3 +94,20 @@ Keep tag naming consistent with the official Nextcloud model. Do not introduce ` Dependabot manages Docker and GitHub Actions dependencies. Renovate is limited to regex-managed values Dependabot cannot discover (currently the explicitly pinned Trivy binary version), so the two bots do not manage the same dependency. The remaining implementation constraints tracked under #47 are to improve metadata when upstream identifiers become available, keep the stable and master publishing contracts aligned, preserve runtime and security gates, and maintain deployment compatibility until replacement approaches are validated. + + +## Automatic Docker base updates + +The supported Nextcloud JSON is the source of truth for the immutable Docker +base references. Renovate updates each supported image tag and digest in a +reviewable pull request. New major versions and EOL transitions remain managed +by the existing maintenance-wiki discovery workflow, which preserves the +Renovate-managed references for already-supported versions. + +Dependabot manages the independent Nginx Dockerfile (also digest-pinned) and +GitHub Actions. The stable Docker image workflow runs on pull requests and +reviewed merges to main. It has no scheduled rebuild. + +The separate maintenance-wiki discovery cron is only for changes to the +Nextcloud support inventory; it does not build or publish images. Docker base +updates alone do not detect changes in unpinned APT/APK packages. diff --git a/renovate.json b/renovate.json index d705793..218218c 100644 --- a/renovate.json +++ b/renovate.json @@ -15,6 +15,32 @@ "# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+version: (?v?\\d+\\.\\d+\\.\\d+)" ], "versioningTemplate": "semver-coerced" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.github\\/nextcloud-supported\\.json$/" + ], + "matchStrings": [ + "\"image\"\\s*:\\s*\"nextcloud:(?\\d+\\.\\d+\\.\\d+-fpm)@(?sha256:[a-f0-9]{64})\"" + ], + "datasourceTemplate": "docker", + "depNameTemplate": "nextcloud", + "versioningTemplate": "docker" + } + ], + "packageRules": [ + { + "matchManagers": [ + "custom.regex" + ], + "matchPackageNames": [ + "nextcloud" + ], + "matchUpdateTypes": [ + "major" + ], + "enabled": false } ] } diff --git a/scripts/needs-rebuild.sh b/scripts/needs-rebuild.sh deleted file mode 100755 index 240554a..0000000 --- a/scripts/needs-rebuild.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# Emit true only when a published image is missing or its upstream base changed. -set -euo pipefail - -app_image=${1:?Usage: needs-rebuild.sh } -web_image=${2:?Missing web image} -revision=${3:?Missing Git revision} -root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) - -[[ $revision =~ ^[a-f0-9]{40}$ ]] || exit 2 - -matches_published() { - local base=$1 target=$2 upstream metadata - upstream=$(docker buildx imagetools inspect "$base" --format '{{.Manifest.Digest}}') || exit 2 - [[ $upstream =~ ^sha256:[a-f0-9]{64}$ ]] || exit 2 - - # Old images without provenance are rebuilt once. Both architectures must match. - metadata=$(docker buildx imagetools inspect "$target" --format '{{json .Image}}' 2>/dev/null) || return 1 - jq -e --arg digest "$upstream" --arg revision "$revision" ' - . as $images | - all(["linux/amd64", "linux/arm64"][]; - $images[.].config.Labels["org.opencontainers.image.base.digest"] == $digest and - $images[.].config.Labels["org.opencontainers.image.revision"] == $revision) - ' <<< "$metadata" > /dev/null -} - -while IFS=$'\t' read -r major release; do - if ! matches_published "nextcloud:${release}-fpm" "${app_image}:${major}-fpm"; then - echo true - exit 0 - fi -done < <(jq -r '.versions[] | [.major, .release] | @tsv' "$root/.github/nextcloud-supported.json") - -if ! matches_published nginx:alpine "${web_image}:main"; then - echo true - exit 0 -fi - -echo false diff --git a/scripts/nextcloud-versions.sh b/scripts/nextcloud-versions.sh index 49e8933..edda7d5 100644 --- a/scripts/nextcloud-versions.sh +++ b/scripts/nextcloud-versions.sh @@ -53,10 +53,10 @@ validate_manifest() { ($root.versions | type == "array" and length >= 1 and length <= 5) and ($root.versions | all(.[]; type == "object" and - (keys == ["eol","major","release"]) and + (keys == ["eol","image","major"]) and (.major | type == "number" and . >= 20 and . <= 99 and . == floor) and - (.release | type == "string" and test("^[0-9]{2,3}[.][0-9]+[.][0-9]+$")) and - ((.release | split(".")[0] | tonumber) == .major) and + (.image | type == "string" and test("^nextcloud:[0-9]{2,3}[.][0-9]+[.][0-9]+-fpm@sha256:[a-f0-9]{64}$")) and + ((.image | capture("^nextcloud:(?[0-9]{2,3})[.]").major | tonumber) == .major) and (.eol | type == "string" and test("^20[0-9]{2}-(0[1-9]|1[0-2])$") and . >= $month) )) and ([$root.versions[].major] as $majors | @@ -87,12 +87,13 @@ verify_release() { (.images | any(.[]; .os == "linux" and .architecture == "amd64")) and (.images | any(.[]; .os == "linux" and .architecture == "arm64")) ' <<< "$docker_tag" > /dev/null || error "Official image nextcloud:${release}-fpm lacks amd64 or arm64" + jq -er '.digest | select(test("^sha256:[a-f0-9]{64}$"))' <<< "$docker_tag" || error "No valid digest for nextcloud:${release}-fpm" } case "$command_name" in matrix) validate_manifest "$manifest" - matrix=$(jq -c '{include: [.versions[] | {major, tag: ((.major | tostring) + "-fpm"), release}]}' "$manifest") + matrix=$(jq -c '{include: [.versions[] | {major, tag: ((.major | tostring) + "-fpm"), image, release: (.image | split(":")[1] | split("-fpm@")[0])}]}' "$manifest") if [[ -n $github_output ]]; then printf 'matrix=%s\n' "$matrix" >> "$github_output" else @@ -116,14 +117,21 @@ case "$command_name" in [[ $released =~ ^20[0-9]{2}-[0-9]{2}-[0-9]{2}$ && $released < $today || $released == "$today" ]] \ || error "Nextcloud $major release date is invalid or in the future: $released" [[ $(date -u -d "$released" +%F 2>/dev/null) == "$released" ]] || error "Invalid release date: $released" - if [[ $skip_upstream == false ]]; then verify_release "$release"; fi + digest="sha256:$(printf "%064d" 0)" + if [[ $skip_upstream == false ]]; then digest=$(verify_release "$release"); fi + image='' + if [[ -f $manifest ]]; then + image=$(jq -r --argjson major "$major" '.versions[]? | select(.major == $major) | .image // empty' "$manifest") + fi + if [[ -z $image ]]; then image="nextcloud:${release}-fpm@${digest}"; fi + printf '%s\t%s\t%s\n' "$major" "$image" "$eol" >> "$tmpdir/images.tsv" done < "$tmpdir/releases.tsv" jq -Rn --arg source "$source_url" ' {schema_version: 1, source: $source, versions: ([inputs | split("\t") | - {major: (.[0] | tonumber), release: .[1], eol: .[2]}] | sort_by(.major))} - ' < "$tmpdir/releases.tsv" > "$tmpdir/manifest.json" + {major: (.[0] | tonumber), image: .[1], eol: .[2]}] | sort_by(.major))} + ' < "$tmpdir/images.tsv" > "$tmpdir/manifest.json" validate_manifest "$tmpdir/manifest.json" if [[ ! -f $manifest ]] || ! cmp -s "$tmpdir/manifest.json" "$manifest"; then diff --git a/scripts/publish-images.sh b/scripts/publish-images.sh index 000a757..db26a18 100644 --- a/scripts/publish-images.sh +++ b/scripts/publish-images.sh @@ -47,7 +47,7 @@ promote() { publish "$app_image" "${major}-fpm" "$prefix" publish "$app_image" "${release}-fpm" "$prefix" latest_prefix=$prefix - done < <(jq -r '.versions[] | [.major, .release] | @tsv' "$manifest_file") + done < <(jq -r '.versions[] | [.major, (.image | split(":")[1] | split("-fpm@")[0])] | @tsv' "$manifest_file") [[ -n ${latest_prefix:-} ]] || fail "No approved stable Nextcloud major" publish "$app_image" stable-fpm "$latest_prefix" diff --git a/tests/needs-rebuild.bats b/tests/needs-rebuild.bats deleted file mode 100644 index 86cda61..0000000 --- a/tests/needs-rebuild.bats +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env bats - -setup() { - cd "$BATS_TEST_DIRNAME/.." - TEST_DIR="$(mktemp -d)" - export PATH="$TEST_DIR:$PATH" - export CHECK_MODE=matching - SHA="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - cat > "$TEST_DIR/docker" <<'MOCK' -#!/usr/bin/env bash -set -euo pipefail -image="$4" -if [[ "$6" == '{{.Manifest.Digest}}' ]]; then - [[ "${CHECK_MODE:-}" == registry-failure ]] && exit 1 - printf 'sha256:%064d\n' 1 - exit 0 -fi -[[ "${CHECK_MODE:-}" == missing && "$image" == *35-fpm ]] && exit 1 -digest="sha256:$(printf '%064d' 1)" -revision="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -if [[ "${CHECK_MODE:-}" == outdated && "$image" == *34-fpm ]]; then - digest="sha256:$(printf '%064d' 2)" -fi -if [[ "${CHECK_MODE:-}" == old-revision ]]; then - revision="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -fi -if [[ "${CHECK_MODE:-}" == missing-arm64 ]]; then - platforms='["linux/amd64"]' -else - platforms='["linux/amd64","linux/arm64"]' -fi -jq -cn --arg d "$digest" --arg r "$revision" --argjson p "$platforms" ' - reduce $p[] as $platform ({}; .[$platform] = { - config: {Labels: {"org.opencontainers.image.base.digest": $d, - "org.opencontainers.image.revision": $r}} - })' -MOCK - chmod +x "$TEST_DIR/docker" -} - -teardown() { - rm -rf "$TEST_DIR" -} - -@test "unchanged bases and published architectures skip the build" { - run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" - [ "$status" -eq 0 ] - [ "$output" = false ] -} - -@test "updated base digest requests a rebuild" { - CHECK_MODE=outdated run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" - [ "$status" -eq 0 ] - [ "$output" = true ] -} - -@test "missing or incomplete GHCR images request a rebuild" { - CHECK_MODE=missing run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" - [ "$status" -eq 0 ] - [ "$output" = true ] - CHECK_MODE=missing-arm64 run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" - [ "$status" -eq 0 ] - [ "$output" = true ] -} - -@test "unpublished Git revision requests a rebuild" { - CHECK_MODE=old-revision run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" - [ "$status" -eq 0 ] - [ "$output" = true ] -} - -@test "upstream inspection errors do not report an unchanged image" { - CHECK_MODE=registry-failure run bash scripts/needs-rebuild.sh ghcr.io/example/app ghcr.io/example/web "$SHA" - [ "$status" -ne 0 ] -} diff --git a/tests/nextcloud-versions.bats b/tests/nextcloud-versions.bats index cf3bb7d..85527d3 100644 --- a/tests/nextcloud-versions.bats +++ b/tests/nextcloud-versions.bats @@ -35,7 +35,7 @@ discover_fixture() { run discover_fixture 2026-supported 2026-10-20 [ "$status" -eq 0 ] [ "$(jq -r '[.versions[].major] | join(",")' "$inventory")" = 33,34,35 ] - [ "$(jq -r '.versions[-1].release' "$inventory")" = 35.0.2 ] + [[ "$(jq -r '.versions[-1].image' "$inventory")" == nextcloud:35.0.2-fpm@sha256:* ]] } @test "historical EOL formatting variations do not change the supported majors" { run discover_fixture 2025-eol-structure 2025-10-27 @@ -103,7 +103,7 @@ case "$url" in ;; *hub.docker.com*) tag="${url##*/}" - printf '{"name":"%s","images":[{"os":"linux","architecture":"amd64"},{"os":"linux","architecture":"arm64"}]}\n' "$tag" + printf '{"name":"%s","digest":"sha256:%064d","images":[{"os":"linux","architecture":"amd64"},{"os":"linux","architecture":"arm64"}]}\n' "$tag" 0 ;; *) exit 1 ;; esac @@ -132,3 +132,20 @@ MOCK [[ "$output" == *'lacks amd64 or arm64'* ]] [ ! -f "$inventory" ] } + +@test "the manifest rejects an altered or incomplete Docker digest" { + discover_fixture 2026-supported 2026-10-20 + jq '.versions[1].image = "nextcloud:34.0.5-fpm@sha256:invalid"' "$inventory" > "$TEST_ROOT/invalid.json" + run bash "$script" matrix --file "$TEST_ROOT/invalid.json" --today 2026-10-20 + [ "$status" -ne 0 ] +} + +@test "wiki discovery preserves Renovate-updated image refs" { + discover_fixture 2026-supported 2026-10-20 + jq '.versions[2].image = "nextcloud:35.0.3-fpm@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' "$inventory" > "$TEST_ROOT/renovate.json" + cp "$TEST_ROOT/renovate.json" "$inventory" + discover_fixture 2026-supported 2026-10-20 + [ "$(jq -r '.versions[2].image' "$inventory")" = "nextcloud:35.0.3-fpm@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ] + run bash "$script" matrix --file "$inventory" --today 2026-10-20 + [ "$status" -eq 0 ] +} diff --git a/tests/publish-images.bats b/tests/publish-images.bats index f0ce99e..36d26b6 100644 --- a/tests/publish-images.bats +++ b/tests/publish-images.bats @@ -58,7 +58,7 @@ teardown() { grep -Fx "buildx imagetools create --tag ${WEB_IMAGE}:latest ${WEB_IMAGE}:sha-${SHA}-web-amd64 ${WEB_IMAGE}:sha-${SHA}-web-arm64" "$DOCKER_COMMAND_LOG" } @test "latest follows Nextcloud 36 when approved, without code changes" { - jq '.versions = (.versions[1:] + [{"major":36,"release":"36.0.0","eol":"2099-12"}])' \ + jq '.versions = (.versions[1:] + [{"major":36,"image":"nextcloud:36.0.0-fpm@sha256:0000000000000000000000000000000000000000000000000000000000000000","eol":"2099-12"}])' \ "$INVENTORY" > "$TEST_ROOT/next.json" run bash "$script" promote "$APP_IMAGE" "$WEB_IMAGE" "$SHA" "$TEST_ROOT/next.json" [ "$status" -eq 0 ] From 1f2d8c36bb78b6f35f5a973bede4c9e8f8531a1c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:19:58 -0300 Subject: [PATCH 9/9] fix(ci): use complete Nginx image digest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/web/Dockerfile | 2 +- tests/nextcloud-versions.bats | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.docker/web/Dockerfile b/.docker/web/Dockerfile index 3f634f7..46aa759 100644 --- a/.docker/web/Dockerfile +++ b/.docker/web/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95a8c2d33d +FROM nginx:alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d RUN apk upgrade --no-cache diff --git a/tests/nextcloud-versions.bats b/tests/nextcloud-versions.bats index 85527d3..ab5a7ee 100644 --- a/tests/nextcloud-versions.bats +++ b/tests/nextcloud-versions.bats @@ -149,3 +149,8 @@ MOCK run bash "$script" matrix --file "$inventory" --today 2026-10-20 [ "$status" -eq 0 ] } + +@test "web Dockerfile uses a complete SHA256 digest" { + run grep -Eq '^FROM nginx:alpine@sha256:[a-f0-9]{64}$' .docker/web/Dockerfile + [ "$status" -eq 0 ] +}