From 50a282573e49290299f5a899910b5d650f2f6534 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:50:32 +0000 Subject: [PATCH 01/10] fix(ci): publish versioned Nextcloud images and unblock master pipeline Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .docker/app/Dockerfile | 1 + .github/workflows/docker-image.yml | 55 +++++++++------------ .github/workflows/nextcloud-development.yml | 5 ++ 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/.docker/app/Dockerfile b/.docker/app/Dockerfile index 3a94750..b4a4a06 100644 --- a/.docker/app/Dockerfile +++ b/.docker/app/Dockerfile @@ -25,6 +25,7 @@ RUN set -eux; \ FROM ${NEXTCLOUD_SOURCE} AS runtime RUN apt-get update \ + && apt-get upgrade -y \ && apt-get install -y --no-install-recommends \ locales \ poppler-utils \ diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 70f5c4d..520d506 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -7,6 +7,7 @@ on: pull_request: branches: - main + workflow_dispatch: permissions: contents: read @@ -18,6 +19,10 @@ jobs: verify: if: github.event_name == 'pull_request' runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + nextcloud_version: ['34-fpm', '35-fpm'] permissions: contents: read @@ -26,31 +31,25 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - - name: Read Nextcloud version - id: nextcloud_version - run: | - version=$(grep -m1 '^NEXTCLOUD_VERSION=' .env.example | cut -d= -f2-) - if [ -z "$version" ]; then - echo "NEXTCLOUD_VERSION is missing from .env.example" >&2 - exit 1 - fi - echo "value=$version" >> "$GITHUB_OUTPUT" - - name: Build and scan runtime images uses: ./.github/actions/build-and-scan with: - nextcloud_version: ${{ steps.nextcloud_version.outputs.value }} + nextcloud_version: ${{ matrix.nextcloud_version }} - name: Save SARIF reports uses: actions/upload-artifact@v6 with: - name: trivy-sarif-reports + name: trivy-sarif-reports-${{ matrix.nextcloud_version }} path: trivy-results/*.sarif if-no-files-found: warn publish: - if: github.event_name == 'push' + if: github.event_name != 'pull_request' runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + nextcloud_version: ['34-fpm', '35-fpm'] permissions: contents: read @@ -61,16 +60,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v7 - - name: Read Nextcloud version - id: nextcloud_version - run: | - version=$(grep -m1 '^NEXTCLOUD_VERSION=' .env.example | cut -d= -f2-) - if [ -z "$version" ]; then - echo "NEXTCLOUD_VERSION is missing from .env.example" >&2 - exit 1 - fi - echo "value=$version" >> "$GITHUB_OUTPUT" - - name: Normalize repository name id: repo_name run: echo "value=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" @@ -78,7 +67,7 @@ jobs: - name: Build and scan runtime images uses: ./.github/actions/build-and-scan with: - nextcloud_version: ${{ steps.nextcloud_version.outputs.value }} + nextcloud_version: ${{ matrix.nextcloud_version }} - name: Upload app amd64 SARIF to GitHub code scanning if: hashFiles('trivy-results/app-linux-amd64.sarif') != '' @@ -111,7 +100,7 @@ jobs: - name: Save SARIF reports uses: actions/upload-artifact@v6 with: - name: trivy-sarif-reports + name: trivy-sarif-reports-${{ matrix.nextcloud_version }} path: trivy-results/*.sarif if-no-files-found: warn @@ -126,7 +115,7 @@ jobs: env: APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web - IMAGE_VERSION: sha-${{ github.sha }} + IMAGE_VERSION: sha-${{ github.sha }}-${{ matrix.nextcloud_version }} run: | set -euo pipefail @@ -144,16 +133,18 @@ jobs: env: APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web - IMAGE_VERSION: sha-${{ github.sha }} + IMAGE_VERSION: sha-${{ github.sha }}-${{ matrix.nextcloud_version }} run: | set -euo pipefail docker buildx imagetools create \ - --tag "${APP_IMAGE}:latest" \ + --tag "${APP_IMAGE}:${{ matrix.nextcloud_version }}" \ "${APP_IMAGE}:${IMAGE_VERSION}-amd64" \ "${APP_IMAGE}:${IMAGE_VERSION}-arm64" - docker buildx imagetools create \ - --tag "${WEB_IMAGE}:latest" \ - "${WEB_IMAGE}:${IMAGE_VERSION}-amd64" \ - "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" + if [ "${{ matrix.nextcloud_version }}" = "35-fpm" ]; then + docker buildx imagetools create \ + --tag "${WEB_IMAGE}:main" \ + "${WEB_IMAGE}:${IMAGE_VERSION}-amd64" \ + "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" + fi diff --git a/.github/workflows/nextcloud-development.yml b/.github/workflows/nextcloud-development.yml index e74ef0d..fbe90aa 100644 --- a/.github/workflows/nextcloud-development.yml +++ b/.github/workflows/nextcloud-development.yml @@ -176,6 +176,11 @@ jobs: packages: write steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 From 6441e90bc9b35e1a1afc15ff03c907e0b1a7f0d3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:21:38 +0000 Subject: [PATCH 02/10] fix(ci): include supported Nextcloud 33 images in release matrix Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/docker-image.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 520d506..1055ff6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - nextcloud_version: ['34-fpm', '35-fpm'] + nextcloud_version: ['33-fpm', '34-fpm', '35-fpm'] permissions: contents: read @@ -49,7 +49,7 @@ jobs: strategy: fail-fast: false matrix: - nextcloud_version: ['34-fpm', '35-fpm'] + nextcloud_version: ['33-fpm', '34-fpm', '35-fpm'] permissions: contents: read From aa3b581320a5f8658c3b06e73453431a5c10f05a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:24:39 +0000 Subject: [PATCH 03/10] fix(ci): separate Code Scanning results by Nextcloud version Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/workflows/docker-image.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 1055ff6..11fcdcf 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -74,28 +74,28 @@ jobs: uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results/app-linux-amd64.sarif - category: trivy-app-linux-amd64 + category: trivy-app-linux-amd64-${{ matrix.nextcloud_version }} - name: Upload app arm64 SARIF to GitHub code scanning if: hashFiles('trivy-results/app-linux-arm64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results/app-linux-arm64.sarif - category: trivy-app-linux-arm64 + category: trivy-app-linux-arm64-${{ matrix.nextcloud_version }} - name: Upload web amd64 SARIF to GitHub code scanning if: hashFiles('trivy-results/web-linux-amd64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results/web-linux-amd64.sarif - category: trivy-web-linux-amd64 + category: trivy-web-linux-amd64-${{ matrix.nextcloud_version }} - name: Upload web arm64 SARIF to GitHub code scanning if: hashFiles('trivy-results/web-linux-arm64.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results/web-linux-arm64.sarif - category: trivy-web-linux-arm64 + category: trivy-web-linux-arm64-${{ matrix.nextcloud_version }} - name: Save SARIF reports uses: actions/upload-artifact@v6 From d83b80e37258406eb1aa3ee868c4642cf1d8538a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:53:47 +0000 Subject: [PATCH 04/10] feat(ci): discover and review supported Nextcloud releases Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/nextcloud-supported.json | 21 ++ .../workflows/discover-nextcloud-versions.yml | 63 +++++ .gitignore | 2 + Makefile | 5 +- scripts/nextcloud_versions.py | 229 ++++++++++++++++++ tests/fixtures/2023-maintained.md | 28 +++ tests/fixtures/2024-maintained.md | 30 +++ tests/fixtures/2024-malformed.md | 22 ++ tests/fixtures/2025-eol-overlap.md | 31 +++ tests/fixtures/2025-supported.md | 30 +++ tests/fixtures/2026-supported.md | 31 +++ tests/fixtures/2026-unreleased.md | 30 +++ tests/test_nextcloud_versions.py | 125 ++++++++++ 13 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 .github/nextcloud-supported.json create mode 100644 .github/workflows/discover-nextcloud-versions.yml create mode 100644 scripts/nextcloud_versions.py create mode 100644 tests/fixtures/2023-maintained.md create mode 100644 tests/fixtures/2024-maintained.md create mode 100644 tests/fixtures/2024-malformed.md create mode 100644 tests/fixtures/2025-eol-overlap.md create mode 100644 tests/fixtures/2025-supported.md create mode 100644 tests/fixtures/2026-supported.md create mode 100644 tests/fixtures/2026-unreleased.md create mode 100644 tests/test_nextcloud_versions.py diff --git a/.github/nextcloud-supported.json b/.github/nextcloud-supported.json new file mode 100644 index 0000000..7c73f88 --- /dev/null +++ b/.github/nextcloud-supported.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "source": "https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule", + "versions": [ + { + "major": 33, + "release": "33.0.10", + "eol": "2027-02" + }, + { + "major": 34, + "release": "34.0.5", + "eol": "2027-06" + }, + { + "major": 35, + "release": "35.0.2", + "eol": "2027-09" + } + ] +} diff --git a/.github/workflows/discover-nextcloud-versions.yml b/.github/workflows/discover-nextcloud-versions.yml new file mode 100644 index 0000000..df18c17 --- /dev/null +++ b/.github/workflows/discover-nextcloud-versions.yml @@ -0,0 +1,63 @@ +name: Discover maintained Nextcloud versions + +on: + schedule: + - cron: '21 5,17 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + actions: write + +concurrency: + group: nextcloud-wiki-discovery + cancel-in-progress: false + +jobs: + discover: + runs-on: ubuntu-latest + steps: + - name: Checkout reviewed branch + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Test wiki parser and publication policy + run: make test-ci-scripts + + - name: Discover and verify maintained versions + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 scripts/nextcloud_versions.py discover + + - 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 + branch: automation/nextcloud-supported-versions + delete-branch: true + signoff: true + sign-commits: true + commit-message: 'chore(ci): synchronize maintained Nextcloud versions' + title: 'chore(ci): synchronize maintained Nextcloud versions' + body: | + Sync the reviewed Nextcloud version manifest with the upstream + Maintenance and Release Schedule wiki. + + The discovery job requires official stable release artifacts and + amd64/arm64 FPM images. Review version additions/removals, patch + upgrades and EOL transitions before merging. Do not auto-merge. + + The source is the upstream server wiki. This PR does not publish + any Docker images. + labels: dependencies + + # GITHUB_TOKEN pushes do not automatically trigger push/PR workflows. + # workflow_dispatch does trigger; publishing is forbidden off main. + - name: Validate generated PR branch + if: contains(fromJSON('["created","updated"]'), steps.pull_request.outputs.pull-request-operation) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh workflow run docker-image.yml --repo "$GITHUB_REPOSITORY" --ref automation/nextcloud-supported-versions diff --git a/.gitignore b/.gitignore index cd252ac..e9f3a04 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ docker-compose.override.yml .trivy-cache/ trivy-results/ +__pycache__/ +*.pyc diff --git a/Makefile b/Makefile index 95454a2..4c16227 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ COMPOSE ?= docker compose GARAGES3_COMPOSE_FILE ?= docker-compose-garages3.yml APP_TEST_IMAGE ?= nextcloud-app:acceptance -.PHONY: up-garages3 down-garages3 bootstrap-garages3 garage-status-garages3 start-garages3 wait-nextcloud-garages3 setup-garages3 test-scan-images test-development-image-scripts test-ncdd test-app-image test-current-app-image scan-images +.PHONY: up-garages3 down-garages3 bootstrap-garages3 garage-status-garages3 start-garages3 wait-nextcloud-garages3 setup-garages3 test-scan-images test-ci-scripts test-development-image-scripts test-ncdd test-app-image test-current-app-image scan-images up-garages3: $(COMPOSE) -f $(GARAGES3_COMPOSE_FILE) up -d garage @@ -30,6 +30,9 @@ setup-garages3: test-scan-images: bash tests/test-scan-images.sh +test-ci-scripts: + python3 -m unittest discover -s tests -p 'test_*.py' -v + test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/scripts/nextcloud_versions.py b/scripts/nextcloud_versions.py new file mode 100644 index 0000000..f2b2bbb --- /dev/null +++ b/scripts/nextcloud_versions.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +"""Discover maintained Nextcloud releases and validate the reviewed CI manifest.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +SOURCE = "https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule" +MARKDOWN_URL = "https://raw.githubusercontent.com/wiki/nextcloud/server/Maintenance-and-Release-Schedule.md" +DEFAULT_MANIFEST = Path(".github/nextcloud-supported.json") +SECTION = re.compile(r"^#{1,6}\s+Currently\s+(?:supported|maintained)\s+versions?\s*$", re.I) +HEADING = re.compile(r"^#{1,3}\s+") +RELEASE = re.compile(r"^\d{2,3}\.\d+\.\d+$") +REQUIRED_COLUMNS = {"version code", "release date", "end of life", "current version"} + + +class VersionError(ValueError): + """Unexpected upstream or manifest structure; never guess a release list.""" + + +def cells(line: str) -> list[str]: + return [re.sub(r"\s+", " ", cell.strip().replace("**", "").replace(chr(96), "")) + for cell in line.strip().strip("|").split("|")] + + +def table_after(lines: list[str], start: int, end: int) -> list[dict[str, str]]: + for index in range(start, end - 1): + if not lines[index].lstrip().startswith("|"): + continue + header = [col.lower() for col in cells(lines[index])] + if not REQUIRED_COLUMNS.issubset(set(header)): + continue + separator = cells(lines[index + 1]) + if len(separator) != len(header) or not all(re.fullmatch(r":?-{3,}:?", col) for col in separator): + raise VersionError("Supported-version table separator has changed") + rows = [] + for line in lines[index + 2:end]: + if not line.lstrip().startswith("|"): + break + values = cells(line) + if len(values) != len(header): + raise VersionError("Supported-version table row has a different column count") + rows.append(dict(zip(header, values))) + if not rows: + raise VersionError("Supported-version table has no data rows") + return rows + raise VersionError("Supported-version table or required columns not found") + + +def parse_wiki(markdown: str, today: dt.date | None = None) -> dict: + today = today or dt.datetime.now(dt.timezone.utc).date() + lines = markdown.splitlines() + start = next((i for i, line in enumerate(lines) if SECTION.fullmatch(line.strip())), None) + if start is None: + raise VersionError("No 'Currently supported/maintained versions' section") + end = next((i for i in range(start + 1, len(lines)) if HEADING.match(lines[i])), len(lines)) + rows = table_after(lines, start + 1, end) + versions = [] + for row in rows: + try: + major = int(row["version code"]) + except ValueError as error: + raise VersionError(f"Invalid major version: {row['version code']!r}") from error + match = re.match(r"^(\d{2,3}\.\d+\.\d+)(?:\s|$)", row["current version"]) + release = match.group(1) if match else "" + eol = row["end of life"] + if not RELEASE.fullmatch(release) or int(release.split(".", 1)[0]) != major: + raise VersionError(f"Invalid current release for Nextcloud {major}: {row['current version']!r}") + try: + eol_date = dt.date.fromisoformat(eol + "-01") + released = dt.date.fromisoformat(row["release date"]) + except ValueError as error: + raise VersionError(f"Invalid release/EOL date for Nextcloud {major}") from error + if eol_date.year < today.year or (eol_date.year == today.year and eol_date.month < today.month): + raise VersionError(f"Upstream still lists Nextcloud {major} although EOL month {eol} has passed") + if released > today: + raise VersionError(f"Nextcloud {major} is listed before its release date") + versions.append({"major": major, "release": release, "eol": eol}) + # Independently check that no proposed major is listed as End of Life. + eol_start = next((i for i, line in enumerate(lines) if re.match(r"^#{1,3}\s+EOL Versions\s*$", line, re.I)), None) + if eol_start is None: + raise VersionError("Missing 'EOL Versions' section") + eol_end = next((i for i in range(eol_start + 1, len(lines)) if HEADING.match(lines[i])), len(lines)) + eol_majors = set() + for line in lines[eol_start + 1:eol_end]: + if line.lstrip().startswith("|"): + first = cells(line)[0] + if first.isdigit(): + eol_majors.add(int(first)) + conflict = eol_majors.intersection(v["major"] for v in versions) + if conflict: + raise VersionError(f"Versions listed as both supported and EOL: {sorted(conflict)}") + manifest = {"schema_version": 1, "source": SOURCE, "versions": sorted(versions, key=lambda x: x["major"])} + validate_manifest(manifest, today=today) + return manifest + + +def validate_manifest(manifest: dict, today: dt.date | None = None) -> list[dict]: + today = today or dt.datetime.now(dt.timezone.utc).date() + if not isinstance(manifest, dict) or manifest.get("schema_version") != 1 or manifest.get("source") != SOURCE: + raise VersionError("Unknown supported-versions schema or provenance") + versions = manifest.get("versions") + if not isinstance(versions, list) or not 1 <= len(versions) <= 5: + raise VersionError("Expected between one and five maintained majors") + majors = [] + for entry in versions: + if not isinstance(entry, dict) or set(entry) != {"major", "release", "eol"}: + raise VersionError("Malformed version entry") + major, release, eol = entry["major"], entry["release"], entry["eol"] + if type(major) is not int or not 20 <= major <= 99 or not isinstance(release, str): + raise VersionError("Invalid major or release type") + if not RELEASE.fullmatch(release) or int(release.split(".", 1)[0]) != major: + raise VersionError(f"Invalid release for major {major}") + if not isinstance(eol, str) or not re.fullmatch(r"20\d{2}-(0[1-9]|1[0-2])", eol): + raise VersionError(f"Invalid EOL month for major {major}") + if eol < today.strftime("%Y-%m"): + raise VersionError(f"Nextcloud {major} has reached EOL ({eol}); update the approved manifest") + majors.append(major) + if sorted(set(majors)) != majors: + raise VersionError("Majors must be unique and sorted ascending") + if any(b - a != 1 for a, b in zip(majors, majors[1:])): + raise VersionError("Unexpected gap in supported majors; review the upstream schedule") + return versions + + +def request_json(url: str) -> dict: + headers = {"User-Agent": "librecode-nextcloud-version-discovery", "Accept": "application/json"} + if "api.github.com" in url and os.environ.get("GITHUB_TOKEN"): + headers["Authorization"] = "Bearer " + os.environ["GITHUB_TOKEN"] + with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=20) as response: + return json.load(response) + + +def verify_upstream(versions: list[dict]) -> None: + """Require an official stable release and an FPM image for each proposed version.""" + for item in versions: + release = item["release"] + published = request_json( + f"https://api.github.com/repos/nextcloud-releases/server/releases/tags/v{release}" + ) + if published.get("tag_name") != f"v{release}" or published.get("prerelease") or published.get("draft"): + raise VersionError(f"Nextcloud {release} is not an official stable GitHub release") + docker_tag = request_json( + f"https://hub.docker.com/v2/repositories/library/nextcloud/tags/{release}-fpm" + ) + if docker_tag.get("name") != f"{release}-fpm": + raise VersionError(f"Official Docker image nextcloud:{release}-fpm is unavailable") + platforms = {(i.get("os"), i.get("architecture")) for i in docker_tag.get("images", [])} + if not {("linux", "amd64"), ("linux", "arm64")}.issubset(platforms): + raise VersionError(f"nextcloud:{release}-fpm lacks amd64 or arm64") + + +def read_manifest(path: Path) -> dict: + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise VersionError(f"Cannot load reviewed supported versions at {path}: {error}") from error + validate_manifest(manifest) + return manifest + + +def append_summary(message: str) -> None: + print(message) + if os.getenv("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + summary.write(message + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + discover = sub.add_parser("discover", help="Parse upstream wiki and propose JSON") + discover.add_argument("--file", type=Path, default=DEFAULT_MANIFEST) + discover.add_argument("--wiki-file", type=Path, help="Read a local fixture instead") + discover.add_argument("--skip-upstream-check", action="store_true", help="Offline testing only") + matrix = sub.add_parser("matrix", help="Validate reviewed JSON and export GitHub Actions matrix") + matrix.add_argument("--file", type=Path, default=DEFAULT_MANIFEST) + matrix.add_argument("--github-output", type=Path) + args = parser.parse_args() + try: + if args.command == "discover": + if args.wiki_file: + markdown = args.wiki_file.read_text(encoding="utf-8") + else: + with urllib.request.urlopen( + urllib.request.Request(MARKDOWN_URL, headers={"User-Agent": "librecode-nextcloud-version-discovery"}), + timeout=20, + ) as response: + markdown = response.read().decode("utf-8") + candidate = parse_wiki(markdown) + if not args.skip_upstream_check: + verify_upstream(candidate["versions"]) + new_content = json.dumps(candidate, indent=2, ensure_ascii=False) + "\n" + changed = not args.file.exists() or args.file.read_text(encoding="utf-8") != new_content + if changed: + args.file.write_text(new_content, encoding="utf-8") + append_summary(f"Nextcloud discovery: {', '.join(str(v['major']) for v in candidate['versions'])}; " + f"manifest {'updated' if changed else 'unchanged'}. Source: {SOURCE}") + else: + versions = read_manifest(args.file)["versions"] + include = [ + {"major": item["major"], "tag": f"{item['major']}-fpm", "release": item["release"]} + for item in versions + ] + matrix_json = json.dumps({"include": include}, separators=(",", ":")) + if args.github_output: + with args.github_output.open("a", encoding="utf-8") as output: + output.write(f"matrix={matrix_json}\n") + else: + print(matrix_json) + append_summary(f"Approved Nextcloud versions: {', '.join(str(v['major']) for v in versions)}") + except (OSError, ValueError, urllib.error.URLError) as error: + message = str(error).replace("\n", " ") + print(f"::error title=Nextcloud version discovery failed::{message}", file=sys.stderr) + append_summary(f"**Nextcloud version discovery FAILED:** {message}. No manifest update can be proposed.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/fixtures/2023-maintained.md b/tests/fixtures/2023-maintained.md new file mode 100644 index 0000000..ad1b929 --- /dev/null +++ b/tests/fixtures/2023-maintained.md @@ -0,0 +1,28 @@ +## Currently maintained versions + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|-------------------------:|-----------------------: +| **27** | Hub 5 | 2023-06-13 | 2024-06 | **27.0.0** (2023-06-13) | 27.0.1 (2023-07-20) +| **26** | Hub 4 | 2023-03-21 | 2024-03 | **26.0.3** (2023-06-22) | 26.0.4 (2023-07-20) +| **25** | Hub 3 | 2022-10-19 | 2023-10 | **25.0.8** (2023-06-22) | 25.0.9 (2023-07-20) + +Older version have reached their **End of Life** and do not receive any updates anymore. + +For more details about the planning check out the [documentation page](https://docs.nextcloud.com/server/stable/admin_manual/release_schedule.html). + +## EOL Versions + + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|-------------------------:|-----------------------: +| **24** | Hub 3 | 2022-05-03 | 2023-04 | 24.0.12 (2023-04-20) | **End of Life** +| **23** | Hub 2 | 2021-11-30 | 2022-12 | 23.0.12 (2022-12-08) | **End of Life** +| **22** | Hub | 2021-07-06 | 2022-07 | 22.2.10 (2022-07-18) | **End of Life** +| **21** | Hub | 2021-02-22 | 2022-02 | 21.0.9 (2022-02-15) | **End of Life** +| **20** | Hub | 2020-10-03 | 2021-11 | 20.0.14 (2021-11-11) | **End of Life** +| **19** | Hub | 2020-06-03 | 2021-06 | 19.0.13 (2021-07-01) | **End of Life** +| **18** | Hub | 2020-01-16 | 2021-01 | 18.0.14 (2021-01-27) | **End of Life** +| **17** | 17 | 2019-09-30 | 2020-10 | 17.0.10 (2020-10-08) | **End of Life** +| **16** | 16 | 2019-04-25 | 2020-06 | 16.0.11 (2020-06-04) | **End of Life** +| **15** | 15 | 2018-12-10 | 2019-12 | 15.0.14 (2019-12-19) | **End of Life** +| **14** | 14 | 2018-09-10 | 2019-09 | 14.0.14 (2019-08-15) | **End of Life** diff --git a/tests/fixtures/2024-maintained.md b/tests/fixtures/2024-maintained.md new file mode 100644 index 0000000..38df13e --- /dev/null +++ b/tests/fixtures/2024-maintained.md @@ -0,0 +1,30 @@ +## Currently maintained versions + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|-------------------------:|-------------------------: +| **29** | Hub 8 | 2024-04-24 | 2025-04 | **29.0.3** (2024-06-25) | **29.0.4** (2024-07-18) +| **28** | Hub 7 | 2023-12-12 | 2024-12 | **28.0.7** (2024-06-25) | **28.0.8** (2024-07-18) + + +📣 Hub 6 (27.1.x) is the successor to Hub 5 (27.0.x) + +Older version have reached their **End of Life** and do not receive any updates anymore. + +For more details about the planning check out the [documentation page](https://docs.nextcloud.com/server/stable/admin_manual/release_schedule.html). + +## EOL Versions + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|-------------------------:|-----------------------: +| **27** | Hub 6 | 2023-06-13 | 2024-06 | 27.1.11 (2024-06-25) | **End of Life** +| **26** | Hub 4 | 2023-03-21 | 2024-03 | 26.0.13 (2024-03-28) | **End of Life** +| **25** | Hub 3 | 2022-10-19 | 2023-10 | 25.0.13 (2023-10-26) | **End of Life** +| **24** | Hub 3 | 2022-05-03 | 2023-04 | 24.0.12 (2023-04-20) | **End of Life** +| **23** | Hub 2 | 2021-11-30 | 2022-12 | 23.0.12 (2022-12-08) | **End of Life** +| **22** | Hub | 2021-07-06 | 2022-07 | 22.2.10 (2022-07-18) | **End of Life** +| **21** | Hub | 2021-02-22 | 2022-02 | 21.0.9 (2022-02-15) | **End of Life** +| **20** | Hub | 2020-10-03 | 2021-11 | 20.0.14 (2021-11-11) | **End of Life** +| **19** | Hub | 2020-06-03 | 2021-06 | 19.0.13 (2021-07-01) | **End of Life** +| **18** | Hub | 2020-01-16 | 2021-01 | 18.0.14 (2021-01-27) | **End of Life** +| **17** | 17 | 2019-09-30 | 2020-10 | 17.0.10 (2020-10-08) | **End of Life** +| **16** | 16 | 2019-04-25 | 2020-06 | 16.0.11 (2020-06-04) | **End of Life** diff --git a/tests/fixtures/2024-malformed.md b/tests/fixtures/2024-malformed.md new file mode 100644 index 0000000..5537640 --- /dev/null +++ b/tests/fixtures/2024-malformed.md @@ -0,0 +1,22 @@ +## Currently supported versions + +Older versions have reached their **End of Life** and do not receive any updates anymore. + +For more details about the planning check out the [documentation page](https://docs.nextcloud.com/server/stable/admin_manual/release_schedule.html). + +## EOL Versions + +📣 Hub 6 (27.1.x) was the successor to Hub 5 (27.0.x) + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|-------------------------:|-----------------------: +| **27** | Hub 6 | 2023-06-13 | 2024-06 | 27.1.11 (2024-06-25) | **End of Life** +| **26** | Hub 4 | 2023-03-21 | 2024-03 | 26.0.13 (2024-03-28) | **End of Life** +| **25** | Hub 3 | 2022-10-19 | 2023-10 | 25.0.13 (2023-10-26) | **End of Life** +| **24** | Hub 3 | 2022-05-03 | 2023-04 | 24.0.12 (2023-04-20) | **End of Life** +| **23** | Hub 2 | 2021-11-30 | 2022-12 | 23.0.12 (2022-12-08) | **End of Life** +| **22** | Hub | 2021-07-06 | 2022-07 | 22.2.10 (2022-07-18) | **End of Life** +| **21** | Hub | 2021-02-22 | 2022-02 | 21.0.9 (2022-02-15) | **End of Life** +| **20** | Hub | 2020-10-03 | 2021-11 | 20.0.14 (2021-11-11) | **End of Life** +| **19** | Hub | 2020-06-03 | 2021-06 | 19.0.13 (2021-07-01) | **End of Life** +| **18** | Hub | 2020-01-16 | 2021-01 | 18.0.14 (2021-01-27) | **End of Life** diff --git a/tests/fixtures/2025-eol-overlap.md b/tests/fixtures/2025-eol-overlap.md new file mode 100644 index 0000000..f087135 --- /dev/null +++ b/tests/fixtures/2025-eol-overlap.md @@ -0,0 +1,31 @@ +## Currently supported versions + +New major versions are published every four months and are supported for one year through monthly maintenance releases. Maintenance releases are published for all still supported major versions and target all known critical bugs and security problems. + +Major versions that have reached their **End of Life** no longer receive further bug fixes or security updates. + +For more details to help with planning your updates, check the [documentation page](https://docs.nextcloud.com/server/stable/admin_manual/release_schedule.html). + + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|--------------------------:|--------------------------: +| **31** | Hub 10 | 2025-02-25 | 2026-02 | **31.0.3** (2025-04-10) | **31.0.4** (2025-04-17) +| **30** | Hub 9 | 2024-09-14 | 2025-09 | **30.0.9** (2025-04-10) | **30.0.10** (2025-04-17) +| **29** | Hub 8 | 2024-04-24 | 2025-04 | **29.0.15** (2025-04-10) | **29.0.16** (2025-04-17) + +## EOL Versions + +📣 Hub 6 (27.1.x) was the successor to Hub 5 (27.0.x) + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|-------------------------:|-----------------------: +| **29** | Hub 8 | 2024-04-24 | 2025-04 | 29.0.15 (2025-04-10) | **End of Life** +| **28** | Hub 7 | 2023-12-12 | 2024-12 | 28.0.14 (2024-12-12) | **End of Life** +| **27** | Hub 6 | 2023-06-13 | 2024-06 | 27.1.11 (2024-06-25) | **End of Life** +| **26** | Hub 4 | 2023-03-21 | 2024-03 | 26.0.13 (2024-03-28) | **End of Life** +| **25** | Hub 3 | 2022-10-19 | 2023-10 | 25.0.13 (2023-10-26) | **End of Life** +| **24** | Hub 3 | 2022-05-03 | 2023-04 | 24.0.12 (2023-04-20) | **End of Life** +| **23** | Hub 2 | 2021-11-30 | 2022-12 | 23.0.12 (2022-12-08) | **End of Life** +| **22** | Hub | 2021-07-06 | 2022-07 | 22.2.10 (2022-07-18) | **End of Life** +| **21** | Hub | 2021-02-22 | 2022-02 | 21.0.9 (2022-02-15) | **End of Life** +| **20** | Hub | 2020-10-03 | 2021-11 | 20.0.14 (2021-11-11) | **End of Life** diff --git a/tests/fixtures/2025-supported.md b/tests/fixtures/2025-supported.md new file mode 100644 index 0000000..cf19c8f --- /dev/null +++ b/tests/fixtures/2025-supported.md @@ -0,0 +1,30 @@ +## Currently supported versions + +New major versions are published every four months and are supported for one year through monthly maintenance releases. Maintenance releases are published for all still supported major versions and target all known critical bugs and security problems. + +Major versions that have reached their **End of Life** no longer receive further bug fixes or security updates. + +For more details to help with planning your updates, check the [documentation page](https://docs.nextcloud.com/server/stable/admin_manual/release_schedule.html). + + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|--------------------------:|--------------------------: +| **31** | Hub 10 | 2025-02-25 | 2026-02 | **31.0.6** (2025-06-12) | **31.0.7** (2025-07-10) +| **30** | Hub 9 | 2024-09-14 | 2025-09 | **30.0.12** (2025-06-12) | **30.0.13** (2025-07-10) + +## EOL Versions + +📣 Hub 6 (27.1.x) was the successor to Hub 5 (27.0.x) + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:------------:|:------------------|-----------------:|-------------------------:|-----------------------: +| **29** | Hub 8 | 2024-04-24 | 2025-04 | 29.0.16 (2025-04-17) | **End of Life** +| **28** | Hub 7 | 2023-12-12 | 2024-12 | 28.0.14 (2024-12-12) | **End of Life** +| **27** | Hub 6 | 2023-06-13 | 2024-06 | 27.1.11 (2024-06-25) | **End of Life** +| **26** | Hub 4 | 2023-03-21 | 2024-03 | 26.0.13 (2024-03-28) | **End of Life** +| **25** | Hub 3 | 2022-10-19 | 2023-10 | 25.0.13 (2023-10-26) | **End of Life** +| **24** | Hub 3 | 2022-05-03 | 2023-04 | 24.0.12 (2023-04-20) | **End of Life** +| **23** | Hub 2 | 2021-11-30 | 2022-12 | 23.0.12 (2022-12-08) | **End of Life** +| **22** | Hub | 2021-07-06 | 2022-07 | 22.2.10 (2022-07-18) | **End of Life** +| **21** | Hub | 2021-02-22 | 2022-02 | 21.0.9 (2022-02-15) | **End of Life** +| **20** | Hub | 2020-10-03 | 2021-11 | 20.0.14 (2021-11-11) | **End of Life** diff --git a/tests/fixtures/2026-supported.md b/tests/fixtures/2026-supported.md new file mode 100644 index 0000000..7544afb --- /dev/null +++ b/tests/fixtures/2026-supported.md @@ -0,0 +1,31 @@ +## Currently supported versions + +New major versions are published every four months and are supported for one year through monthly maintenance releases. Maintenance releases are published for all still supported major versions and target all known critical bugs and security problems. + +Major versions that have reached their **End of Life** no longer receive further bug fixes or security updates. + +For more details to help with planning your updates, check the [documentation page](https://docs.nextcloud.com/server/stable/admin_manual/release_schedule.html). + + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:-------------:|:------------------|-----------------:|-------------------------:|--------------------------: +| **35** | Hub 26 Summer | 2026-09-16 | 2027-09 | **35.0.2** (2026-10-15) | **35.0.3** (2026-11-12) +| **34** | Hub 26 Spring | 2026-06-09 | 2027-06 | **34.0.5** (2026-10-15) | **34.0.6** (2026-11-12) +| **33** | Hub 26 Winter | 2026-02-18 | 2027-02 | **33.0.10** (2026-10-15) | **33.0.11** (2026-11-12) + +## EOL Versions + +📣 Hub 6 (27.1.x) was the successor to Hub 5 (27.0.x) + +| Version code | Version name | Release date | End of life | Last version +|:------------:|:-------------:|:------------------|-----------------:|-------------------------: +| **32** | Hub 25 Autumn | 2025-09-27 | 2026-09 | 32.0.15 (2026-09-10) +| **31** | Hub 10 | 2025-02-25 | 2026-02 | 31.0.14 (2026-02-12) +| **30** | Hub 9 | 2024-09-11 | 2025-10 | 30.0.17 (2025-10-23) +| **29** | Hub 8 | 2024-04-24 | 2025-04 | 29.0.16 (2025-04-17) +| **28** | Hub 7 | 2023-12-12 | 2024-12 | 28.0.14 (2024-12-12) +| **27** | Hub 6 | 2023-06-13 | 2024-06 | 27.1.11 (2024-06-25) +| **26** | Hub 4 | 2023-03-21 | 2024-03 | 26.0.13 (2024-03-28) +| **25** | Hub 3 | 2022-10-19 | 2023-10 | 25.0.13 (2023-10-26) +| **24** | Hub 3 | 2022-05-03 | 2023-04 | 24.0.12 (2023-04-20) +| **23** | Hub 2 | 2021-11-30 | 2022-12 | 23.0.12 (2022-12-08) diff --git a/tests/fixtures/2026-unreleased.md b/tests/fixtures/2026-unreleased.md new file mode 100644 index 0000000..3e57fb8 --- /dev/null +++ b/tests/fixtures/2026-unreleased.md @@ -0,0 +1,30 @@ +## Currently supported versions + +New major versions are published every four months and are supported for one year through monthly maintenance releases. Maintenance releases are published for all still supported major versions and target all known critical bugs and security problems. + +Major versions that have reached their **End of Life** no longer receive further bug fixes or security updates. + +For more details to help with planning your updates, check the [documentation page](https://docs.nextcloud.com/server/stable/admin_manual/release_schedule.html). + + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:-------------:|:------------------|-----------------:|-------------------------:|--------------------------: +| **33** | Hub 26 Winter | 2026-02-18 | 2027-02 | **---** | **33.0.0** (2026-02-18) +| **32** | Hub 25 Autumn | 2025-09-27 | 2026-09 | **32.0.6** (2026-02-12) | **32.0.7** (2026-03-26) + +## EOL Versions + +📣 Hub 6 (27.1.x) was the successor to Hub 5 (27.0.x) + +| Version code | Version name | Release date | End of life | Last version +|:------------:|:------------:|:------------------|-----------------:|-------------------------: +| **31** | Hub 10 | 2025-02-25 | 2026-02 | 31.0.14 (2026-02-12) +| **30** | Hub 9 | 2024-09-11 | 2025-10 | 30.0.17 (2025-10-23) +| **29** | Hub 8 | 2024-04-24 | 2025-04 | 29.0.16 (2025-04-17) +| **28** | Hub 7 | 2023-12-12 | 2024-12 | 28.0.14 (2024-12-12) +| **27** | Hub 6 | 2023-06-13 | 2024-06 | 27.1.11 (2024-06-25) +| **26** | Hub 4 | 2023-03-21 | 2024-03 | 26.0.13 (2024-03-28) +| **25** | Hub 3 | 2022-10-19 | 2023-10 | 25.0.13 (2023-10-26) +| **24** | Hub 3 | 2022-05-03 | 2023-04 | 24.0.12 (2023-04-20) +| **23** | Hub 2 | 2021-11-30 | 2022-12 | 23.0.12 (2022-12-08) +| **22** | Hub | 2021-07-06 | 2022-07 | 22.2.10 (2022-07-18) diff --git a/tests/test_nextcloud_versions.py b/tests/test_nextcloud_versions.py new file mode 100644 index 0000000..9fa774a --- /dev/null +++ b/tests/test_nextcloud_versions.py @@ -0,0 +1,125 @@ +"""Offline regression tests for wiki discovery, schema validation and failure handling.""" + +import datetime as dt +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import nextcloud_versions as versions # noqa: E402 + + +class NextcloudVersionsTest(unittest.TestCase): + def fixture(self, name: str) -> str: + return (ROOT / "tests" / "fixtures" / (name + ".md")).read_text(encoding="utf-8") + + def test_real_historical_wiki_formats(self): + cases = [ + ("2023-maintained", dt.date(2023, 7, 1), [25, 26, 27]), + ("2024-maintained", dt.date(2024, 7, 1), [28, 29]), + ("2025-supported", dt.date(2025, 7, 1), [30, 31]), + ("2026-supported", dt.date(2026, 10, 20), [33, 34, 35]), + ] + for name, day, expected in cases: + with self.subTest(revision=name): + result = versions.parse_wiki(self.fixture(name), today=day) + self.assertEqual([item["major"] for item in result["versions"]], expected) + + def test_malformed_historical_revision_fails_closed(self): + with self.assertRaises(versions.VersionError): + versions.parse_wiki(self.fixture("2024-malformed"), today=dt.date(2024, 10, 24)) + + def test_unreleased_or_conflicting_historical_wiki_fails_closed(self): + with self.assertRaisesRegex(versions.VersionError, "Invalid current release"): + versions.parse_wiki(self.fixture("2026-unreleased"), today=dt.date(2026, 2, 12)) + with self.assertRaisesRegex(versions.VersionError, "both supported and EOL"): + versions.parse_wiki(self.fixture("2025-eol-overlap"), today=dt.date(2025, 4, 15)) + + def test_heading_removed_fails_closed(self): + source = self.fixture("2026-supported").replace("Currently supported versions", "Upcoming releases") + with self.assertRaisesRegex(versions.VersionError, "No 'Currently"): + versions.parse_wiki(source, today=dt.date(2026, 10, 20)) + + def test_supported_and_eol_conflict_fails_closed(self): + source = self.fixture("2026-supported").replace("| **32**", "| **35**") + with self.assertRaisesRegex(versions.VersionError, "both supported and EOL"): + versions.parse_wiki(source, today=dt.date(2026, 10, 20)) + + def test_end_of_life_month_fails_closed(self): + with self.assertRaisesRegex(versions.VersionError, "EOL"): + versions.parse_wiki(self.fixture("2026-supported"), today=dt.date(2027, 10, 1)) + + def test_json_schema_rejects_out_of_order_or_eol(self): + manifest = versions.parse_wiki(self.fixture("2026-supported"), today=dt.date(2026, 10, 20)) + reversed_manifest = {**manifest, "versions": list(reversed(manifest["versions"]))} + with self.assertRaisesRegex(versions.VersionError, "sorted"): + versions.validate_manifest(reversed_manifest, today=dt.date(2026, 10, 20)) + expired_manifest = json.loads(json.dumps(manifest)) + expired_manifest["versions"][0]["eol"] = "2026-09" + with self.assertRaisesRegex(versions.VersionError, "EOL"): + versions.validate_manifest(expired_manifest, today=dt.date(2026, 10, 20)) + + def test_manifest_matrix_uses_approved_patch_versions(self): + with tempfile.TemporaryDirectory() as tmp: + manifest_file = Path(tmp) / "approved.json" + fixture = versions.parse_wiki(self.fixture("2026-supported"), today=dt.date(2026, 10, 20)) + for entry in fixture["versions"]: + entry["eol"] = "2099-12" # Never tie a unit test to the real-world EOL clock. + manifest_file.write_text(json.dumps(fixture), encoding="utf-8") + output = Path(tmp) / "actions_output" + completed = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "nextcloud_versions.py"), + "matrix", "--file", str(manifest_file), "--github-output", str(output)], + capture_output=True, text=True, cwd=ROOT, check=True, + ) + self.assertIn("Approved Nextcloud versions", completed.stdout) + raw = output.read_text(encoding="utf-8").strip() + self.assertTrue(raw.startswith("matrix=")) + self.assertEqual(json.loads(raw.partition("=")[2]), { + "include": [ + {"major": 33, "tag": "33-fpm", "release": "33.0.10"}, + {"major": 34, "tag": "34-fpm", "release": "34.0.5"}, + {"major": 35, "tag": "35-fpm", "release": "35.0.2"}, + ] + }) + + def test_bad_wiki_does_not_modify_approved_manifest(self): + with tempfile.TemporaryDirectory() as tmp: + approved = Path(tmp) / "approved.json" + approved.write_text('{"approved":true}\n') + completed = subprocess.run( + [sys.executable, str(ROOT / "scripts" / "nextcloud_versions.py"), "discover", + "--file", str(approved), "--wiki-file", + str(ROOT / "tests/fixtures/2024-malformed.md"), "--skip-upstream-check"], + capture_output=True, text=True, cwd=ROOT, + ) + self.assertEqual(completed.returncode, 1) + self.assertEqual(approved.read_text(), '{"approved":true}\n') + self.assertIn("::error title=Nextcloud version discovery failed::", completed.stderr) + + def test_upstream_verification_requires_release_and_both_architectures(self): + entries = [{"major": 35, "release": "35.0.2", "eol": "2027-09"}] + with patch.object(versions, "request_json", side_effect=[ + {"tag_name": "v35.0.2", "prerelease": False, "draft": False}, + {"name": "35.0.2-fpm", "images": [ + {"os": "linux", "architecture": "amd64"}, + {"os": "linux", "architecture": "arm64"}, + ]}, + ]) as request: + versions.verify_upstream(entries) + self.assertEqual(request.call_count, 2) + with patch.object(versions, "request_json", side_effect=[ + {"tag_name": "v35.0.2", "prerelease": False, "draft": False}, + {"name": "35.0.2-fpm", "images": [{"os": "linux", "architecture": "amd64"}]}, + ]): + with self.assertRaisesRegex(versions.VersionError, "lacks amd64 or arm64"): + versions.verify_upstream(entries) + + +if __name__ == "__main__": + unittest.main() From 462f13664eaa27063c0255cc003d79afc3991f04 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:54:00 +0000 Subject: [PATCH 05/10] refactor(ci): publish tested images from approved manifest Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .env.example | 2 +- .github/actions/build-and-scan/action.yml | 46 +++-- .github/workflows/docker-image.yml | 212 +++++++++++++--------- README.md | 2 +- docs/images.md | 63 ++++++- scripts/publish_images.py | 110 +++++++++++ tests/test_publish_images.py | 97 ++++++++++ 7 files changed, 427 insertions(+), 105 deletions(-) create mode 100644 scripts/publish_images.py create mode 100644 tests/test_publish_images.py diff --git a/.env.example b/.env.example index fc4375d..b84d06a 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -NEXTCLOUD_VERSION=34-fpm +NEXTCLOUD_VERSION=35-fpm VIRTUAL_HOST= LETSENCRYPT_HOST= diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index 02d9955..9128b2b 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -1,13 +1,18 @@ -name: Build, scan, and validate runtime images -description: Build and scan both runtime images, then run app image acceptance tests +name: Build, scan, and validate image +description: Build either a Nextcloud FPM runtime or its independent web image inputs: - nextcloud_version: - description: Nextcloud version passed to the app image build + component: + description: Either app or web required: true + nextcloud_version: + description: Official Nextcloud release tag for app builds (e.g. 35.0.2-fpm) + required: false + default: '' runs: using: composite steps: - name: Setup Bats + if: inputs.component == 'app' uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 with: support-install: false @@ -24,6 +29,7 @@ runs: uses: docker/setup-buildx-action@v3 - name: Build app image (linux/amd64) + if: inputs.component == 'app' uses: docker/build-push-action@v6 with: context: .docker/app @@ -32,10 +38,11 @@ runs: build-args: | NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} tags: scan/app:amd64 - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-amd64 + cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-amd64 - name: Build app image (linux/arm64) + if: inputs.component == 'app' uses: docker/build-push-action@v6 with: context: .docker/app @@ -44,28 +51,30 @@ runs: build-args: | NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} tags: scan/app:arm64 - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-arm64 + cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-arm64 - name: Build web image (linux/amd64) + if: inputs.component == 'web' uses: docker/build-push-action@v6 with: context: .docker/web platforms: linux/amd64 load: true tags: scan/web:amd64 - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=web-amd64 + cache-to: type=gha,mode=max,scope=web-amd64 - name: Build web image (linux/arm64) + if: inputs.component == 'web' uses: docker/build-push-action@v6 with: context: .docker/web platforms: linux/arm64 load: true tags: scan/web:arm64 - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=web-arm64 + cache-to: type=gha,mode=max,scope=web-arm64 - name: Install Trivy uses: aquasecurity/setup-trivy@81e514348e19b6112ce2a7e3ecbafe19c1e1f567 # v0.3.1 @@ -74,22 +83,31 @@ runs: version: v0.75.0 cache: true - - name: Scan runtime images + - name: Scan app image + if: inputs.component == 'app' shell: bash run: | bash scripts/scan-images.sh \ 'app@linux/amd64=scan/app:amd64' \ - 'app@linux/arm64=scan/app:arm64' \ + 'app@linux/arm64=scan/app:arm64' + + - name: Scan web image + if: inputs.component == 'web' + shell: bash + run: | + bash scripts/scan-images.sh \ 'web@linux/amd64=scan/web:amd64' \ 'web@linux/arm64=scan/web:arm64' - name: Runtime acceptance app image (linux/amd64) + if: inputs.component == 'app' shell: bash env: APP_IMAGE: scan/app:amd64 run: bats tests/app-image.bats - name: Runtime acceptance app image (linux/arm64) + if: inputs.component == 'app' shell: bash env: APP_IMAGE: scan/app:arm64 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 11fcdcf..5b69742 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,4 +1,4 @@ -name: Build and Push Docker Image +name: Build and publish maintained Nextcloud images on: push: @@ -8,143 +8,183 @@ on: branches: - main workflow_dispatch: + schedule: + - cron: '13 4 * * 2' permissions: contents: read +concurrency: + group: nextcloud-stable-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + env: - REGISTRY: ghcr.io + APP_IMAGE: ghcr.io/librecodecoop/nextcloud-docker-app + WEB_IMAGE: ghcr.io/librecodecoop/nextcloud-docker-web jobs: - verify: - if: github.event_name == 'pull_request' + script-tests: + name: Test version and publication scripts runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - nextcloud_version: ['33-fpm', '34-fpm', '35-fpm'] + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false - permissions: - contents: read + - name: Run offline regression tests + run: make test-ci-scripts + + - name: Test Trivy wrapper + run: bash tests/test-scan-images.sh + versions: + needs: script-tests + name: Load approved Nextcloud releases + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.approved.outputs.matrix }} steps: - name: Checkout repository uses: actions/checkout@v7 - - - name: Build and scan runtime images - uses: ./.github/actions/build-and-scan with: - nextcloud_version: ${{ matrix.nextcloud_version }} + persist-credentials: false - - name: Save SARIF reports - uses: actions/upload-artifact@v6 - with: - name: trivy-sarif-reports-${{ matrix.nextcloud_version }} - path: trivy-results/*.sarif - if-no-files-found: warn + - name: Validate reviewed manifest and emit matrix + id: approved + run: python3 scripts/nextcloud_versions.py matrix --github-output "$GITHUB_OUTPUT" - publish: - if: github.event_name != 'pull_request' + app: + name: app ${{ matrix.tag }} + needs: [script-tests, versions] runs-on: ubuntu-latest strategy: fail-fast: false - matrix: - nextcloud_version: ['33-fpm', '34-fpm', '35-fpm'] - + matrix: ${{ fromJSON(needs.versions.outputs.matrix) }} permissions: contents: read packages: write security-events: write - steps: - name: Checkout repository uses: actions/checkout@v7 + with: + persist-credentials: false - - name: Normalize repository name - id: repo_name - run: echo "value=${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" - - - name: Build and scan runtime images + - name: Build, scan, and test Nextcloud app uses: ./.github/actions/build-and-scan with: - nextcloud_version: ${{ matrix.nextcloud_version }} + component: app + nextcloud_version: ${{ matrix.release }}-fpm - - name: Upload app amd64 SARIF to GitHub code scanning - if: hashFiles('trivy-results/app-linux-amd64.sarif') != '' + - name: Upload amd64 security findings + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results/app-linux-amd64.sarif - category: trivy-app-linux-amd64-${{ matrix.nextcloud_version }} + category: trivy-app-linux-amd64-${{ matrix.tag }} - - name: Upload app arm64 SARIF to GitHub code scanning - if: hashFiles('trivy-results/app-linux-arm64.sarif') != '' + - name: Upload arm64 security findings + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results/app-linux-arm64.sarif - category: trivy-app-linux-arm64-${{ matrix.nextcloud_version }} + category: trivy-app-linux-arm64-${{ matrix.tag }} + + - name: Save vulnerability reports + uses: actions/upload-artifact@v6 + with: + name: trivy-app-${{ matrix.tag }} + path: trivy-results/*.sarif + if-no-files-found: error + + - name: Log in to GHCR + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Stage validated architectures + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + run: python3 scripts/publish_images.py stage-app --image "$APP_IMAGE" --revision "$GITHUB_SHA" --tag "${{ matrix.tag }}" - - name: Upload web amd64 SARIF to GitHub code scanning - if: hashFiles('trivy-results/web-linux-amd64.sarif') != '' + web: + name: web runtime + needs: script-tests + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Build and scan web image once + uses: ./.github/actions/build-and-scan + with: + component: web + + - name: Upload amd64 security findings + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results/web-linux-amd64.sarif - category: trivy-web-linux-amd64-${{ matrix.nextcloud_version }} + category: trivy-web-linux-amd64 - - name: Upload web arm64 SARIF to GitHub code scanning - if: hashFiles('trivy-results/web-linux-arm64.sarif') != '' + - name: Upload arm64 security findings + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results/web-linux-arm64.sarif - category: trivy-web-linux-arm64-${{ matrix.nextcloud_version }} + category: trivy-web-linux-arm64 - - name: Save SARIF reports + - name: Save vulnerability reports uses: actions/upload-artifact@v6 with: - name: trivy-sarif-reports-${{ matrix.nextcloud_version }} + name: trivy-web path: trivy-results/*.sarif - if-no-files-found: warn + if-no-files-found: error + + - name: Log in to GHCR + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: docker/login-action@v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Stage validated web architectures + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + run: python3 scripts/publish_images.py stage-web --image "$WEB_IMAGE" --revision "$GITHUB_SHA" + + 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] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Log in to Container Registry + - name: Log in to GHCR uses: docker/login-action@v4.6.0 with: - registry: ${{ env.REGISTRY }} + registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Push scanned architecture images - env: - APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app - WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web - IMAGE_VERSION: sha-${{ github.sha }}-${{ matrix.nextcloud_version }} - run: | - set -euo pipefail - - docker tag scan/app:amd64 "${APP_IMAGE}:${IMAGE_VERSION}-amd64" - docker tag scan/app:arm64 "${APP_IMAGE}:${IMAGE_VERSION}-arm64" - docker tag scan/web:amd64 "${WEB_IMAGE}:${IMAGE_VERSION}-amd64" - docker tag scan/web:arm64 "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" - - docker push "${APP_IMAGE}:${IMAGE_VERSION}-amd64" - docker push "${APP_IMAGE}:${IMAGE_VERSION}-arm64" - docker push "${WEB_IMAGE}:${IMAGE_VERSION}-amd64" - docker push "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" - - - name: Publish multi-platform image tags - env: - APP_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-app - WEB_IMAGE: ${{ env.REGISTRY }}/${{ steps.repo_name.outputs.value }}-web - IMAGE_VERSION: sha-${{ github.sha }}-${{ matrix.nextcloud_version }} - run: | - set -euo pipefail - - docker buildx imagetools create \ - --tag "${APP_IMAGE}:${{ matrix.nextcloud_version }}" \ - "${APP_IMAGE}:${IMAGE_VERSION}-amd64" \ - "${APP_IMAGE}:${IMAGE_VERSION}-arm64" - - if [ "${{ matrix.nextcloud_version }}" = "35-fpm" ]; then - docker buildx imagetools create \ - --tag "${WEB_IMAGE}:main" \ - "${WEB_IMAGE}:${IMAGE_VERSION}-amd64" \ - "${WEB_IMAGE}:${IMAGE_VERSION}-arm64" - fi + - name: Publish approved images and aliases + run: python3 scripts/publish_images.py promote --app-image "$APP_IMAGE" --web-image "$WEB_IMAGE" --revision "$GITHUB_SHA" diff --git a/README.md b/README.md index aeebc5e..abe7880 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ docker compose -d Change the value of NEXTCLOUD_VERSION at `.env` file and put the tag name that you want to use. Check the availables tags here: https://hub.docker.com/_/nextcloud/tags -The GHCR build workflow reads `NEXTCLOUD_VERSION` from `.env.example` and publishes the app and web images as static `:latest` tags so tools like Watchtower can track them reliably. +The GHCR build workflow reads the reviewed `.github/nextcloud-supported.json` manifest, builds each maintained major using its current official patch release, and promotes the newest supported app image to `:stable-fpm` and the historical `:latest` alias. The web image is built independently as `:main` (with a `:latest` compatibility alias). Use explicit major tags in deployed stacks to avoid automatic Nextcloud major upgrades. See [image publishing and version discovery](docs/images.md). Build the images, down the containers and get up again: diff --git a/docs/images.md b/docs/images.md index 7a47144..be3ffdc 100644 --- a/docs/images.md +++ b/docs/images.md @@ -23,7 +23,8 @@ The app image follows the tag structure used by the official Nextcloud images. | --- | --- | --- | | `:-fpm` | Stable release channel within one Nextcloud major, for example `35-fpm` | Moving | | `:-fpm` | A specific Nextcloud server release, for example `35.0.1-fpm` | Release-specific tag; may be rebuilt | -| `:stable-fpm` | Current stable Nextcloud channel, when this alias is intentionally published | Moving | +| `:stable-fpm` | Newest approved, supported stable major | Moving | +| `:latest` | Compatibility alias of `stable-fpm` for existing consumers; not appropriate for pinned deployments | Moving | | `:master-fpm` | Development channel following Nextcloud Server `master` | Moving | | `@sha256:` | Exact OCI image content | Immutable | @@ -40,6 +41,7 @@ The web image is not tied to a Nextcloud server version. Use: - `:main` for the moving image built from this repository's main development line; +- `:latest` as a compatibility alias of `:main`; - `@sha256:` when an exact immutable image is required. Do not add a Nextcloud major to the web image tag unless the web image itself gains a real version-dependent contract. @@ -71,7 +73,6 @@ A full Nextcloud release tag such as `35.0.1-fpm` identifies the server release Do not introduce these as the repository convention: -- bare `:latest`; - `:dev`; - `:dev-`; - `:stable--fpm`; @@ -79,7 +80,7 @@ Do not introduce these as the repository convention: - bare `:`; - a development tag tied only to the next Nextcloud major. -Historical tags may continue to exist temporarily while old implementations are replaced, but their existence does not make them part of this contract. +Compatibility aliases `:latest` are intentionally maintained for the app and web images. They are moving tags, and an automatic Nextcloud major upgrade may not be safe. Other historical tags are not part of this contract. The historical development tag `:35` does not require a compatibility alias: the consumer inventory in #52 found no active consumer that depends on it. @@ -211,3 +212,59 @@ Remaining work in #47 must continue incrementally. In particular: - keep LibreSign-specific behavior out of the generic runtime; - preserve scan and runtime-acceptance gates before publication; - preserve the existing Compose environment until a replacement is explicitly validated. + +## Approved maintained releases and automated discovery + +The **reviewed** `.github/nextcloud-supported.json` file is the only source for the stable-image CI matrix. +It records the upstream source, schema version and each supported major's current patch release and EOL month. +The latest supported major is the highest major in this reviewed list; no major is hardcoded into the image workflow. + +The `Discover maintained Nextcloud versions` workflow checks the upstream +[Server maintenance wiki](https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule) +twice each day and on manual dispatch. Discovery uses +`scripts/nextcloud_versions.py` to extract the **Currently supported/maintained versions** table +and verifies the independent **EOL Versions** table. It rejects changed table structures, +duplicate majors, expired EOL months, future release dates and invalid version formats. +It then checks the official `nextcloud-releases/server` release and the +`nextcloud:-fpm` Docker Hub tag for both AMD64 and ARM64. + +If the JSON changes, the watcher proposes an updating PR on +`automation/nextcloud-supported-versions`, without merging it or publishing images. +Generated PR commits use DCO sign-off and the verified GitHub Actions bot signature. +The bot uses GitHub's signing API, not the interactive Git Signing MCP service. +Because `GITHUB_TOKEN` PRs do not automatically trigger standard PR checks, +the watcher dispatches the stable workflow on that branch for validation. +Workflow dispatch on non-main branches can **only validate** and cannot push or promote images. + +If discovery fails, the workflow is red and emits a GitHub Actions error and job summary. +**No manifest changes or PR are created on failure**. The last approved JSON remains intact. +Enable GitHub Actions failure notifications so these errors do not go unnoticed. +GitHub Actions must also be permitted to create pull requests in repository/organization settings. + +After a human reviews and merges the manifest PR, the stable workflow uses the committed JSON +to build and scan each `nextcloud:-fpm` with Bats runtime acceptance. +The independent web image is built and scanned **once**, without tying it to a Nextcloud major. +Only successful jobs push architecture-specific staging tags. After all jobs pass, +`scripts/publish_images.py` publishes the versioned multi-platform manifests, followed by: + +- The `:-fpm` channels for every approved, supported major. +- The `:-fpm` patch tags for the verified upstream releases. +- App `:stable-fpm` and historical `:latest` aliases for the **newest** approved major. +- Web `:main` and compatibility `:latest` aliases, independently of the app major. + +Images that reach EOL are **not deleted** from GHCR; they merely stop being rebuilt +once their removal is approved. Existing deployments should pin a major or OCI digest. +Do not point a live Nextcloud data volume at a moving cross-major `:latest` alias. +An unreconciled EOL month in the reviewed JSON blocks the release workflow rather than +building a potentially unsupported major. + +Offline regression tests include real wiki snapshots from 2023, 2024, 2025 and 2026, +including a malformed upstream revision, and mocked Docker publication commands: + +```bash +make test-ci-scripts +``` + +Stable publication is triggered by pushes to `main`, a weekly security rebuild or manual +workflow dispatch on `main`. The `master-fpm` development channel remains on its separate +daily upstream master workflow and does not depend on this reviewed JSON. diff --git a/scripts/publish_images.py b/scripts/publish_images.py new file mode 100644 index 0000000..bace532 --- /dev/null +++ b/scripts/publish_images.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Stage tested multi-architecture images and promote only after all CI gates pass.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +from nextcloud_versions import read_manifest + +ARCHITECTURES = ("amd64", "arm64") +GIT_SHA = re.compile(r"^[a-f0-9]{40}$") +IMAGE = re.compile(r"^[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+$") + + +def run(*arguments: str) -> None: + subprocess.run(["docker", *arguments], check=True) + + +def validate(image: str, revision: str) -> None: + if not IMAGE.fullmatch(image): + raise ValueError(f"Unsafe image reference: {image!r}") + if not GIT_SHA.fullmatch(revision): + raise ValueError("Expected the full 40-character Git commit SHA") + + +def staging_tag(image: str, revision: str, component: str, arch: str) -> str: + assert arch in ARCHITECTURES + if component == "web": + return f"{image}:sha-{revision}-web-{arch}" + if not re.fullmatch(r"[2-9]\d-fpm", component): + raise ValueError(f"Invalid Nextcloud channel: {component}") + return f"{image}:sha-{revision}-{component}-{arch}" + + +def stage(image: str, revision: str, component: str) -> None: + validate(image, revision) + if component != "web" and not re.fullmatch(r"[2-9]\d-fpm", component): + raise ValueError("Expected a stable Nextcloud major-fpm tag") + local_component = "web" if component == "web" else "app" + for arch in ARCHITECTURES: + local = f"scan/{local_component}:{arch}" + remote = staging_tag(image, revision, component, arch) + run("tag", local, remote) + run("push", remote) + + +def manifest(image: str, tag: str, sources: list[str]) -> None: + if len(sources) != 2: + raise ValueError("A manifest must combine exactly amd64 and arm64") + run("buildx", "imagetools", "create", "--tag", f"{image}:{tag}", *sources) + run("buildx", "imagetools", "inspect", f"{image}:{tag}") + + +def promote(app_image: str, web_image: str, revision: str, manifest_file: Path) -> None: + validate(app_image, revision) + validate(web_image, revision) + versions = read_manifest(manifest_file)["versions"] + # Publish stable versioned manifests before moving any alias. + for entry in versions: + tag = f"{entry['major']}-fpm" + sources = [staging_tag(app_image, revision, tag, arch) for arch in ARCHITECTURES] + manifest(app_image, tag, sources) + manifest(app_image, f"{entry['release']}-fpm", sources) + current = f"{versions[-1]['major']}-fpm" + current_sources = [staging_tag(app_image, revision, current, arch) for arch in ARCHITECTURES] + # Both aliases move only after every supported version has passed all jobs. + manifest(app_image, "stable-fpm", current_sources) + manifest(app_image, "latest", current_sources) + + web_sources = [staging_tag(web_image, revision, "web", arch) for arch in ARCHITECTURES] + manifest(web_image, "main", web_sources) + # Keep the existing web latest consumers on a tested web runtime. + manifest(web_image, "latest", web_sources) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="action", required=True) + app = sub.add_parser("stage-app") + app.add_argument("--image", required=True) + app.add_argument("--revision", required=True) + app.add_argument("--tag", required=True) + web = sub.add_parser("stage-web") + web.add_argument("--image", required=True) + web.add_argument("--revision", required=True) + promote_cmd = sub.add_parser("promote") + promote_cmd.add_argument("--app-image", required=True) + promote_cmd.add_argument("--web-image", required=True) + promote_cmd.add_argument("--revision", required=True) + promote_cmd.add_argument("--file", type=Path, default=Path(".github/nextcloud-supported.json")) + args = parser.parse_args() + try: + if args.action == "stage-app": + stage(args.image, args.revision, args.tag) + elif args.action == "stage-web": + stage(args.image, args.revision, "web") + else: + promote(args.app_image, args.web_image, args.revision, args.file) + except (ValueError, OSError, subprocess.CalledProcessError) as error: + print(f"::error title=Container publication failed::{error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_publish_images.py b/tests/test_publish_images.py new file mode 100644 index 0000000..f637150 --- /dev/null +++ b/tests/test_publish_images.py @@ -0,0 +1,97 @@ +"""Mocked Docker tests: no registry writes, Docker daemon or network required.""" + +import sys +import unittest +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +import publish_images as images # noqa: E402 + +SHA = "a" * 40 +APP = "ghcr.io/librecodecoop/nextcloud-docker-app" +WEB = "ghcr.io/librecodecoop/nextcloud-docker-web" + + +class PublishImagesTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.approved = Path(self.directory.name) / "manifest.json" + manifest = { + "schema_version": 1, + "source": "https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule", + "versions": [ + {"major": 33, "release": "33.0.10", "eol": "2099-12"}, + {"major": 34, "release": "34.0.5", "eol": "2099-12"}, + {"major": 35, "release": "35.0.2", "eol": "2099-12"}, + ], + } + self.approved.write_text(json.dumps(manifest), encoding="utf-8") + @patch.object(images, "run") + def test_stage_app_publishes_only_requested_version(self, docker): + images.stage(APP, SHA, "34-fpm") + self.assertEqual([call.args for call in docker.call_args_list], [ + ("tag", "scan/app:amd64", f"{APP}:sha-{SHA}-34-fpm-amd64"), + ("push", f"{APP}:sha-{SHA}-34-fpm-amd64"), + ("tag", "scan/app:arm64", f"{APP}:sha-{SHA}-34-fpm-arm64"), + ("push", f"{APP}:sha-{SHA}-34-fpm-arm64"), + ]) + + @patch.object(images, "run") + def test_web_does_not_depend_on_nextcloud_major(self, docker): + images.stage(WEB, SHA, "web") + self.assertEqual(docker.call_count, 4) + self.assertIn(f"{WEB}:sha-{SHA}-web-arm64", str(docker.call_args_list)) + self.assertNotIn("35-fpm", str(docker.call_args_list)) + + @patch.object(images, "run") + def test_promote_uses_highest_approved_major_and_web_always(self, docker): + images.promote(APP, WEB, SHA, self.approved) + calls = [call.args for call in docker.call_args_list] + created = [item for item in calls if item[:3] == ("buildx", "imagetools", "create")] + self.assertEqual(len(created), 10) + tags = [item[4] for item in created] + self.assertEqual(tags, [ + f"{APP}:33-fpm", f"{APP}:33.0.10-fpm", + f"{APP}:34-fpm", f"{APP}:34.0.5-fpm", + f"{APP}:35-fpm", f"{APP}:35.0.2-fpm", + f"{APP}:stable-fpm", f"{APP}:latest", f"{WEB}:main", f"{WEB}:latest", + ]) + self.assertEqual(created[7][-2:], ( + f"{APP}:sha-{SHA}-35-fpm-amd64", f"{APP}:sha-{SHA}-35-fpm-arm64", + )) + self.assertEqual(created[8][-2:], ( + f"{WEB}:sha-{SHA}-web-amd64", f"{WEB}:sha-{SHA}-web-arm64", + )) + + @patch.object(images, "run") + @patch.object(images, "read_manifest") + def test_promote_selects_next_major_without_hardcoding(self, reviewed, docker): + reviewed.return_value = {"versions": [ + {"major": 34, "release": "34.0.5"}, + {"major": 35, "release": "35.0.2"}, + {"major": 36, "release": "36.0.0"}, + ]} + images.promote(APP, WEB, SHA, self.approved) + creates = [call.args for call in docker.call_args_list if call.args[:3] == + ("buildx", "imagetools", "create")] + self.assertIn(f"{APP}:sha-{SHA}-36-fpm-amd64", creates[7]) + self.assertIn(f"{APP}:sha-{SHA}-36-fpm-arm64", creates[7]) + self.assertEqual(creates[7][4], f"{APP}:latest") + + @patch.object(images, "run") + def test_invalid_tag_or_commit_never_publishes(self, docker): + for bad in ("35; rm -rf /", "latest", "0-fpm"): + with self.assertRaises(ValueError): + images.stage(APP, SHA, bad) + with self.assertRaises(ValueError): + images.stage(APP, "short", "35-fpm") + docker.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From 32a0d2fc5d2352a82bea4bf31a59c8df74d62796 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:55:15 +0000 Subject: [PATCH 06/10] test(ci): read Nextcloud example version dynamically Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/ncdd.bats | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ncdd.bats b/tests/ncdd.bats index b3801d9..aa899b5 100644 --- a/tests/ncdd.bats +++ b/tests/ncdd.bats @@ -41,9 +41,11 @@ teardown() { } @test "env reads the existing repository version source" { + expected_version=$(sed -n 's/^NEXTCLOUD_VERSION=//p' "$REPO_ROOT/.env.example" | head -n 1) + [ -n "$expected_version" ] run bash "$REPO_ROOT/bin/ncdd" env [ "$status" -eq 0 ] - [[ "$output" == *"NEXTCLOUD_VERSION=34-fpm"* ]] + [[ "$output" == *"NEXTCLOUD_VERSION=$expected_version"* ]] } @test "doctor exposes a machine-readable healthy state" { From 46506dd50dcc97a36d79123e3db7f8a454a2a054 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:30:09 +0000 Subject: [PATCH 07/10] refactor(ci): discover supported Nextcloud releases with Bash and Bats Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../workflows/discover-nextcloud-versions.yml | 10 +- .github/workflows/docker-image.yml | 10 +- Makefile | 2 +- scripts/nextcloud-versions.sh | 143 ++++++++++++++++++ scripts/parse-nextcloud-wiki.awk | 100 ++++++++++++ tests/nextcloud-versions.bats | 129 ++++++++++++++++ 6 files changed, 391 insertions(+), 3 deletions(-) create mode 100644 scripts/nextcloud-versions.sh create mode 100644 scripts/parse-nextcloud-wiki.awk create mode 100644 tests/nextcloud-versions.bats diff --git a/.github/workflows/discover-nextcloud-versions.yml b/.github/workflows/discover-nextcloud-versions.yml index df18c17..0ac4a6f 100644 --- a/.github/workflows/discover-nextcloud-versions.yml +++ b/.github/workflows/discover-nextcloud-versions.yml @@ -23,13 +23,21 @@ jobs: with: persist-credentials: false + - name: Setup Bats + uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 + with: + support-install: false + assert-install: false + detik-install: false + file-install: false + - name: Test wiki parser and publication policy run: make test-ci-scripts - name: Discover and verify maintained versions env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: python3 scripts/nextcloud_versions.py discover + run: bash scripts/nextcloud-versions.sh discover - name: Propose reviewed manifest update id: pull_request diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 5b69742..c8014c5 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -32,6 +32,14 @@ jobs: with: persist-credentials: false + - name: Setup Bats + uses: bats-core/bats-action@77d6fb60505b4d0d1d73e48bd035b55074bbfb43 # 4.0.0 + with: + support-install: false + assert-install: false + detik-install: false + file-install: false + - name: Run offline regression tests run: make test-ci-scripts @@ -52,7 +60,7 @@ jobs: - name: Validate reviewed manifest and emit matrix id: approved - run: python3 scripts/nextcloud_versions.py matrix --github-output "$GITHUB_OUTPUT" + run: bash scripts/nextcloud-versions.sh matrix --github-output "$GITHUB_OUTPUT" app: name: app ${{ matrix.tag }} diff --git a/Makefile b/Makefile index 4c16227..9284a20 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ test-scan-images: bash tests/test-scan-images.sh test-ci-scripts: - python3 -m unittest discover -s tests -p 'test_*.py' -v + bats tests/nextcloud-versions.bats test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/scripts/nextcloud-versions.sh b/scripts/nextcloud-versions.sh new file mode 100644 index 0000000..49e8933 --- /dev/null +++ b/scripts/nextcloud-versions.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Reviewable Nextcloud stable-version inventory. Requires curl, awk and jq. +set -euo pipefail + +source_url=https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule +wiki_url=https://raw.githubusercontent.com/wiki/nextcloud/server/Maintenance-and-Release-Schedule.md +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +manifest="$repo_root/.github/nextcloud-supported.json" +command_name=${1:?Usage: nextcloud-versions.sh discover|matrix [options]} +shift +wiki_file='' +github_output='' +skip_upstream=false +today=$(date -u +%F) + +error() { + local message=$1 + printf '::error title=Nextcloud version discovery failed::%s\n' "$message" >&2 + if [[ -n ${GITHUB_STEP_SUMMARY:-} ]]; then + printf '**Nextcloud version discovery FAILED:** %s. Existing manifest was preserved.\n' "$message" >> "$GITHUB_STEP_SUMMARY" + fi + exit 1 +} +trap 'error "Unexpected failure at line $LINENO (exit $?)"' ERR + +while (( $# )); do + case "$1" in + --file|--wiki-file|--github-output|--today) + (( $# >= 2 )) || error "Missing argument for $1" + case "$1" in + --file) manifest=$2 ;; + --wiki-file) wiki_file=$2 ;; + --github-output) github_output=$2 ;; + --today) today=$2 ;; + esac + shift 2 + ;; + --skip-upstream-check) skip_upstream=true; shift ;; + *) error "Unknown option: $1" ;; + esac +done + +[[ $today =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || error "Invalid date: $today" + +# Validate the committed JSON before it can reach the build matrix or tag promotion. +validate_manifest() { + local path=$1 + jq -e --arg source "$source_url" --arg month "${today:0:7}" ' + . as $root | + ($root | type == "object") and + (($root | keys) == ["schema_version","source","versions"]) and + ($root.schema_version == 1) and ($root.source == $source) and + ($root.versions | type == "array" and length >= 1 and length <= 5) and + ($root.versions | all(.[]; + type == "object" and + (keys == ["eol","major","release"]) 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 + (.eol | type == "string" and test("^20[0-9]{2}-(0[1-9]|1[0-2])$") and . >= $month) + )) and + ([$root.versions[].major] as $majors | + ($majors == ($majors | unique | sort)) and + all(range(1; ($majors | length)); . as $i | $majors[$i] == ($majors[$i-1] + 1))) + ' "$path" > /dev/null || error "Invalid or EOL approved manifest: $path" +} + +request() { + curl --fail --silent --show-error --location --retry 2 --max-time 30 \ + -H 'User-Agent: librecode-nextcloud-version-discovery' "$@" +} + +verify_release() { + local release=$1 github_tag docker_tag headers=() + if [[ -n ${GITHUB_TOKEN:-} ]]; then + headers=(-H "Authorization: Bearer $GITHUB_TOKEN") + fi + github_tag=$(request "${headers[@]}" \ + "https://api.github.com/repos/nextcloud-releases/server/releases/tags/v${release}") + jq -e --arg release "v$release" ' + .tag_name == $release and .prerelease == false and .draft == false + ' <<< "$github_tag" > /dev/null || error "No official stable GitHub release v$release" + + docker_tag=$(request "https://hub.docker.com/v2/repositories/library/nextcloud/tags/${release}-fpm") + jq -e --arg tag "${release}-fpm" ' + .name == $tag and + (.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" +} + +case "$command_name" in + matrix) + validate_manifest "$manifest" + matrix=$(jq -c '{include: [.versions[] | {major, tag: ((.major | tostring) + "-fpm"), release}]}' "$manifest") + if [[ -n $github_output ]]; then + printf 'matrix=%s\n' "$matrix" >> "$github_output" + else + printf '%s\n' "$matrix" + fi + printf 'Approved Nextcloud versions: %s\n' "$(jq -r '[.versions[].major | tostring] | join(", ")' "$manifest")" >&2 + ;; + discover) + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + if [[ -n $wiki_file ]]; then + cp -- "$wiki_file" "$tmpdir/wiki.md" + else + request "$wiki_url" > "$tmpdir/wiki.md" + fi + + awk -f "$repo_root/scripts/parse-nextcloud-wiki.awk" "$tmpdir/wiki.md" > "$tmpdir/releases.tsv" + while IFS=$'\t' read -r major release eol released; do + [[ $eol =~ ^20[0-9]{2}-(0[1-9]|1[0-2])$ && $eol > "${today:0:7}" || $eol == "${today:0:7}" ]] \ + || error "Nextcloud $major is EOL or has invalid EOL month: $eol" + [[ $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 + 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" + + validate_manifest "$tmpdir/manifest.json" + if [[ ! -f $manifest ]] || ! cmp -s "$tmpdir/manifest.json" "$manifest"; then + cp -- "$tmpdir/manifest.json" "$manifest" + status=updated + else + status=unchanged + fi + printf 'Nextcloud discovery: %s; manifest %s\n' \ + "$(jq -r '[.versions[].major | tostring] | join(", ")' "$manifest")" "$status" + if [[ -n ${GITHUB_STEP_SUMMARY:-} ]]; then + printf 'Nextcloud discovery: **%s**, manifest **%s**. Source: %s\n' \ + "$(jq -r '[.versions[].major | tostring] | join(", ")' "$manifest")" "$status" "$source_url" >> "$GITHUB_STEP_SUMMARY" + fi + ;; + *) error "Expected discover or matrix, received $command_name" ;; +esac diff --git a/scripts/parse-nextcloud-wiki.awk b/scripts/parse-nextcloud-wiki.awk new file mode 100644 index 0000000..8fc380f --- /dev/null +++ b/scripts/parse-nextcloud-wiki.awk @@ -0,0 +1,100 @@ +# Extract the supported-version table and reject conflicting EOL entries. +# Output: majorreleaseeol-monthrelease-date +function trim(value) { + gsub(/\r/, "", value) + gsub(/\*\*/, "", value) + gsub(sprintf("%c", 96), "", value) + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + return value +} +function columns(line, out, count, field_index, raw) { + line = trim(line) + sub(/^\|/, "", line) + sub(/\|$/, "", line) + count = split(line, raw, "|") + for (field_index = 1; field_index <= count; field_index++) { + out[field_index] = trim(raw[field_index]) + } + return count +} +function failure(message) { + print "Nextcloud wiki parser: " message > "/dev/stderr" + invalid = 1 + exit 1 +} +BEGIN { section = ""; state = 0; supported = 0; eol_rows = 0 } +{ + heading = trim($0) + if (heading ~ /^#+[[:space:]]+/) { + sub(/^#+[[:space:]]+/, "", heading) + heading = tolower(heading) + if (heading ~ /^currently (supported|maintained) versions?$/) { + if (supported_section++) failure("duplicate supported section") + section = "supported" + state = 0 + } else if (heading == "eol versions") { + section = "eol" + state = 0 + eol_section++ + } else { + section = "" + } + next + } + if (section == "" || $0 !~ /^[[:space:]]*\|/) next + + count = columns($0, parts) + if (state == 0) { + if (section == "supported") { + delete field_index + for (i = 1; i <= count; i++) field_index[tolower(parts[i])] = i + if (!("version code" in field_index) || !("release date" in field_index) || + !("end of life" in field_index) || !("current version" in field_index)) { + failure("supported table header changed") + } + } + header_columns = count + state = 1 + next + } + if (state == 1) { + if (count != header_columns) failure("separator column count changed") + for (i = 1; i <= count; i++) { + if (parts[i] !~ /^:?-{3,}:?$/) failure("invalid Markdown table separator") + } + state = 2 + next + } + if (count != header_columns) failure("table row column count changed") + if (section == "supported") { + major = parts[field_index["version code"]] + current = parts[field_index["current version"]] + split(current, release_fields, /[[:space:]]+/) + release = release_fields[1] + if (major !~ /^[0-9]+$/ || release !~ /^[0-9]+\.[0-9]+\.[0-9]+$/) { + failure("invalid current release or major " major) + } + if (int(major) != int(release) || int(major) < 20 || int(major) > 99) { + failure("major does not match current release " release) + } + if (seen[major]++) failure("duplicate supported major " major) + supported++ + versions[supported] = major "\t" release "\t" parts[field_index["end of life"]] "\t" parts[field_index["release date"]] + supported_majors[major] = 1 + } else if (section == "eol" && parts[1] ~ /^[0-9]+$/) { + eol_majors[parts[1]] = 1 + eol_rows++ + } +} +END { + if (invalid) exit 1 + if (supported_section != 1 || supported < 1 || supported > 5 || + eol_section != 1 || eol_rows < 1) { + failure("supported/EOL sections or their version tables are missing") + } + for (major in supported_majors) { + if (major in eol_majors) failure("version " major " is both supported and EOL") + } + for (i = 1; i <= supported; i++) print versions[i] +} diff --git a/tests/nextcloud-versions.bats b/tests/nextcloud-versions.bats new file mode 100644 index 0000000..b92c5bd --- /dev/null +++ b/tests/nextcloud-versions.bats @@ -0,0 +1,129 @@ +#!/usr/bin/env bats +# Offline regression tests for the official wiki and reviewed JSON format. +setup() { + cd "$BATS_TEST_DIRNAME/.." + TEST_ROOT=$(mktemp -d) + export TEST_ROOT + mkdir -p "$TEST_ROOT/bin" + export PATH="$TEST_ROOT/bin:$PATH" + script=scripts/nextcloud-versions.sh + inventory="$TEST_ROOT/versions.json" +} +teardown() { + rm -rf "$TEST_ROOT" +} +discover_fixture() { + bash "$script" discover --wiki-file "tests/fixtures/$1.md" \ + --today "$2" --skip-upstream-check --file "$inventory" +} +@test "historical 2023 maintained section parses three majors" { + run discover_fixture 2023-maintained 2023-07-01 + [ "$status" -eq 0 ] + [ "$(jq -r '[.versions[].major] | join(",")' "$inventory")" = 25,26,27 ] +} +@test "historical 2024 maintained section parses two majors" { + run discover_fixture 2024-maintained 2024-07-01 + [ "$status" -eq 0 ] + [ "$(jq -r '[.versions[].major] | join(",")' "$inventory")" = 28,29 ] +} +@test "historical 2025 supported section parses two majors" { + run discover_fixture 2025-supported 2025-07-01 + [ "$status" -eq 0 ] + [ "$(jq -r '[.versions[].major] | join(",")' "$inventory")" = 30,31 ] +} +@test "current supported section extracts 33, 34 and 35, without 32" { + 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 ] +} +@test "malformed wiki preserves the last approved manifest" { + echo '{"untouched":true}' > "$inventory" + run discover_fixture 2024-malformed 2024-10-24 + [ "$status" -ne 0 ] + [[ "$output" == *'::error title=Nextcloud version discovery failed::'* ]] + [ "$(cat "$inventory")" = '{"untouched":true}' ] +} +@test "upstream EOL overlap and unreleased versions fail closed" { + run discover_fixture 2025-eol-overlap 2025-04-15 + [ "$status" -ne 0 ] + [[ "$output" == *'both supported and EOL'* ]] + run discover_fixture 2026-unreleased 2026-02-12 + [ "$status" -ne 0 ] + [[ "$output" == *'invalid current release'* ]] +} +@test "renamed wiki heading fails instead of guessing" { + sed 's/Currently supported versions/Upcoming releases/' \ + tests/fixtures/2026-supported.md > "$TEST_ROOT/broken.md" + run bash "$script" discover --wiki-file "$TEST_ROOT/broken.md" \ + --skip-upstream-check --today 2026-10-20 --file "$inventory" + [ "$status" -ne 0 ] + [ ! -f "$inventory" ] +} +@test "EOL date and future release date prevent a proposal" { + run discover_fixture 2026-supported 2027-10-01 + [ "$status" -ne 0 ] + [[ "$output" == *EOL* ]] + run discover_fixture 2026-supported 2026-02-01 + [ "$status" -ne 0 ] + [[ "$output" == *EOL* || "$output" == *'in the future'* ]] +} +@test "matrix contains only approved and ordered releases" { + discover_fixture 2026-supported 2026-10-20 + run bash "$script" matrix --file "$inventory" --today 2026-10-20 \ + --github-output "$TEST_ROOT/output" + [ "$status" -eq 0 ] + jq -e '.include | map(.tag) == ["33-fpm","34-fpm","35-fpm"]' \ + <(sed 's/^matrix=//' "$TEST_ROOT/output") + jq -e '.include[-1].release == "35.0.2"' \ + <(sed 's/^matrix=//' "$TEST_ROOT/output") +} +@test "out-of-order and expired JSON is rejected" { + discover_fixture 2026-supported 2026-10-20 + jq '.versions |= reverse' "$inventory" > "$TEST_ROOT/reversed.json" + run bash "$script" matrix --file "$TEST_ROOT/reversed.json" --today 2026-10-20 + [ "$status" -ne 0 ] + jq '.versions[0].eol = "2026-09"' "$inventory" > "$TEST_ROOT/expired.json" + run bash "$script" matrix --file "$TEST_ROOT/expired.json" --today 2026-10-20 + [ "$status" -ne 0 ] +} +@test "upstream confirmation verifies release and both FPM architectures" { + cat > "$TEST_ROOT/bin/curl" <<'MOCK' +#!/usr/bin/env bash +url="${*: -1}" +case "$url" in + *api.github.com*) + tag="${url##*/}" + printf '{"tag_name":"%s","prerelease":false,"draft":false}\n' "$tag" + ;; + *hub.docker.com*) + tag="${url##*/}" + printf '{"name":"%s","images":[{"os":"linux","architecture":"amd64"},{"os":"linux","architecture":"arm64"}]}\n' "$tag" + ;; + *) exit 1 ;; +esac +MOCK + chmod +x "$TEST_ROOT/bin/curl" + run bash "$script" discover --wiki-file tests/fixtures/2026-supported.md \ + --today 2026-10-20 --file "$inventory" + [ "$status" -eq 0 ] +} +@test "missing upstream arm64 architecture rejects proposed manifest" { + cat > "$TEST_ROOT/bin/curl" <<'MOCK' +#!/usr/bin/env bash +url="${*: -1}" +case "$url" in + *api.github.com*) printf '{"tag_name":"%s","prerelease":false,"draft":false}\n' "${url##*/}" ;; + *hub.docker.com*) + printf '{"name":"%s","images":[{"os":"linux","architecture":"amd64"}]}\n' "${url##*/}" + ;; + *) exit 1 ;; +esac +MOCK + chmod +x "$TEST_ROOT/bin/curl" + run bash "$script" discover --wiki-file tests/fixtures/2026-supported.md \ + --today 2026-10-20 --file "$inventory" + [ "$status" -ne 0 ] + [[ "$output" == *'lacks amd64 or arm64'* ]] + [ ! -f "$inventory" ] +} From f01bccd71949f49579f430475a8a72cee758f902 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:31:17 +0000 Subject: [PATCH 08/10] refactor(ci): share Bash image publishing and retire Python tooling Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../workflows/discover-nextcloud-versions.yml | 3 + .github/workflows/docker-image.yml | 12 +- .gitignore | 2 - Makefile | 2 +- docs/images.md | 4 +- scripts/nextcloud_versions.py | 229 ------------------ scripts/publish-images.sh | 74 ++++++ scripts/publish-manifest.sh | 12 + scripts/publish-master-manifest.sh | 8 +- scripts/publish_images.py | 110 --------- scripts/push-architecture.sh | 11 + scripts/push-master-architecture.sh | 19 +- tests/publish-images.bats | 78 ++++++ tests/test_nextcloud_versions.py | 125 ---------- tests/test_publish_images.py | 97 -------- 15 files changed, 199 insertions(+), 587 deletions(-) delete mode 100644 scripts/nextcloud_versions.py create mode 100644 scripts/publish-images.sh create mode 100644 scripts/publish-manifest.sh delete mode 100644 scripts/publish_images.py create mode 100644 scripts/push-architecture.sh create mode 100644 tests/publish-images.bats delete mode 100644 tests/test_nextcloud_versions.py delete mode 100644 tests/test_publish_images.py diff --git a/.github/workflows/discover-nextcloud-versions.yml b/.github/workflows/discover-nextcloud-versions.yml index 0ac4a6f..ca6ff2d 100644 --- a/.github/workflows/discover-nextcloud-versions.yml +++ b/.github/workflows/discover-nextcloud-versions.yml @@ -34,6 +34,9 @@ jobs: - name: Test wiki parser and publication policy run: make test-ci-scripts + - name: ShellCheck version discovery + run: shellcheck scripts/nextcloud-versions.sh + - name: Discover and verify maintained versions env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c8014c5..584b937 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -46,6 +46,12 @@ jobs: - name: Test Trivy wrapper run: bash tests/test-scan-images.sh + - name: ShellCheck image publication scripts + run: | + 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 + versions: needs: script-tests name: Load approved Nextcloud releases @@ -116,7 +122,7 @@ jobs: - name: Stage validated architectures if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - run: python3 scripts/publish_images.py stage-app --image "$APP_IMAGE" --revision "$GITHUB_SHA" --tag "${{ matrix.tag }}" + run: bash scripts/publish-images.sh stage-app "$APP_IMAGE" "$GITHUB_SHA" "${{ matrix.tag }}" web: name: web runtime @@ -168,7 +174,7 @@ jobs: - name: Stage validated web architectures if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - run: python3 scripts/publish_images.py stage-web --image "$WEB_IMAGE" --revision "$GITHUB_SHA" + run: bash scripts/publish-images.sh stage-web "$WEB_IMAGE" "$GITHUB_SHA" promote: name: Publish versioned manifests and moving aliases @@ -195,4 +201,4 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Publish approved images and aliases - run: python3 scripts/publish_images.py promote --app-image "$APP_IMAGE" --web-image "$WEB_IMAGE" --revision "$GITHUB_SHA" + run: bash scripts/publish-images.sh promote "$APP_IMAGE" "$WEB_IMAGE" "$GITHUB_SHA" .github/nextcloud-supported.json diff --git a/.gitignore b/.gitignore index e9f3a04..cd252ac 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,3 @@ docker-compose.override.yml .trivy-cache/ trivy-results/ -__pycache__/ -*.pyc diff --git a/Makefile b/Makefile index 9284a20..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 + bats tests/nextcloud-versions.bats tests/publish-images.bats test-development-image-scripts: bats tests/nextcloud-development-image.bats diff --git a/docs/images.md b/docs/images.md index be3ffdc..6cefd28 100644 --- a/docs/images.md +++ b/docs/images.md @@ -222,7 +222,7 @@ The latest supported major is the highest major in this reviewed list; no major The `Discover maintained Nextcloud versions` workflow checks the upstream [Server maintenance wiki](https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule) twice each day and on manual dispatch. Discovery uses -`scripts/nextcloud_versions.py` to extract the **Currently supported/maintained versions** table +`scripts/nextcloud-versions.sh` and the small `scripts/parse-nextcloud-wiki.awk` parser to extract the **Currently supported/maintained versions** table and verifies the independent **EOL Versions** table. It rejects changed table structures, duplicate majors, expired EOL months, future release dates and invalid version formats. It then checks the official `nextcloud-releases/server` release and the @@ -245,7 +245,7 @@ After a human reviews and merges the manifest PR, the stable workflow uses the c to build and scan each `nextcloud:-fpm` with Bats runtime acceptance. The independent web image is built and scanned **once**, without tying it to a Nextcloud major. Only successful jobs push architecture-specific staging tags. After all jobs pass, -`scripts/publish_images.py` publishes the versioned multi-platform manifests, followed by: +`scripts/publish-images.sh`, using the same Bash manifest helper as the master channel, publishes the versioned multi-platform manifests, followed by: - The `:-fpm` channels for every approved, supported major. - The `:-fpm` patch tags for the verified upstream releases. diff --git a/scripts/nextcloud_versions.py b/scripts/nextcloud_versions.py deleted file mode 100644 index f2b2bbb..0000000 --- a/scripts/nextcloud_versions.py +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env python3 -"""Discover maintained Nextcloud releases and validate the reviewed CI manifest.""" - -from __future__ import annotations - -import argparse -import datetime as dt -import json -import os -import re -import sys -import urllib.error -import urllib.request -from pathlib import Path - -SOURCE = "https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule" -MARKDOWN_URL = "https://raw.githubusercontent.com/wiki/nextcloud/server/Maintenance-and-Release-Schedule.md" -DEFAULT_MANIFEST = Path(".github/nextcloud-supported.json") -SECTION = re.compile(r"^#{1,6}\s+Currently\s+(?:supported|maintained)\s+versions?\s*$", re.I) -HEADING = re.compile(r"^#{1,3}\s+") -RELEASE = re.compile(r"^\d{2,3}\.\d+\.\d+$") -REQUIRED_COLUMNS = {"version code", "release date", "end of life", "current version"} - - -class VersionError(ValueError): - """Unexpected upstream or manifest structure; never guess a release list.""" - - -def cells(line: str) -> list[str]: - return [re.sub(r"\s+", " ", cell.strip().replace("**", "").replace(chr(96), "")) - for cell in line.strip().strip("|").split("|")] - - -def table_after(lines: list[str], start: int, end: int) -> list[dict[str, str]]: - for index in range(start, end - 1): - if not lines[index].lstrip().startswith("|"): - continue - header = [col.lower() for col in cells(lines[index])] - if not REQUIRED_COLUMNS.issubset(set(header)): - continue - separator = cells(lines[index + 1]) - if len(separator) != len(header) or not all(re.fullmatch(r":?-{3,}:?", col) for col in separator): - raise VersionError("Supported-version table separator has changed") - rows = [] - for line in lines[index + 2:end]: - if not line.lstrip().startswith("|"): - break - values = cells(line) - if len(values) != len(header): - raise VersionError("Supported-version table row has a different column count") - rows.append(dict(zip(header, values))) - if not rows: - raise VersionError("Supported-version table has no data rows") - return rows - raise VersionError("Supported-version table or required columns not found") - - -def parse_wiki(markdown: str, today: dt.date | None = None) -> dict: - today = today or dt.datetime.now(dt.timezone.utc).date() - lines = markdown.splitlines() - start = next((i for i, line in enumerate(lines) if SECTION.fullmatch(line.strip())), None) - if start is None: - raise VersionError("No 'Currently supported/maintained versions' section") - end = next((i for i in range(start + 1, len(lines)) if HEADING.match(lines[i])), len(lines)) - rows = table_after(lines, start + 1, end) - versions = [] - for row in rows: - try: - major = int(row["version code"]) - except ValueError as error: - raise VersionError(f"Invalid major version: {row['version code']!r}") from error - match = re.match(r"^(\d{2,3}\.\d+\.\d+)(?:\s|$)", row["current version"]) - release = match.group(1) if match else "" - eol = row["end of life"] - if not RELEASE.fullmatch(release) or int(release.split(".", 1)[0]) != major: - raise VersionError(f"Invalid current release for Nextcloud {major}: {row['current version']!r}") - try: - eol_date = dt.date.fromisoformat(eol + "-01") - released = dt.date.fromisoformat(row["release date"]) - except ValueError as error: - raise VersionError(f"Invalid release/EOL date for Nextcloud {major}") from error - if eol_date.year < today.year or (eol_date.year == today.year and eol_date.month < today.month): - raise VersionError(f"Upstream still lists Nextcloud {major} although EOL month {eol} has passed") - if released > today: - raise VersionError(f"Nextcloud {major} is listed before its release date") - versions.append({"major": major, "release": release, "eol": eol}) - # Independently check that no proposed major is listed as End of Life. - eol_start = next((i for i, line in enumerate(lines) if re.match(r"^#{1,3}\s+EOL Versions\s*$", line, re.I)), None) - if eol_start is None: - raise VersionError("Missing 'EOL Versions' section") - eol_end = next((i for i in range(eol_start + 1, len(lines)) if HEADING.match(lines[i])), len(lines)) - eol_majors = set() - for line in lines[eol_start + 1:eol_end]: - if line.lstrip().startswith("|"): - first = cells(line)[0] - if first.isdigit(): - eol_majors.add(int(first)) - conflict = eol_majors.intersection(v["major"] for v in versions) - if conflict: - raise VersionError(f"Versions listed as both supported and EOL: {sorted(conflict)}") - manifest = {"schema_version": 1, "source": SOURCE, "versions": sorted(versions, key=lambda x: x["major"])} - validate_manifest(manifest, today=today) - return manifest - - -def validate_manifest(manifest: dict, today: dt.date | None = None) -> list[dict]: - today = today or dt.datetime.now(dt.timezone.utc).date() - if not isinstance(manifest, dict) or manifest.get("schema_version") != 1 or manifest.get("source") != SOURCE: - raise VersionError("Unknown supported-versions schema or provenance") - versions = manifest.get("versions") - if not isinstance(versions, list) or not 1 <= len(versions) <= 5: - raise VersionError("Expected between one and five maintained majors") - majors = [] - for entry in versions: - if not isinstance(entry, dict) or set(entry) != {"major", "release", "eol"}: - raise VersionError("Malformed version entry") - major, release, eol = entry["major"], entry["release"], entry["eol"] - if type(major) is not int or not 20 <= major <= 99 or not isinstance(release, str): - raise VersionError("Invalid major or release type") - if not RELEASE.fullmatch(release) or int(release.split(".", 1)[0]) != major: - raise VersionError(f"Invalid release for major {major}") - if not isinstance(eol, str) or not re.fullmatch(r"20\d{2}-(0[1-9]|1[0-2])", eol): - raise VersionError(f"Invalid EOL month for major {major}") - if eol < today.strftime("%Y-%m"): - raise VersionError(f"Nextcloud {major} has reached EOL ({eol}); update the approved manifest") - majors.append(major) - if sorted(set(majors)) != majors: - raise VersionError("Majors must be unique and sorted ascending") - if any(b - a != 1 for a, b in zip(majors, majors[1:])): - raise VersionError("Unexpected gap in supported majors; review the upstream schedule") - return versions - - -def request_json(url: str) -> dict: - headers = {"User-Agent": "librecode-nextcloud-version-discovery", "Accept": "application/json"} - if "api.github.com" in url and os.environ.get("GITHUB_TOKEN"): - headers["Authorization"] = "Bearer " + os.environ["GITHUB_TOKEN"] - with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=20) as response: - return json.load(response) - - -def verify_upstream(versions: list[dict]) -> None: - """Require an official stable release and an FPM image for each proposed version.""" - for item in versions: - release = item["release"] - published = request_json( - f"https://api.github.com/repos/nextcloud-releases/server/releases/tags/v{release}" - ) - if published.get("tag_name") != f"v{release}" or published.get("prerelease") or published.get("draft"): - raise VersionError(f"Nextcloud {release} is not an official stable GitHub release") - docker_tag = request_json( - f"https://hub.docker.com/v2/repositories/library/nextcloud/tags/{release}-fpm" - ) - if docker_tag.get("name") != f"{release}-fpm": - raise VersionError(f"Official Docker image nextcloud:{release}-fpm is unavailable") - platforms = {(i.get("os"), i.get("architecture")) for i in docker_tag.get("images", [])} - if not {("linux", "amd64"), ("linux", "arm64")}.issubset(platforms): - raise VersionError(f"nextcloud:{release}-fpm lacks amd64 or arm64") - - -def read_manifest(path: Path) -> dict: - try: - manifest = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise VersionError(f"Cannot load reviewed supported versions at {path}: {error}") from error - validate_manifest(manifest) - return manifest - - -def append_summary(message: str) -> None: - print(message) - if os.getenv("GITHUB_STEP_SUMMARY"): - with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: - summary.write(message + "\n") - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - sub = parser.add_subparsers(dest="command", required=True) - discover = sub.add_parser("discover", help="Parse upstream wiki and propose JSON") - discover.add_argument("--file", type=Path, default=DEFAULT_MANIFEST) - discover.add_argument("--wiki-file", type=Path, help="Read a local fixture instead") - discover.add_argument("--skip-upstream-check", action="store_true", help="Offline testing only") - matrix = sub.add_parser("matrix", help="Validate reviewed JSON and export GitHub Actions matrix") - matrix.add_argument("--file", type=Path, default=DEFAULT_MANIFEST) - matrix.add_argument("--github-output", type=Path) - args = parser.parse_args() - try: - if args.command == "discover": - if args.wiki_file: - markdown = args.wiki_file.read_text(encoding="utf-8") - else: - with urllib.request.urlopen( - urllib.request.Request(MARKDOWN_URL, headers={"User-Agent": "librecode-nextcloud-version-discovery"}), - timeout=20, - ) as response: - markdown = response.read().decode("utf-8") - candidate = parse_wiki(markdown) - if not args.skip_upstream_check: - verify_upstream(candidate["versions"]) - new_content = json.dumps(candidate, indent=2, ensure_ascii=False) + "\n" - changed = not args.file.exists() or args.file.read_text(encoding="utf-8") != new_content - if changed: - args.file.write_text(new_content, encoding="utf-8") - append_summary(f"Nextcloud discovery: {', '.join(str(v['major']) for v in candidate['versions'])}; " - f"manifest {'updated' if changed else 'unchanged'}. Source: {SOURCE}") - else: - versions = read_manifest(args.file)["versions"] - include = [ - {"major": item["major"], "tag": f"{item['major']}-fpm", "release": item["release"]} - for item in versions - ] - matrix_json = json.dumps({"include": include}, separators=(",", ":")) - if args.github_output: - with args.github_output.open("a", encoding="utf-8") as output: - output.write(f"matrix={matrix_json}\n") - else: - print(matrix_json) - append_summary(f"Approved Nextcloud versions: {', '.join(str(v['major']) for v in versions)}") - except (OSError, ValueError, urllib.error.URLError) as error: - message = str(error).replace("\n", " ") - print(f"::error title=Nextcloud version discovery failed::{message}", file=sys.stderr) - append_summary(f"**Nextcloud version discovery FAILED:** {message}. No manifest update can be proposed.") - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/publish-images.sh b/scripts/publish-images.sh new file mode 100644 index 0000000..000a757 --- /dev/null +++ b/scripts/publish-images.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Publish only scanned images; moving tags wait for every CI matrix job to pass. +set -euo pipefail +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +fail() { + echo "::error title=Container publication failed::$1" >&2 + exit 2 +} +validate_image() { + [[ $1 =~ ^[a-z0-9.-]+(:[0-9]+)?(/[a-z0-9._-]+)+$ ]] || fail "Invalid image name: $1" +} +validate_revision() { + [[ $1 =~ ^[0-9a-f]{40}$ ]] || fail "Expected a full 40-digit Git SHA" +} +stage() { + local image=$1 revision=$2 variant=$3 local_component tag arch + validate_image "$image" + validate_revision "$revision" + if [[ $variant == web ]]; then + local_component=web + else + [[ $variant =~ ^[2-9][0-9]-fpm$ ]] || fail "Invalid Nextcloud channel: $variant" + local_component=app + fi + for arch in amd64 arm64; do + tag="sha-${revision}-${variant}-${arch}" + bash "$script_dir/push-architecture.sh" "$image" "scan/${local_component}:${arch}" "$tag" + done +} +publish() { + local image=$1 tag=$2 prefix=$3 + bash "$script_dir/publish-manifest.sh" "$image" "$tag" \ + "${image}:${prefix}-amd64" "${image}:${prefix}-arm64" +} +promote() { + local app_image=$1 web_image=$2 revision=$3 manifest_file=$4 + local major release prefix latest_prefix + validate_image "$app_image" + validate_image "$web_image" + validate_revision "$revision" + + # Independently enforce the reviewed schema and EOL policy before any pushes. + bash "$script_dir/nextcloud-versions.sh" matrix --file "$manifest_file" > /dev/null + while IFS=$'\t' read -r major release; do + prefix="sha-${revision}-${major}-fpm" + publish "$app_image" "${major}-fpm" "$prefix" + publish "$app_image" "${release}-fpm" "$prefix" + latest_prefix=$prefix + done < <(jq -r '.versions[] | [.major, .release] | @tsv' "$manifest_file") + + [[ -n ${latest_prefix:-} ]] || fail "No approved stable Nextcloud major" + publish "$app_image" stable-fpm "$latest_prefix" + publish "$app_image" latest "$latest_prefix" + + publish "$web_image" main "sha-${revision}-web" + publish "$web_image" latest "sha-${revision}-web" +} + +case ${1:-} in + stage-app) + (( $# == 4 )) || fail 'Usage: stage-app ' + stage "$2" "$3" "$4" + ;; + stage-web) + (( $# == 3 )) || fail 'Usage: stage-web ' + stage "$2" "$3" web + ;; + promote) + (( $# == 5 )) || fail 'Usage: promote ' + promote "$2" "$3" "$4" "$5" + ;; + *) fail 'Expected stage-app, stage-web or promote' ;; +esac diff --git a/scripts/publish-manifest.sh b/scripts/publish-manifest.sh new file mode 100644 index 0000000..a9532ed --- /dev/null +++ b/scripts/publish-manifest.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Common multi-platform manifest publisher for master and stable channels. +set -euo pipefail + +image=${1:?Usage: publish-manifest.sh } +tag=${2:?Missing target tag} +amd64=${3:?Missing amd64 staging image} +arm64=${4:?Missing arm64 staging image} +(( $# == 4 )) || { echo 'Expected exactly two architecture sources' >&2; exit 2; } + +docker buildx imagetools create --tag "${image}:${tag}" "$amd64" "$arm64" +docker buildx imagetools inspect "${image}:${tag}" diff --git a/scripts/publish-master-manifest.sh b/scripts/publish-master-manifest.sh index 47f3464..1c90a4f 100644 --- a/scripts/publish-master-manifest.sh +++ b/scripts/publish-master-manifest.sh @@ -1,13 +1,9 @@ #!/usr/bin/env bash - set -euo pipefail app_image=${1:?Usage: publish-master-manifest.sh } -revision=${2:?Usage: publish-master-manifest.sh } +revision=${2:?Missing revision} -docker buildx imagetools create \ - --tag "${app_image}:master-fpm" \ +bash "$(dirname "$0")/publish-manifest.sh" "$app_image" master-fpm \ "${app_image}:master-fpm-${revision}-amd64" \ "${app_image}:master-fpm-${revision}-arm64" - -docker buildx imagetools inspect "${app_image}:master-fpm" diff --git a/scripts/publish_images.py b/scripts/publish_images.py deleted file mode 100644 index bace532..0000000 --- a/scripts/publish_images.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -"""Stage tested multi-architecture images and promote only after all CI gates pass.""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -from pathlib import Path - -from nextcloud_versions import read_manifest - -ARCHITECTURES = ("amd64", "arm64") -GIT_SHA = re.compile(r"^[a-f0-9]{40}$") -IMAGE = re.compile(r"^[a-z0-9.-]+(?::[0-9]+)?/[a-z0-9._/-]+$") - - -def run(*arguments: str) -> None: - subprocess.run(["docker", *arguments], check=True) - - -def validate(image: str, revision: str) -> None: - if not IMAGE.fullmatch(image): - raise ValueError(f"Unsafe image reference: {image!r}") - if not GIT_SHA.fullmatch(revision): - raise ValueError("Expected the full 40-character Git commit SHA") - - -def staging_tag(image: str, revision: str, component: str, arch: str) -> str: - assert arch in ARCHITECTURES - if component == "web": - return f"{image}:sha-{revision}-web-{arch}" - if not re.fullmatch(r"[2-9]\d-fpm", component): - raise ValueError(f"Invalid Nextcloud channel: {component}") - return f"{image}:sha-{revision}-{component}-{arch}" - - -def stage(image: str, revision: str, component: str) -> None: - validate(image, revision) - if component != "web" and not re.fullmatch(r"[2-9]\d-fpm", component): - raise ValueError("Expected a stable Nextcloud major-fpm tag") - local_component = "web" if component == "web" else "app" - for arch in ARCHITECTURES: - local = f"scan/{local_component}:{arch}" - remote = staging_tag(image, revision, component, arch) - run("tag", local, remote) - run("push", remote) - - -def manifest(image: str, tag: str, sources: list[str]) -> None: - if len(sources) != 2: - raise ValueError("A manifest must combine exactly amd64 and arm64") - run("buildx", "imagetools", "create", "--tag", f"{image}:{tag}", *sources) - run("buildx", "imagetools", "inspect", f"{image}:{tag}") - - -def promote(app_image: str, web_image: str, revision: str, manifest_file: Path) -> None: - validate(app_image, revision) - validate(web_image, revision) - versions = read_manifest(manifest_file)["versions"] - # Publish stable versioned manifests before moving any alias. - for entry in versions: - tag = f"{entry['major']}-fpm" - sources = [staging_tag(app_image, revision, tag, arch) for arch in ARCHITECTURES] - manifest(app_image, tag, sources) - manifest(app_image, f"{entry['release']}-fpm", sources) - current = f"{versions[-1]['major']}-fpm" - current_sources = [staging_tag(app_image, revision, current, arch) for arch in ARCHITECTURES] - # Both aliases move only after every supported version has passed all jobs. - manifest(app_image, "stable-fpm", current_sources) - manifest(app_image, "latest", current_sources) - - web_sources = [staging_tag(web_image, revision, "web", arch) for arch in ARCHITECTURES] - manifest(web_image, "main", web_sources) - # Keep the existing web latest consumers on a tested web runtime. - manifest(web_image, "latest", web_sources) - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - sub = parser.add_subparsers(dest="action", required=True) - app = sub.add_parser("stage-app") - app.add_argument("--image", required=True) - app.add_argument("--revision", required=True) - app.add_argument("--tag", required=True) - web = sub.add_parser("stage-web") - web.add_argument("--image", required=True) - web.add_argument("--revision", required=True) - promote_cmd = sub.add_parser("promote") - promote_cmd.add_argument("--app-image", required=True) - promote_cmd.add_argument("--web-image", required=True) - promote_cmd.add_argument("--revision", required=True) - promote_cmd.add_argument("--file", type=Path, default=Path(".github/nextcloud-supported.json")) - args = parser.parse_args() - try: - if args.action == "stage-app": - stage(args.image, args.revision, args.tag) - elif args.action == "stage-web": - stage(args.image, args.revision, "web") - else: - promote(args.app_image, args.web_image, args.revision, args.file) - except (ValueError, OSError, subprocess.CalledProcessError) as error: - print(f"::error title=Container publication failed::{error}", file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/push-architecture.sh b/scripts/push-architecture.sh new file mode 100644 index 0000000..9cf5cf7 --- /dev/null +++ b/scripts/push-architecture.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Reuse the same tested push sequence for release and development channels. +set -euo pipefail + +image=${1:?Usage: push-architecture.sh } +source_image=${2:?Missing source image} +staging_tag=${3:?Missing staging tag} +(( $# == 3 )) || exit 2 + +docker tag "$source_image" "${image}:${staging_tag}" +docker push "${image}:${staging_tag}" diff --git a/scripts/push-master-architecture.sh b/scripts/push-master-architecture.sh index 6268c2c..fe573cc 100644 --- a/scripts/push-master-architecture.sh +++ b/scripts/push-master-architecture.sh @@ -1,20 +1,15 @@ #!/usr/bin/env bash - set -euo pipefail app_image=${1:?Usage: push-master-architecture.sh } -source_tag=${2:?Usage: push-master-architecture.sh } -revision=${3:?Usage: push-master-architecture.sh } -arch=${4:?Usage: push-master-architecture.sh } +source_tag=${2:?Missing source image} +revision=${3:?Missing revision} +arch=${4:?Missing architecture} -case "${arch}" in +case "$arch" in amd64|arm64) ;; - *) - echo "Unsupported architecture: ${arch}" >&2 - exit 2 - ;; + *) echo "Unsupported architecture: $arch" >&2; exit 2 ;; esac -staging_tag="master-fpm-${revision}-${arch}" -docker tag "${source_tag}" "${app_image}:${staging_tag}" -docker push "${app_image}:${staging_tag}" +bash "$(dirname "$0")/push-architecture.sh" \ + "$app_image" "$source_tag" "master-fpm-${revision}-${arch}" diff --git a/tests/publish-images.bats b/tests/publish-images.bats new file mode 100644 index 0000000..f0ce99e --- /dev/null +++ b/tests/publish-images.bats @@ -0,0 +1,78 @@ +#!/usr/bin/env bats +# Docker is mocked; these tests never push real images to a registry. +setup() { + cd "$BATS_TEST_DIRNAME/.." + TEST_ROOT=$(mktemp -d) + export TEST_ROOT + mkdir -p "$TEST_ROOT/bin" + export PATH="$TEST_ROOT/bin:$PATH" + export DOCKER_COMMAND_LOG="$TEST_ROOT/docker.log" + touch "$DOCKER_COMMAND_LOG" + script=scripts/publish-images.sh + APP_IMAGE=ghcr.io/librecodecoop/nextcloud-docker-app + WEB_IMAGE=ghcr.io/librecodecoop/nextcloud-docker-web + SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + INVENTORY="$TEST_ROOT/approved.json" + jq '.versions |= map(.eol = "2099-12")' \ + .github/nextcloud-supported.json > "$INVENTORY" + cat > "$TEST_ROOT/bin/docker" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$DOCKER_COMMAND_LOG" +MOCK + chmod +x "$TEST_ROOT/bin/docker" +} +teardown() { + rm -rf "$TEST_ROOT" +} +@test "stage-app only pushes its requested architecture images" { + run bash "$script" stage-app "$APP_IMAGE" "$SHA" 34-fpm + [ "$status" -eq 0 ] + [ "$(wc -l < "$DOCKER_COMMAND_LOG")" -eq 4 ] + grep -Fx "tag scan/app:amd64 ${APP_IMAGE}:sha-${SHA}-34-fpm-amd64" "$DOCKER_COMMAND_LOG" + grep -Fx "push ${APP_IMAGE}:sha-${SHA}-34-fpm-arm64" "$DOCKER_COMMAND_LOG" +} +@test "web staging never depends on a Nextcloud major" { + run bash "$script" stage-web "$WEB_IMAGE" "$SHA" + [ "$status" -eq 0 ] + grep -Fx "tag scan/web:amd64 ${WEB_IMAGE}:sha-${SHA}-web-amd64" "$DOCKER_COMMAND_LOG" + ! grep -q '35-fpm' "$DOCKER_COMMAND_LOG" +} +@test "invalid channel, image or SHA never invokes Docker" { + run bash "$script" stage-app "$APP_IMAGE" "$SHA" '35;exit' + [ "$status" -ne 0 ] + run bash "$script" stage-app "$APP_IMAGE" invalid 35-fpm + [ "$status" -ne 0 ] + run bash "$script" stage-app 'bad;rm' "$SHA" 35-fpm + [ "$status" -ne 0 ] + [ ! -s "$DOCKER_COMMAND_LOG" ] +} +@test "publish all approved versions and newest aliases after validation" { + run bash "$script" promote "$APP_IMAGE" "$WEB_IMAGE" "$SHA" "$INVENTORY" + [ "$status" -eq 0 ] + [ "$(grep -c '^buildx imagetools create ' "$DOCKER_COMMAND_LOG")" -eq 10 ] + grep -Fx "buildx imagetools create --tag ${APP_IMAGE}:33-fpm ${APP_IMAGE}:sha-${SHA}-33-fpm-amd64 ${APP_IMAGE}:sha-${SHA}-33-fpm-arm64" "$DOCKER_COMMAND_LOG" + grep -Fx "buildx imagetools create --tag ${APP_IMAGE}:35.0.2-fpm ${APP_IMAGE}:sha-${SHA}-35-fpm-amd64 ${APP_IMAGE}:sha-${SHA}-35-fpm-arm64" "$DOCKER_COMMAND_LOG" + grep -Fx "buildx imagetools create --tag ${APP_IMAGE}:latest ${APP_IMAGE}:sha-${SHA}-35-fpm-amd64 ${APP_IMAGE}:sha-${SHA}-35-fpm-arm64" "$DOCKER_COMMAND_LOG" + grep -Fx "buildx imagetools create --tag ${WEB_IMAGE}:main ${WEB_IMAGE}:sha-${SHA}-web-amd64 ${WEB_IMAGE}:sha-${SHA}-web-arm64" "$DOCKER_COMMAND_LOG" + 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"}])' \ + "$INVENTORY" > "$TEST_ROOT/next.json" + run bash "$script" promote "$APP_IMAGE" "$WEB_IMAGE" "$SHA" "$TEST_ROOT/next.json" + [ "$status" -eq 0 ] + grep -Fx "buildx imagetools create --tag ${APP_IMAGE}:latest ${APP_IMAGE}:sha-${SHA}-36-fpm-amd64 ${APP_IMAGE}:sha-${SHA}-36-fpm-arm64" "$DOCKER_COMMAND_LOG" + ! grep -q "sha-${SHA}-33-fpm" "$DOCKER_COMMAND_LOG" +} +@test "expired inventory stops before any Docker operation" { + jq '.versions[0].eol = "2026-09"' "$INVENTORY" > "$TEST_ROOT/expired.json" + run bash "$script" promote "$APP_IMAGE" "$WEB_IMAGE" "$SHA" "$TEST_ROOT/expired.json" + [ "$status" -ne 0 ] + [ ! -s "$DOCKER_COMMAND_LOG" ] +} +@test "upstream master still uses same tested manifest publisher" { + run bash scripts/publish-master-manifest.sh "$APP_IMAGE" "$SHA" + [ "$status" -eq 0 ] + grep -Fx "buildx imagetools create --tag ${APP_IMAGE}:master-fpm ${APP_IMAGE}:master-fpm-${SHA}-amd64 ${APP_IMAGE}:master-fpm-${SHA}-arm64" "$DOCKER_COMMAND_LOG" +} diff --git a/tests/test_nextcloud_versions.py b/tests/test_nextcloud_versions.py deleted file mode 100644 index 9fa774a..0000000 --- a/tests/test_nextcloud_versions.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Offline regression tests for wiki discovery, schema validation and failure handling.""" - -import datetime as dt -import json -import subprocess -import sys -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "scripts")) -import nextcloud_versions as versions # noqa: E402 - - -class NextcloudVersionsTest(unittest.TestCase): - def fixture(self, name: str) -> str: - return (ROOT / "tests" / "fixtures" / (name + ".md")).read_text(encoding="utf-8") - - def test_real_historical_wiki_formats(self): - cases = [ - ("2023-maintained", dt.date(2023, 7, 1), [25, 26, 27]), - ("2024-maintained", dt.date(2024, 7, 1), [28, 29]), - ("2025-supported", dt.date(2025, 7, 1), [30, 31]), - ("2026-supported", dt.date(2026, 10, 20), [33, 34, 35]), - ] - for name, day, expected in cases: - with self.subTest(revision=name): - result = versions.parse_wiki(self.fixture(name), today=day) - self.assertEqual([item["major"] for item in result["versions"]], expected) - - def test_malformed_historical_revision_fails_closed(self): - with self.assertRaises(versions.VersionError): - versions.parse_wiki(self.fixture("2024-malformed"), today=dt.date(2024, 10, 24)) - - def test_unreleased_or_conflicting_historical_wiki_fails_closed(self): - with self.assertRaisesRegex(versions.VersionError, "Invalid current release"): - versions.parse_wiki(self.fixture("2026-unreleased"), today=dt.date(2026, 2, 12)) - with self.assertRaisesRegex(versions.VersionError, "both supported and EOL"): - versions.parse_wiki(self.fixture("2025-eol-overlap"), today=dt.date(2025, 4, 15)) - - def test_heading_removed_fails_closed(self): - source = self.fixture("2026-supported").replace("Currently supported versions", "Upcoming releases") - with self.assertRaisesRegex(versions.VersionError, "No 'Currently"): - versions.parse_wiki(source, today=dt.date(2026, 10, 20)) - - def test_supported_and_eol_conflict_fails_closed(self): - source = self.fixture("2026-supported").replace("| **32**", "| **35**") - with self.assertRaisesRegex(versions.VersionError, "both supported and EOL"): - versions.parse_wiki(source, today=dt.date(2026, 10, 20)) - - def test_end_of_life_month_fails_closed(self): - with self.assertRaisesRegex(versions.VersionError, "EOL"): - versions.parse_wiki(self.fixture("2026-supported"), today=dt.date(2027, 10, 1)) - - def test_json_schema_rejects_out_of_order_or_eol(self): - manifest = versions.parse_wiki(self.fixture("2026-supported"), today=dt.date(2026, 10, 20)) - reversed_manifest = {**manifest, "versions": list(reversed(manifest["versions"]))} - with self.assertRaisesRegex(versions.VersionError, "sorted"): - versions.validate_manifest(reversed_manifest, today=dt.date(2026, 10, 20)) - expired_manifest = json.loads(json.dumps(manifest)) - expired_manifest["versions"][0]["eol"] = "2026-09" - with self.assertRaisesRegex(versions.VersionError, "EOL"): - versions.validate_manifest(expired_manifest, today=dt.date(2026, 10, 20)) - - def test_manifest_matrix_uses_approved_patch_versions(self): - with tempfile.TemporaryDirectory() as tmp: - manifest_file = Path(tmp) / "approved.json" - fixture = versions.parse_wiki(self.fixture("2026-supported"), today=dt.date(2026, 10, 20)) - for entry in fixture["versions"]: - entry["eol"] = "2099-12" # Never tie a unit test to the real-world EOL clock. - manifest_file.write_text(json.dumps(fixture), encoding="utf-8") - output = Path(tmp) / "actions_output" - completed = subprocess.run( - [sys.executable, str(ROOT / "scripts" / "nextcloud_versions.py"), - "matrix", "--file", str(manifest_file), "--github-output", str(output)], - capture_output=True, text=True, cwd=ROOT, check=True, - ) - self.assertIn("Approved Nextcloud versions", completed.stdout) - raw = output.read_text(encoding="utf-8").strip() - self.assertTrue(raw.startswith("matrix=")) - self.assertEqual(json.loads(raw.partition("=")[2]), { - "include": [ - {"major": 33, "tag": "33-fpm", "release": "33.0.10"}, - {"major": 34, "tag": "34-fpm", "release": "34.0.5"}, - {"major": 35, "tag": "35-fpm", "release": "35.0.2"}, - ] - }) - - def test_bad_wiki_does_not_modify_approved_manifest(self): - with tempfile.TemporaryDirectory() as tmp: - approved = Path(tmp) / "approved.json" - approved.write_text('{"approved":true}\n') - completed = subprocess.run( - [sys.executable, str(ROOT / "scripts" / "nextcloud_versions.py"), "discover", - "--file", str(approved), "--wiki-file", - str(ROOT / "tests/fixtures/2024-malformed.md"), "--skip-upstream-check"], - capture_output=True, text=True, cwd=ROOT, - ) - self.assertEqual(completed.returncode, 1) - self.assertEqual(approved.read_text(), '{"approved":true}\n') - self.assertIn("::error title=Nextcloud version discovery failed::", completed.stderr) - - def test_upstream_verification_requires_release_and_both_architectures(self): - entries = [{"major": 35, "release": "35.0.2", "eol": "2027-09"}] - with patch.object(versions, "request_json", side_effect=[ - {"tag_name": "v35.0.2", "prerelease": False, "draft": False}, - {"name": "35.0.2-fpm", "images": [ - {"os": "linux", "architecture": "amd64"}, - {"os": "linux", "architecture": "arm64"}, - ]}, - ]) as request: - versions.verify_upstream(entries) - self.assertEqual(request.call_count, 2) - with patch.object(versions, "request_json", side_effect=[ - {"tag_name": "v35.0.2", "prerelease": False, "draft": False}, - {"name": "35.0.2-fpm", "images": [{"os": "linux", "architecture": "amd64"}]}, - ]): - with self.assertRaisesRegex(versions.VersionError, "lacks amd64 or arm64"): - versions.verify_upstream(entries) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_publish_images.py b/tests/test_publish_images.py deleted file mode 100644 index f637150..0000000 --- a/tests/test_publish_images.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Mocked Docker tests: no registry writes, Docker daemon or network required.""" - -import sys -import unittest -import json -import tempfile -from pathlib import Path -from unittest.mock import patch - -ROOT = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(ROOT / "scripts")) -import publish_images as images # noqa: E402 - -SHA = "a" * 40 -APP = "ghcr.io/librecodecoop/nextcloud-docker-app" -WEB = "ghcr.io/librecodecoop/nextcloud-docker-web" - - -class PublishImagesTest(unittest.TestCase): - def setUp(self): - self.directory = tempfile.TemporaryDirectory() - self.addCleanup(self.directory.cleanup) - self.approved = Path(self.directory.name) / "manifest.json" - manifest = { - "schema_version": 1, - "source": "https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule", - "versions": [ - {"major": 33, "release": "33.0.10", "eol": "2099-12"}, - {"major": 34, "release": "34.0.5", "eol": "2099-12"}, - {"major": 35, "release": "35.0.2", "eol": "2099-12"}, - ], - } - self.approved.write_text(json.dumps(manifest), encoding="utf-8") - @patch.object(images, "run") - def test_stage_app_publishes_only_requested_version(self, docker): - images.stage(APP, SHA, "34-fpm") - self.assertEqual([call.args for call in docker.call_args_list], [ - ("tag", "scan/app:amd64", f"{APP}:sha-{SHA}-34-fpm-amd64"), - ("push", f"{APP}:sha-{SHA}-34-fpm-amd64"), - ("tag", "scan/app:arm64", f"{APP}:sha-{SHA}-34-fpm-arm64"), - ("push", f"{APP}:sha-{SHA}-34-fpm-arm64"), - ]) - - @patch.object(images, "run") - def test_web_does_not_depend_on_nextcloud_major(self, docker): - images.stage(WEB, SHA, "web") - self.assertEqual(docker.call_count, 4) - self.assertIn(f"{WEB}:sha-{SHA}-web-arm64", str(docker.call_args_list)) - self.assertNotIn("35-fpm", str(docker.call_args_list)) - - @patch.object(images, "run") - def test_promote_uses_highest_approved_major_and_web_always(self, docker): - images.promote(APP, WEB, SHA, self.approved) - calls = [call.args for call in docker.call_args_list] - created = [item for item in calls if item[:3] == ("buildx", "imagetools", "create")] - self.assertEqual(len(created), 10) - tags = [item[4] for item in created] - self.assertEqual(tags, [ - f"{APP}:33-fpm", f"{APP}:33.0.10-fpm", - f"{APP}:34-fpm", f"{APP}:34.0.5-fpm", - f"{APP}:35-fpm", f"{APP}:35.0.2-fpm", - f"{APP}:stable-fpm", f"{APP}:latest", f"{WEB}:main", f"{WEB}:latest", - ]) - self.assertEqual(created[7][-2:], ( - f"{APP}:sha-{SHA}-35-fpm-amd64", f"{APP}:sha-{SHA}-35-fpm-arm64", - )) - self.assertEqual(created[8][-2:], ( - f"{WEB}:sha-{SHA}-web-amd64", f"{WEB}:sha-{SHA}-web-arm64", - )) - - @patch.object(images, "run") - @patch.object(images, "read_manifest") - def test_promote_selects_next_major_without_hardcoding(self, reviewed, docker): - reviewed.return_value = {"versions": [ - {"major": 34, "release": "34.0.5"}, - {"major": 35, "release": "35.0.2"}, - {"major": 36, "release": "36.0.0"}, - ]} - images.promote(APP, WEB, SHA, self.approved) - creates = [call.args for call in docker.call_args_list if call.args[:3] == - ("buildx", "imagetools", "create")] - self.assertIn(f"{APP}:sha-{SHA}-36-fpm-amd64", creates[7]) - self.assertIn(f"{APP}:sha-{SHA}-36-fpm-arm64", creates[7]) - self.assertEqual(creates[7][4], f"{APP}:latest") - - @patch.object(images, "run") - def test_invalid_tag_or_commit_never_publishes(self, docker): - for bad in ("35; rm -rf /", "latest", "0-fpm"): - with self.assertRaises(ValueError): - images.stage(APP, SHA, bad) - with self.assertRaises(ValueError): - images.stage(APP, "short", "35-fpm") - docker.assert_not_called() - - -if __name__ == "__main__": - unittest.main() From 7d9bf3207dc987f1e051045c9be8d8f0c64bac1e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:33:34 +0000 Subject: [PATCH 09/10] test(ci): tolerate historical EOL-table formatting changes Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- scripts/parse-nextcloud-wiki.awk | 13 +++- tests/fixtures/2025-eol-structure.md | 92 ++++++++++++++++++++++++++++ tests/nextcloud-versions.bats | 5 ++ 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/2025-eol-structure.md diff --git a/scripts/parse-nextcloud-wiki.awk b/scripts/parse-nextcloud-wiki.awk index 8fc380f..4f95f7f 100644 --- a/scripts/parse-nextcloud-wiki.awk +++ b/scripts/parse-nextcloud-wiki.awk @@ -42,7 +42,12 @@ BEGIN { section = ""; state = 0; supported = 0; eol_rows = 0 } } next } - if (section == "" || $0 !~ /^[[:space:]]*\|/) next + if (section == "") next + if ($0 !~ /^[[:space:]]*\|/) { + if (state == 2) state = 3 + next + } + if (state == 3) next count = columns($0, parts) if (state == 0) { @@ -53,6 +58,8 @@ BEGIN { section = ""; state = 0; supported = 0; eol_rows = 0 } !("end of life" in field_index) || !("current version" in field_index)) { failure("supported table header changed") } + } else if (tolower(parts[1]) != "version code") { + failure("EOL table header changed") } header_columns = count state = 1 @@ -66,7 +73,9 @@ BEGIN { section = ""; state = 0; supported = 0; eol_rows = 0 } state = 2 next } - if (count != header_columns) failure("table row column count changed") + if (section == "supported" && count != header_columns) { + failure("supported table row column count changed") + } if (section == "supported") { major = parts[field_index["version code"]] current = parts[field_index["current version"]] diff --git a/tests/fixtures/2025-eol-structure.md b/tests/fixtures/2025-eol-structure.md new file mode 100644 index 0000000..e60bbae --- /dev/null +++ b/tests/fixtures/2025-eol-structure.md @@ -0,0 +1,92 @@ +## Currently supported versions + +New major versions are published every four months and are supported for one year through monthly maintenance releases. Maintenance releases are published for all still supported major versions and target all known critical bugs and security problems. + +Major versions that have reached their **End of Life** no longer receive further bug fixes or security updates. + +For more details to help with planning your updates, check the [documentation page](https://docs.nextcloud.com/server/stable/admin_manual/release_schedule.html). + + +| Version code | Version name | Release date | End of life | Current version | Next version +|:------------:|:-------------:|:------------------|-----------------:|-------------------------:|--------------------------: +| **32** | Hub 25 Autumn | 2025-09-27 | 2026-09 | **32.0.1** (2025-10-23) | **32.0.2** (2025-11-20) +| **31** | Hub 10 | 2025-02-25 | 2026-02 | **31.0.10** (2025-10-23) | **31.0.11** (2025-11-20) + +```mermaid + %%{init: { 'logLevel': 'debug', 'theme': 'dark' } }%% + timeline + Version code
(major) + : Version name + : Initial release + : Supported Until + : Latest release + : Latest release date + : Next release + 31 : Hub 10 : 25 Feb 2025 : 24 Feb 2026 : 31.0.10 : 23 October 2025 : 20 November 2025 + 32 : Hub 25 Autumn : 27 Sep 2025 : 26 Sep 2026 : 32.0.1 : 23 October 2025 : 20 November 2025 +``` + + + + +## EOL Versions + +📣 Hub 6 (27.1.x) was the successor to Hub 5 (27.0.x) + +| Version code | Version name | Release date | End of life | Last version +|:------------:|:------------:|:------------------|-----------------:|-------------------------: +| **30** | Hub 9 | 2024-09-11 | 2025-10 | 30.0.17 (2025-10-23) +| **29** | Hub 8 | 2024-04-24 | 2025-04 | 29.0.16 (2025-04-17) +| **28** | Hub 7 | 2023-12-12 | 2024-12 | 28.0.14 (2024-12-12) +| **27** | Hub 6 | 2023-06-13 | 2024-06 | 27.1.11 (2024-06-25) +| **26** | Hub 4 | 2023-03-21 | 2024-03 | 26.0.13 (2024-03-28) +| **25** | Hub 3 | 2022-10-19 | 2023-10 | 25.0.13 (2023-10-26 +| **24** | Hub 3 | 2022-05-03 | 2023-04 | 24.0.12 (2023-04-20) +| **23** | Hub 2 | 2021-11-30 | 2022-12 | 23.0.12 (2022-12-08) +| **22** | Hub | 2021-07-06 | 2022-07 | 22.2.10 (2022-07-18) +| **21** | Hub | 2021-02-22 | 2022-02 | 21.0.9 (2022-02-15) +| **20** | Hub | 2020-10-03 | 2021-11 | 20.0.14 (2021-11-11) +| **19** | Hub | 2020-06-03 | 2021-06 | 19.0.13 (2021-07-01) +| **18** | Hub | 2020-01-16 | 2021-01 | 18.0.14 (2021-01-27) +| **17** | 17 | 2019-09-30 | 2020-10 | 17.0.10 (2020-10-08) +| **16** | 16 | 2019-04-25 | 2020-06 | 16.0.11 (2020-06-04) +| **15** | 15 | 2018-12-10 | 2019-12 | 15.0.14 (2019-12-19) +| **14** | 14 | 2018-09-10 | 2019-09 | 14.0.14 (2019-08-15) +| **13** | 13 | 2018-02-06 | 2019-02 | 13.0.12 (2019-02-28) +| **12** | 12 | 2017-05-22 | 2018-11 | 12.0.13 (2018-11-22) +| **11** | 11 | 2016-12-13 | 2018-03 | 11.0.8 (2018-03-15) +| **10** | 10 | 2016-08-25 | 2017-08 | 10.0.6 (2017-08-07) +| **9** | 9 | 2016-08-03 | 2017-08 | 9.0.58 (2017-04-24) + +--- + +```mermaid +gantt + + title Currently Supported Versions + axisFormat %e %b %Y + todayMarker stroke-width:5px,stroke:#0f0,opacity:0.5,content: "Today" + + section Latest + Nextcloud 32 :crit,active, v32, 2025-09-27, 365d + + section Previous + Nextcloud 31 :active, v30, 2025-02-25, 365d +``` diff --git a/tests/nextcloud-versions.bats b/tests/nextcloud-versions.bats index b92c5bd..cf3bb7d 100644 --- a/tests/nextcloud-versions.bats +++ b/tests/nextcloud-versions.bats @@ -37,6 +37,11 @@ discover_fixture() { [ "$(jq -r '[.versions[].major] | join(",")' "$inventory")" = 33,34,35 ] [ "$(jq -r '.versions[-1].release' "$inventory")" = 35.0.2 ] } +@test "historical EOL formatting variations do not change the supported majors" { + run discover_fixture 2025-eol-structure 2025-10-27 + [ "$status" -eq 0 ] + [ "$(jq -r '[.versions[].major] | join(",")' "$inventory")" = 31,32 ] +} @test "malformed wiki preserves the last approved manifest" { echo '{"untouched":true}' > "$inventory" run discover_fixture 2024-malformed 2024-10-24 From 17b439da602d125e5efe525bb2af4cf3ca01c9e3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:57:16 +0000 Subject: [PATCH 10/10] docs: focus READMEs on adoption and separate maintainer internals Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- README.md | 249 +++++------------------------------ docs/README_ptBR.md | 176 +++++-------------------- docs/deployment.md | 117 +++++++++++++++++ docs/deployment_ptBR.md | 117 +++++++++++++++++ docs/images.md | 281 ++++++---------------------------------- docs/maintenance.md | 96 ++++++++++++++ 6 files changed, 429 insertions(+), 607 deletions(-) create mode 100644 docs/deployment.md create mode 100644 docs/deployment_ptBR.md create mode 100644 docs/maintenance.md diff --git a/README.md b/README.md index abe7880..d853d3d 100644 --- a/README.md +++ b/README.md @@ -1,243 +1,54 @@ -Languages avaliable: [pt-BR](docs/README_ptBR.md) +# Nextcloud with Docker -# Nextcloud with SSL and Docker +[Português](docs/README_ptBR.md) -- [Nextcloud with SSL and Docker](#nextcloud-with-ssl-and-docker) - - [Setup of docker](#setup-of-docker) - - [Setup of proxy](#setup-of-proxy) - - [Setup of database](#setup-of-database) - - [Before first run](#before-first-run) - - [After setup](#after-setup) - - [Custom setup](#custom-setup) - - [Customize docker-compose content](#customize-docker-compose-content) - - [PHP](#php) - - [Run Nextcloud](#run-nextcloud) - - [Use a specific version of Nextcloud](#use-a-specific-version-of-nextcloud) - - [Logs](#logs) - - [Nextcloud Talk](#talk) +A configurable Docker environment for organizations and teams running their own Nextcloud. Maintained by [LibreCode](https://librecode.coop/), this repository combines the Nextcloud application, web server, caching, and database integration in a deployment you can adapt to your infrastructure. -## NCDD command interface +## Why use this project? -For development automation, use the repository's `bin/ncdd` wrapper instead of reproducing Docker Compose details in CI, agents, or local instructions. It operates the existing `docker-compose.yml` and `docker-compose-postgres.yml`; it does not define a second stack. +- **Control upgrades.** Choose a specific Nextcloud major and plan upgrades instead of unexpectedly moving to a new major. +- **Keep deployment choices open.** Use Docker Compose with Nginx, Redis, PostgreSQL, and optional Garage S3 storage. +- **Use tested image channels.** Published application images follow supported Nextcloud releases; image builds go through vulnerability scanning and runtime checks before publication. +- **Separate production from development.** Versioned stable images and the `master-fpm` development channel serve different needs. -```bash -bin/ncdd up -bin/ncdd doctor -bin/ncdd shell -bin/ncdd down -``` - -See [docs/ncdd.md](docs/ncdd.md) for the command contract and testing policy. - -## Container image contract - -Image names, moving tags, immutable references, runtime variants, and required traceability metadata are defined in [docs/images.md](docs/images.md). - -## Setup of docker - -You need to have, on your server, the installed docker. The installation can be done with an official script, following the following steps: -- Download the docker -```bash -curl -fsSL https://get.docker.com -o get-docker.sh -``` -- run the script -```bash -sh get-docker.sh -``` -- Give permissions to execute the Docker command to your user -```bash -sudo usermod -aG docker $USER -``` -- Remove the installation script -```bash -rm get-docker.sh -``` - -## Setup of proxy - -Follow the instructions of this repository: +This project is intended for teams that manage their own infrastructure or deliver Nextcloud as a service. It provides deployment tooling, not a hosted Nextcloud subscription. -https://github.com/LibreCodeCoop/nginx-proxy +## Get started -## Setup of database - -Follow the instructions of this repository: - -https://github.com/LibreCodeCoop/postgres-docker - -## Before first run - -Copy the `.env.example` to `.env` and set the values. +You need Docker Engine with Docker Compose, a domain for public access, and a reverse proxy. This example uses the [LibreCode Nginx proxy](https://github.com/LibreCodeCoop/nginx-proxy); the PostgreSQL Compose extension is included in this repository. ```bash cp .env.example .env -``` - -| Environment | service | Description | -|-------------|---------|-------| -| [`VIRTUAL_HOST`](https://github.com/nginx-proxy/nginx-proxy#usage) | `web` | Your domain | -| [`LETSENCRYPT_HOST`](https://github.com/nginx-proxy/docker-letsencrypt-nginx-proxy-companion/blob/master/docs/Basic-usage.md#step-3---proxyed-containers) | `web` | Your domain | -| [`LETSENCRYPT_EMAIL`](https://github.com/nginx-proxy/docker-letsencrypt-nginx-proxy-companion/blob/master/docs/Let's-Encrypt-and-ACME.md#contact-address) | `web` | Your sysadmin email | -| `NEXTCLOUD_TRUSTED_DOMAINS` | `app` | domains separated by comma. The domain web is mandatory, add your domain together with whe domain web. The domain `web` is the domain of Nginx service. | -| `POSTGRES_DB` | `db` | PostgreSQL database name (default: nextcloud) | -| `POSTGRES_USER` | `db` | PostgreSQL database user (default: nextcloud) | -| `POSTGRES_PASSWORD` | `db` | PostgreSQL database user password | -| `POSTGRES_HOST` | `app` | PostgreSQL server host (default: postgres) | -| `NEXTCLOUD_ADMIN_USER` | `app` | Nextcloud administrator username | -| `NEXTCLOUD_ADMIN_PASSWORD` | `app` | Nextcloud administrator password | -| `NEXTCLOUD_ADMIN_EMAIL` | `app` | Nextcloud administrator email | -| `SMTP_HOST` | `app` | SMTP server for email sending | -| `SMTP_SECURE` | `app` | SMTP security type (ssl, tls, or empty) | -| `SMTP_PORT` | `app` | SMTP server port | -| `SMTP_AUTHTYPE` | `app` | SMTP authentication type (LOGIN, PLAIN, NTLM) | -| `SMTP_NAME` | `app` | Username for SMTP authentication | -| `SMTP_PASSWORD` | `app` | Password for SMTP authentication | -| `MAIL_FROM_ADDRESS` | `app` | Sender email address | -| `MAIL_DOMAIN` | `app` | Sender email domain | -| `TZ` | `app` | Timezone (e.g., America/Sao_Paulo) | -| `TRASHBIN_RETENTION_OBLIGATION` | `app` | Number of days files should remain in the recycle bin before being cleaned. | - -> **PS**: Let's Encrypt only work in servers when the `VIRTUAL_HOST` and `LETSENCRYPT_HOST` have a valid public domain registered in a DNS server. Don't try to use in localhost, don't work! - -Create a network - -```bash +# Edit .env: domain, credentials, administrator account, and your Nextcloud version. docker network create reverse-proxy docker network create postgres +docker compose -f docker-compose.yml -f docker-compose-postgres.yml up -d --build ``` -## After setup - -After finish the setup, access this url: https://yourdomain.tld/settings/admin/overview. - -If is necessary run any occ command, run like this: - -```bash -docker compose exec -u www-data app ./occ db:add-missing-indices -docker compose exec -u www-data app ./occ db:convert-filecache-bigint -``` - -## Custom setup - -### Customize docker-compose content - -You can do this using environments and creating a file called `docker-compose.override.yml` to add new services. - -### Redis - -The main compose files now include a `redis` service by default. This keeps the stack self-contained for Nextcloud installations that already use Redis in `config.php` and avoids depending on a host-specific external network. - -### Garage S3 primary storage - -Use `docker-compose-garages3.yml` when you want Nextcloud to store files in a Garage S3 bucket instead of the local `data/` directory. - -The stack expects these values in `.env`: - -- `GARAGES3_BUCKET` -- `GARAGES3_KEY` -- `GARAGES3_KEY_ID` -- `GARAGES3_SECRET` -- `GARAGES3_HOSTNAME`, defaulting to `host.docker.internal` -- `GARAGES3_PORT`, defaulting to `3900` -- `GARAGES3_REGION`, defaulting to `garage` - -Create the bucket and access key in Garage before starting Nextcloud with this compose file. `GARAGES3_KEY_ID` must contain the Garage access key ID used by Nextcloud. - -The Garage service uses `garage/garage.toml`. Update `rpc_secret` before using it in a real environment. - -Use `make up-garages3` to start Garage, and `make bootstrap-garages3` to create the Garage bucket and access key. -The bootstrap updates `.env` in place with the generated Garage credentials. -Use `make setup-garages3` to run the full setup and wait for Nextcloud to report as installed. -The stack now uses PostgreSQL 16. If you already created the database volume with an older PostgreSQL major version, recreate or migrate that volume once before starting the updated compose file. - -For a clean local installation, use `make reset-garages3`. -Use `make setup-garages3` if you want to keep the existing local state and only rerun the setup steps. - -Basic flow: - -1. Copy `.env.example` to `.env` if needed. -2. Update `garage/garage.toml` and replace the placeholder `rpc_secret`. -3. Run `make setup-garages3`. -4. Open the Nextcloud URL and finish the initial admin setup if it is still pending. - -### PHP - -- Create your `.ini` file at `volumes/php/` folder. Example: `volumes/php/xdebug.ini` -- Alter the file `docker-compose.override.yml` adding your volume -```yaml -services: - app: - volumes: - - ./volumes/php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini -``` - - -### PHP-FPM - -- For PHP-FPM modifications, include the following volume to the app service in `docker-compose.override.yml` file: -```yaml -services: - app: - volumes: - - ./volumes/php/pm.conf:/usr/local/etc/php-fpm.d/www2.conf -``` -- Create a file `./volumes/php/pm.conf` with the following content (see references for tunning according your setup): -```yaml -[www] -pm.max_children = 10 -pm.start_servers = 2 -pm.min_spare_servers = 1 -pm.max_spare_servers = 3 -``` - -- References: -- https://docs.nextcloud.com/server/21/admin_manual/installation/server_tuning.html#tune-php-fpm -- https://spot13.com/pmcalculator/ +The external `reverse-proxy` network must have a configured proxy attached. Set up the proxy and DNS before expecting HTTPS to work. For production, review the PostgreSQL version in the Compose example, your backup strategy, and volume migrations. -## Run Nextcloud +For environment variables, existing deployments, storage options, and troubleshooting, see the [deployment guide](docs/deployment.md). -```bash -# The postgres service is executed separated to be possible reuse this service to other applications that use PostgreSQL -docker compose up -f docker-compose-postgres.yml -d -docker compose up -d -docker compose -d -``` -## Use a specific version of Nextcloud +## Nextcloud versions and image updates -Change the value of NEXTCLOUD_VERSION at `.env` file and put the tag name that you want to use. Check the availables tags here: https://hub.docker.com/_/nextcloud/tags +Set `NEXTCLOUD_VERSION` in `.env` to select the **official Nextcloud base image** for this repository's local Docker Compose build. This is separate from publishing the LibreCode images to GHCR. -The GHCR build workflow reads the reviewed `.github/nextcloud-supported.json` manifest, builds each maintained major using its current official patch release, and promotes the newest supported app image to `:stable-fpm` and the historical `:latest` alias. The web image is built independently as `:main` (with a `:latest` compatibility alias). Use explicit major tags in deployed stacks to avoid automatic Nextcloud major upgrades. See [image publishing and version discovery](docs/images.md). +Prebuilt LibreCode application images use versioned tags, such as: -Build the images, down the containers and get up again: - -```bash -docker compose build --pull -docker compose up -d +```text +ghcr.io/librecodecoop/nextcloud-docker-app:35-fpm ``` -## Vulnerability scanning +For production, **pin a Nextcloud major or an OCI digest** and plan major upgrades deliberately. The moving `:latest` and `:stable-fpm` aliases can switch to a newer major; they are not safe defaults for unattended production upgrades. -Published `app` and `web` images are scanned for vulnerabilities. Contributors -can run the scan locally with: - -```bash -make scan-images -``` - -## Logs - -If you want to see the logs, run: - -```bash -docker compose logs -f --tail=100 -``` -You will see this message in the logs and other many upgrade messages: - -```log -app_1 | 2020-04-28T19:49:38.568623133Z Initializing nextcloud 18.0.4.2 ... -app_1 | 2020-04-28T19:49:38.577733913Z Upgrading nextcloud from 18.0.3.0 ... -``` +See [image names, tags, and upgrade policy](docs/images.md) for the complete image options. -## Talk +## Documentation -For setting up Nextcloud Talk with all services, see [here](https://github.com/LibreCodeCoop/nextcloud-docker-talk). +| Guide | Purpose | +| --- | --- | +| [Deployment and customization](docs/deployment.md) | Installation, configuration, storage, PHP tuning, and logs | +| [Container images](docs/images.md) | Available image tags, compatibility, and safe upgrades | +| [Nextcloud Talk](https://github.com/LibreCodeCoop/nextcloud-docker-talk) | Additional services for Talk | +| [Development commands](docs/ncdd.md) | The `bin/ncdd` interface for local development | +| [Image maintenance](docs/maintenance.md) | Version discovery, workflows, CI checks, and release publication | diff --git a/docs/README_ptBR.md b/docs/README_ptBR.md index 1e013f8..a3e3fce 100644 --- a/docs/README_ptBR.md +++ b/docs/README_ptBR.md @@ -1,166 +1,54 @@ -# Nextcloud com SSL e Docker -- [Nextcloud com SSL e Docker](#nextcloud-com-ssl-e-docker) - - [Configuração do Docker](#configuração-do-docker) - - [Configuração de proxy](#configuração-de-proxy) - - [Antes da primeira execução](#antes-da-primeira-execução) - - [Após a configuração](#após-a-configuração) - - [Configuração personalizada](#configuração-personalizada) - - [Personalize o conteúdo do docker-compose](#personalize-o-conteúdo-do-docker-compose) - - [PHP](#php) - - [Execute o Nextcloud](#execute-o-nextcloud) - - [Use uma versão específica do Nextcloud](#use-uma-versão-específica-do-nextcloud) - - [Logs](#logs) - - [Nextcloud Talk](#talk) - -## Configuração do Docker - -Você precisa ter, em seu servidor, o Docker instalado. A instalação pode ser feita com um script oficial, seguindo os seguintes passos: -- Baixe o Docker -```bash -curl -fsSL https://get.docker.com -o get-docker.sh -``` -- Execute o script -```bash -sh get-docker.sh -``` -- Conceda permissões para executar o comando Docker para seu usuário -```bash -sudo usermod -aG docker $USER -``` -- Remova o script de instalação -```bash -rm get-docker.sh -``` - -## Configuração de proxy +# Nextcloud com Docker -Siga as instruções deste repositório: +[English](../README.md) -https://github.com/LibreCodeCoop/nginx-proxy +Um ambiente Docker configurável para organizações e equipes que hospedam seu próprio Nextcloud. Mantido pela [LibreCode](https://librecode.coop/), este repositório reúne a aplicação, o servidor web, o cache e a integração com banco de dados em uma implantação que pode ser adaptada à sua infraestrutura. -## Antes da primeira execução +## Por que utilizar este projeto? -Copie o `.env.example` para `.env` e defina os valores. +- **Controle das atualizações.** Escolha uma versão principal do Nextcloud e planeje as migrações sem mudanças inesperadas de versão. +- **Flexibilidade na infraestrutura.** Utilize Docker Compose com Nginx, Redis, PostgreSQL e armazenamento opcional no Garage S3. +- **Imagens com verificações automatizadas.** As imagens publicadas acompanham versões mantidas do Nextcloud e passam por varredura de vulnerabilidades e testes de execução antes da publicação. +- **Separação entre produção e desenvolvimento.** Imagens estáveis versionadas e o canal `master-fpm` atendem a necessidades diferentes. -```bash -cp .env.example .env -``` +O projeto é voltado a equipes que administram sua própria infraestrutura ou oferecem Nextcloud como serviço. Ele fornece ferramentas de implantação, não uma assinatura de hospedagem. -| Ambiente | serviço | Descrição | -|-------------|---------|-------| -| [`VIRTUAL_HOST`](https://github.com/nginx-proxy/nginx-proxy#usage) | `web` | Seu domínio | -| [`LETSENCRYPT_HOST`](https://github.com/nginx-proxy/docker-letsencrypt-nginx-proxy-companion/blob/master/docs/Basic-usage.md#step-3---proxyed-containers) | `web` | Seu domínio | -| [`LETSENCRYPT_EMAIL`](https://github.com/nginx-proxy/docker-letsencrypt-nginx-proxy-companion/blob/master/docs/Let's-Encrypt-and-ACME.md#contact-address) | `web` | Seu email de administrador de sistema | -| `NEXTCLOUD_TRUSTED_DOMAINS` | `app` | domínios separados por vírgula. O domínio `web` é obrigatório, adicione seu domínio junto com o domínio `web`. O domínio `web` é o domínio do serviço Nginx. | +## Comece a utilizar -> **PS**: A Let's Encrypt só funciona em servidores quando os `VIRTUAL_HOST` e `LETSENCRYPT_HOST` têm um domínio público válido registrado em um servidor DNS. Não tente usá-lo em localhost, não funciona! - -Crie uma rede +Você precisa do Docker Engine com Docker Compose, de um domínio para acesso público e de um proxy reverso. O exemplo utiliza o [proxy Nginx da LibreCode](https://github.com/LibreCodeCoop/nginx-proxy); a extensão Compose do PostgreSQL está incluída neste repositório. ```bash +cp .env.example .env +# Edite .env: domínio, credenciais, administrador e versão do Nextcloud. docker network create reverse-proxy +docker network create postgres +docker compose -f docker-compose.yml -f docker-compose-postgres.yml up -d --build ``` -## Após a configuração - -Após concluir a configuração, acesse esta URL: https://seudomínio.tld/settings/admin/overview. - -Se for necessário executar algum comando occ, execute da seguinte forma: - -```bash -docker compose exec -u www-data app ./occ db:add-missing-indices -docker compose exec -u www-data app ./occ db:convert-filecache-bigint -``` - - ## Configuração personalizada - -### Personalize o conteúdo do docker-compose - -Você pode fazer isso usando variáveis de ambiente e criando um arquivo chamado `docker-compose.override.yml` para adicionar novos serviços. - -### Redis - -Os arquivos principais de compose agora incluem o serviço `redis` por padrão. Isso deixa o stack autocontido para instalações do Nextcloud que já usam Redis no `config.php` e evita depender de uma rede externa específica do host. - -### Storage primário Garage S3 - -Use `docker-compose-garages3.yml` quando quiser que o Nextcloud grave os arquivos em um bucket Garage S3 em vez do diretório local `data/`. - -O stack espera estes valores em `.env`: - -- `GARAGES3_BUCKET` -- `GARAGES3_KEY` -- `GARAGES3_KEY_ID` -- `GARAGES3_SECRET` -- `GARAGES3_HOSTNAME`, com padrão `host.docker.internal` -- `GARAGES3_PORT`, com padrão `3900` -- `GARAGES3_REGION`, com padrão `garage` - -Crie o bucket e a chave de acesso no Garage antes de iniciar o Nextcloud com este compose. `GARAGES3_KEY_ID` deve conter o access key ID do Garage usado pelo Nextcloud. - -O serviço Garage usa `garage/garage.toml`. Atualize `rpc_secret` antes de usar em ambiente real. - -Use `make up-garages3` para subir o Garage e `make bootstrap-garages3` para criar o bucket e a chave de acesso no Garage. -O bootstrap atualiza `.env` com as credenciais geradas do Garage. -Use `make setup-garages3` para executar a configuração completa e aguardar o Nextcloud reportar como instalado. -O stack agora usa PostgreSQL 16. Se você já criou o volume do banco com uma versão major mais antiga, recrie ou migre esse volume uma vez antes de subir o compose atualizado. - -Para uma instalação local limpa, use `make reset-garages3`. -Use `make setup-garages3` se quiser manter o estado local existente e apenas repetir as etapas de setup. - -Fluxo básico: - -1. Copie `.env.example` para `.env`, se necessário. -2. Atualize `garage/garage.toml` e substitua o `rpc_secret` placeholder. -3. Execute `make setup-garages3`. -4. Abra a URL do Nextcloud e finalize a configuração inicial do admin, se ainda estiver pendente. - -### PHP - -- Crie seu arquivo `.ini` na pasta `volumes/php/`. Exemplo: `volumes/php/xdebug.ini` -- Altere o arquivo `docker-compose.override.yml` adicionando seu volume -```yaml -services: - app: - volumes: - - ./volumes/php/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini -``` - -## Execute o Nextcloud +O proxy reverso deve estar configurado e conectado à rede externa `reverse-proxy`. Configure também o DNS antes de esperar que o HTTPS funcione. Em produção, revise a versão do PostgreSQL usada no exemplo, os backups e as migrações dos volumes. -```bash -# O serviço postgres é executado separadamente para que seja possível reutilizar este serviço para outras aplicações que usam PostgreSQL -docker compose up -f docker-compose-postgres.yml -d -docker compose up -d -docker compose -d -``` -## Use uma versão específica do Nextcloud +Para variáveis de ambiente, instalações existentes, armazenamento e solução de problemas, consulte o [guia de implantação](deployment_ptBR.md). -Altere o valor de `NEXTCLOUD_VERSION` no arquivo `.env` e coloque o nome do rótulo que deseja usar. Verifique as tags disponíveis aqui: https://hub.docker.com/_/nextcloud/tags +## Versões e atualizações do Nextcloud -O workflow de build no GHCR lê `NEXTCLOUD_VERSION` de `.env.example` e publica as imagens de app e web com tag fixa `:latest`, para que ferramentas como o Watchtower acompanhem atualizações sem trocar o nome da imagem. +Defina `NEXTCLOUD_VERSION` no arquivo `.env` para selecionar a **imagem-base oficial do Nextcloud** utilizada no build local do Docker Compose. Isso é diferente da publicação das imagens da LibreCode no GHCR. -Construa as imagens, derrube os contêineres e inicie-os novamente: +As imagens de aplicação da LibreCode disponibilizam tags por versão, por exemplo: -```bash -docker compose build --pull -docker compose up -d +```text +ghcr.io/librecodecoop/nextcloud-docker-app:35-fpm ``` -## Logs +Em produção, **fixe uma versão principal ou o digest OCI** e planeje cada atualização de versão principal. As tags móveis `:latest` e `:stable-fpm` podem passar para uma versão principal mais recente; não são uma boa escolha para atualizações automáticas em produção. -Se você quiser ver os logs, execute: - -```bash -docker compose logs -f --tail=100 -``` -Você verá esta mensagem nos logs e muitas outras mensagens de atualização: - -```log -app_1 | 2020-04-28T19:49:38.568623133Z Inicializando o Nextcloud 18.0.4.2 ... -app_1 | 2020-04-28T19:49:38.577733913Z Atualizando o Nextcloud a partir da versão 18.0.3.0 ... -``` +Consulte [imagens, tags e política de atualização](images.md) para conhecer as opções. -## Talk +## Documentação -Para configurar o Nextcloud Talk e demais serviços necessários, veja [aqui](https://github.com/LibreCodeCoop/nextcloud-docker-talk). +| Guia | Conteúdo | +| --- | --- | +| [Implantação e personalização](deployment_ptBR.md) | Instalação, configuração, armazenamento, PHP e logs | +| [Imagens de contêiner](images.md) | Tags, compatibilidade e atualizações seguras | +| [Nextcloud Talk](https://github.com/LibreCodeCoop/nextcloud-docker-talk) | Serviços adicionais para o Talk | +| [Comandos de desenvolvimento](ncdd.md) | Interface `bin/ncdd` para desenvolvimento local | +| [Manutenção das imagens](maintenance.md) | Descoberta de versões, workflows, CI e publicação | diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..489d582 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,117 @@ +# Deployment and customization + +This guide covers the example Docker Compose stack shipped with this repository. The Compose configuration **builds application and web images locally**. It does not pull the prebuilt LibreCode GHCR images by default. + +Return to the [project overview](../README.md) or see the [published image tags](images.md). + +## Prerequisites + +Install Docker Engine and the Docker Compose plugin. See the [Docker installation documentation](https://docs.docker.com/engine/install/). + +The standard deployment expects a public domain, a reverse proxy that handles HTTPS (for example, [LibreCode nginx-proxy](https://github.com/LibreCodeCoop/nginx-proxy)), and two external Docker networks: + +```bash +docker network create reverse-proxy +docker network create postgres +``` + +Create these networks **once** if they do not exist. Configure the proxy service, DNS, and any Let's Encrypt integration separately. A publicly resolvable domain is required to obtain a public TLS certificate; Let's Encrypt will not issue a certificate for localhost. + +## Configure the environment + +```bash +cp .env.example .env +``` + +Edit `.env` before running the services. At a minimum, provide administrator credentials, the database password, and your public domain. + +| Variable | Purpose | +| --- | --- | +| `NEXTCLOUD_VERSION` | Nextcloud version/tag used for the **official Docker base image** during a local build, e.g. `35-fpm` | +| `VIRTUAL_HOST`, `LETSENCRYPT_HOST` | Public domain passed to the external proxy | +| `LETSENCRYPT_EMAIL` | Contact email for certificate management | +| `NEXTCLOUD_TRUSTED_DOMAINS` | Trusted hostnames, including the public domain and the internal `web` host where applicable | +| `NEXTCLOUD_ADMIN_USER`, `NEXTCLOUD_ADMIN_PASSWORD` | Initial administrator credentials | +| `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | Database name, username, and password | +| `POSTGRES_HOST` | Database host; defaults to `postgres` in the standard Compose extension | +| `TZ` | Container timezone | +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | Outgoing mail transport | +| `SMTP_NAME`, `SMTP_PASSWORD`, `SMTP_AUTHTYPE` | Optional mail authentication | +| `MAIL_FROM_ADDRESS`, `MAIL_DOMAIN` | Sender address settings | + +See `docker-compose.yml` and `.env.example` for the values consumed by the stack. + +## Start Nextcloud + +The standard setup uses the base Compose file plus the PostgreSQL extension: + +```bash +docker compose -f docker-compose.yml -f docker-compose-postgres.yml up -d --build +``` + +The main Compose file provides Nextcloud FPM, an Nginx web container, Redis, and cron. The additional Compose file supplies PostgreSQL and connects the app and cron services to its network. + +**Database compatibility:** `docker-compose-postgres.yml` currently uses `postgres:11-alpine`, which is no longer a supported PostgreSQL major. This is a legacy example, not a production-ready database policy. Before a production deployment, choose a supported PostgreSQL version compatible with your Nextcloud release and plan any existing volume migration. Do not simply swap PostgreSQL majors against an existing data directory. The Garage S3 example uses a separate PostgreSQL 16 service; its volume is not interchangeable without migration. + +After setup, open `https://your-domain.example/settings/admin/overview` to check the administrator overview. + +For maintenance commands, run `occ` as the web-server user: + +```bash +docker compose -f docker-compose.yml -f docker-compose-postgres.yml \ + exec -u www-data app php occ status +``` + +## Choosing and upgrading Nextcloud + +Change `NEXTCLOUD_VERSION` in `.env` to select the official upstream image used by local Compose builds. It **does not control** which GHCR versions are published by LibreCode. + +```bash +docker compose -f docker-compose.yml -f docker-compose-postgres.yml build --pull +docker compose -f docker-compose.yml -f docker-compose-postgres.yml up -d +``` + +Back up the database, application configuration, and persistent files before an upgrade. Check Nextcloud's upgrade requirements and compatibility first: downgrades and skipping unsupported upgrade paths can leave stored data unusable. Use a controlled major-version upgrade process instead of unattended `:latest` changes. + +For users deploying the prebuilt GHCR images, see the [image selection and upgrade policy](images.md). + +## Optional Garage S3 primary storage + +Use the separate `docker-compose-garages3.yml` example if Nextcloud should store primary files in Garage instead of the standard local data directory. This layout uses its own PostgreSQL 16 service. + +Set up the required Garage values in `.env`: + +- `GARAGES3_BUCKET`, `GARAGES3_KEY_ID`, `GARAGES3_SECRET` +- `GARAGES3_HOSTNAME` (default `host.docker.internal`), `GARAGES3_PORT` (default `3900`) +- `GARAGES3_REGION` (default `garage`), plus TLS/path-style settings as needed + +Replace the placeholder `rpc_secret` in `garage/garage.toml` before deployment. Create the bucket and access key for Nextcloud, or use the project's bootstrap commands: + +```bash +make up-garages3 +make bootstrap-garages3 +make setup-garages3 +``` + +The bootstrap may update `.env` with generated credentials. Review its effects before using it on an existing deployment. Do not switch primary storage on a live installation without a migration plan. + +## Advanced customization + +Add services or override service options in `docker-compose.override.yml`. For example, to load a PHP configuration file: + +```yaml +services: + app: + volumes: + - ./volumes/php/custom.ini:/usr/local/etc/php/conf.d/custom.ini:ro +``` + +PHP-FPM pool settings can also be mounted into `/usr/local/etc/php-fpm.d/`. Keep changes compatible with the underlying Nextcloud PHP version and workload. + +## Logs and troubleshooting + +```bash +docker compose -f docker-compose.yml -f docker-compose-postgres.yml logs -f --tail=100 +``` + +To inspect available development commands, see [NCDD](ncdd.md). To configure calling and conferencing services, see [Nextcloud Talk](https://github.com/LibreCodeCoop/nextcloud-docker-talk). diff --git a/docs/deployment_ptBR.md b/docs/deployment_ptBR.md new file mode 100644 index 0000000..bf50174 --- /dev/null +++ b/docs/deployment_ptBR.md @@ -0,0 +1,117 @@ +# Implantação e personalização + +Este guia explica o ambiente Docker Compose fornecido pelo repositório. O Compose **constrói as imagens da aplicação e do servidor web localmente**. Por padrão, ele não utiliza as imagens pré-compiladas da LibreCode no GHCR. + +Volte para a [apresentação do projeto](README_ptBR.md) ou consulte as [tags das imagens publicadas](images.md). + +## Pré-requisitos + +Instale o Docker Engine e o plugin Docker Compose. Consulte a [documentação oficial](https://docs.docker.com/engine/install/). + +A instalação padrão pressupõe um domínio público, um proxy reverso responsável pelo HTTPS (por exemplo, o [nginx-proxy da LibreCode](https://github.com/LibreCodeCoop/nginx-proxy)) e duas redes externas do Docker: + +```bash +docker network create reverse-proxy +docker network create postgres +``` + +Crie essas redes **uma única vez**, caso ainda não existam. Configure separadamente o proxy, o DNS e a integração com Let's Encrypt. Para obter um certificado TLS público, você precisa de um domínio resolvível publicamente; o Let's Encrypt não emite certificados para localhost. + +## Configuração do ambiente + +```bash +cp .env.example .env +``` + +Edite o `.env` antes de iniciar os serviços. Informe, no mínimo, as credenciais do administrador, a senha do banco e o domínio público. + +| Variável | Finalidade | +| --- | --- | +| `NEXTCLOUD_VERSION` | Versão/tag da **imagem-base oficial do Nextcloud** utilizada no build local, por exemplo `35-fpm` | +| `VIRTUAL_HOST`, `LETSENCRYPT_HOST` | Domínio público utilizado pelo proxy | +| `LETSENCRYPT_EMAIL` | E-mail de contato para os certificados | +| `NEXTCLOUD_TRUSTED_DOMAINS` | Domínios confiáveis, incluindo o domínio público e o host interno `web`, quando aplicável | +| `NEXTCLOUD_ADMIN_USER`, `NEXTCLOUD_ADMIN_PASSWORD` | Credenciais do administrador inicial | +| `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | Nome, usuário e senha do banco de dados | +| `POSTGRES_HOST` | Host do banco; o padrão na extensão Compose é `postgres` | +| `TZ` | Fuso horário dos contêineres | +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE` | Configurações do servidor de envio de e-mail | +| `SMTP_NAME`, `SMTP_PASSWORD`, `SMTP_AUTHTYPE` | Autenticação de e-mail, quando necessária | +| `MAIL_FROM_ADDRESS`, `MAIL_DOMAIN` | Endereço do remetente | + +Consulte `docker-compose.yml` e `.env.example` para verificar as variáveis usadas pelo ambiente. + +## Iniciar o Nextcloud + +A configuração padrão combina o Compose principal com a extensão do PostgreSQL: + +```bash +docker compose -f docker-compose.yml -f docker-compose-postgres.yml up -d --build +``` + +O Compose principal contém Nextcloud FPM, Nginx, Redis e cron. A extensão adiciona o PostgreSQL e conecta os serviços da aplicação e do cron à rede do banco. + +**Compatibilidade do banco:** o arquivo `docker-compose-postgres.yml` ainda utiliza `postgres:11-alpine`, versão que não recebe mais manutenção. Esse é um exemplo legado, não uma recomendação para produção. Para produção, escolha uma versão do PostgreSQL ainda suportada e compatível com o Nextcloud e planeje a migração dos volumes existentes. Não altere simplesmente a versão principal do PostgreSQL mantendo um diretório de dados antigo. O exemplo Garage S3 usa PostgreSQL 16 de forma independente. + +Após a configuração, abra `https://seu-dominio.example/settings/admin/overview` para consultar a visão administrativa. + +Para executar comandos de manutenção, utilize o usuário do servidor web: + +```bash +docker compose -f docker-compose.yml -f docker-compose-postgres.yml \ + exec -u www-data app php occ status +``` + +## Escolher e atualizar a versão do Nextcloud + +Altere `NEXTCLOUD_VERSION` no arquivo `.env` para escolher a imagem oficial utilizada nos builds locais. Essa variável **não define** quais versões são publicadas pela LibreCode no GHCR. + +```bash +docker compose -f docker-compose.yml -f docker-compose-postgres.yml build --pull +docker compose -f docker-compose.yml -f docker-compose-postgres.yml up -d +``` + +Faça backup do banco de dados, da configuração e dos arquivos persistidos antes de atualizar. Confira as exigências de compatibilidade do Nextcloud: downgrade ou saltos não suportados entre versões podem impedir o uso dos dados. Prefira atualizações de versões principais planejadas. + +Para quem utiliza as imagens do GHCR, consulte a [política de tags e atualizações](images.md). + +## Armazenamento primário opcional com Garage S3 + +O arquivo `docker-compose-garages3.yml` oferece um exemplo separado em que o Nextcloud utiliza o Garage para armazenar os arquivos, em vez do diretório de dados local. Esse ambiente possui seu próprio PostgreSQL 16. + +Configure no `.env`: + +- `GARAGES3_BUCKET`, `GARAGES3_KEY_ID`, `GARAGES3_SECRET` +- `GARAGES3_HOSTNAME` (padrão `host.docker.internal`) e `GARAGES3_PORT` (padrão `3900`) +- `GARAGES3_REGION` (padrão `garage`) e as opções de TLS e acesso por caminho, quando necessárias + +Substitua o valor de exemplo de `rpc_secret` em `garage/garage.toml`. Crie o bucket e a chave de acesso do Nextcloud ou utilize os comandos de preparação do projeto: + +```bash +make up-garages3 +make bootstrap-garages3 +make setup-garages3 +``` + +O bootstrap pode atualizar o `.env` com as credenciais geradas. Revise os efeitos antes de executá-lo em uma instalação existente. Não mude o armazenamento primário de um ambiente em produção sem um plano de migração. + +## Personalização avançada + +Utilize `docker-compose.override.yml` para acrescentar serviços ou personalizar os existentes. Por exemplo, para carregar um arquivo de configuração do PHP: + +```yaml +services: + app: + volumes: + - ./volumes/php/custom.ini:/usr/local/etc/php/conf.d/custom.ini:ro +``` + +As configurações do PHP-FPM também podem ser montadas em `/usr/local/etc/php-fpm.d/`. Verifique a compatibilidade com a versão do PHP utilizada pelo Nextcloud. + +## Logs e solução de problemas + +```bash +docker compose -f docker-compose.yml -f docker-compose-postgres.yml logs -f --tail=100 +``` + +Para comandos de desenvolvimento, consulte o [NCDD](ncdd.md). Para serviços de chamada e conferência, veja o [Nextcloud Talk](https://github.com/LibreCodeCoop/nextcloud-docker-talk). diff --git a/docs/images.md b/docs/images.md index 6cefd28..77d12f6 100644 --- a/docs/images.md +++ b/docs/images.md @@ -1,270 +1,63 @@ -# Container image naming and tagging +# Container images and upgrade policy -This document is the source of truth for image names, tags, runtime variants, and traceability for images published by this repository. +The LibreCode Nextcloud images let you choose a maintained Nextcloud release for your environment. This guide describes the **public image names, tags, and update behavior**. For details of how these images are built, tested, and published, see the [maintainer guide](maintenance.md). -The convention intentionally stays close to the official Nextcloud image model. Channel or server version, runtime variant, and immutable image identity are separate concerns. +## Images -## Image names +| Image | Purpose | +| --- | --- | +| `ghcr.io/librecodecoop/nextcloud-docker-app` | Nextcloud application runtime with PHP-FPM | +| `ghcr.io/librecodecoop/nextcloud-docker-web` | Nginx web runtime; independent of the Nextcloud major | -Published image names describe the component contained in the image: +The [repository's Docker Compose example](deployment.md) builds its images locally from the official `nextcloud:${NEXTCLOUD_VERSION}` base. It does not automatically pull these GHCR images. -- `ghcr.io/librecodecoop/nextcloud-docker-app` — Nextcloud application runtime. -- `ghcr.io/librecodecoop/nextcloud-docker-web` — web/proxy runtime published by this repository. +## Application tags -Version, development channel, and runtime variant belong in tags, not in the image name. - -Do not create a new image name merely to represent a Nextcloud major or development channel. - -## App image tags - -The app image follows the tag structure used by the official Nextcloud images. - -| Reference | Meaning | Behavior | +| Tag | Meaning | Update behavior | | --- | --- | --- | -| `:-fpm` | Stable release channel within one Nextcloud major, for example `35-fpm` | Moving | -| `:-fpm` | A specific Nextcloud server release, for example `35.0.1-fpm` | Release-specific tag; may be rebuilt | -| `:stable-fpm` | Newest approved, supported stable major | Moving | -| `:latest` | Compatibility alias of `stable-fpm` for existing consumers; not appropriate for pinned deployments | Moving | -| `:master-fpm` | Development channel following Nextcloud Server `master` | Moving | -| `@sha256:` | Exact OCI image content | Immutable | - -The runtime variant is the suffix after the channel or version. The currently defined app runtime variant is `fpm`. - -A future runtime variant, such as `apache`, must preserve the same channel/version semantics instead of introducing a different naming system. - -The development channel must follow upstream `master`; it must not be identified by a future Nextcloud major. - -## Web image tags - -The web image is not tied to a Nextcloud server version. - -Use: - -- `:main` for the moving image built from this repository's main development line; -- `:latest` as a compatibility alias of `:main`; -- `@sha256:` when an exact immutable image is required. - -Do not add a Nextcloud major to the web image tag unless the web image itself gains a real version-dependent contract. - -## Moving tags and immutable references - -Moving tags are convenient channel selectors. Their target can change when a new image is published. - -Examples: - -- `35-fpm`; -- `stable-fpm`; -- `master-fpm`; -- `main` for the web image. - -Use a moving tag when following a maintained channel is intentional. - -The canonical immutable identity of a published image is its OCI digest: - -``` -ghcr.io/librecodecoop/nextcloud-docker-app@sha256: -``` - -A tag containing a repository commit, such as `sha-`, may be useful for traceability but is not inherently immutable if a rebuild can resolve different upstream packages or base-image content. - -A full Nextcloud release tag such as `35.0.1-fpm` identifies the server release clearly, but the OCI digest is still the exact image identity. +| `:-fpm`, e.g. `:35-fpm` | One maintained Nextcloud major | Receives updates within that major | +| `:-fpm`, e.g. `:35.0.2-fpm` | A particular Nextcloud server release | May be rebuilt with updated base packages | +| `:stable-fpm` | The newest approved, supported major | Can advance to a different major | +| `:latest` | Compatibility alias of `:stable-fpm` | Can advance to a different major | +| `:master-fpm` | Development builds following upstream Nextcloud Server `master` | Changes independently of stable releases | +| `@sha256:` | Exact OCI image content | Immutable reference | -## Tags that are not part of the new contract +All currently defined application tags use the `fpm` runtime variant. The `master-fpm` channel is intended for testing and development, not production data. -Do not introduce these as the repository convention: +## Web tags -- `:dev`; -- `:dev-`; -- `:stable--fpm`; -- `:nc-`; -- bare `:`; -- a development tag tied only to the next Nextcloud major. +The web image is not coupled to a Nextcloud release: -Compatibility aliases `:latest` are intentionally maintained for the app and web images. They are moving tags, and an automatic Nextcloud major upgrade may not be safe. Other historical tags are not part of this contract. +| Tag | Meaning | +| --- | --- | +| `:main` | Moving web image from the main development line | +| `:latest` | Compatibility alias of `:main` | +| `@sha256:` | Exact web image content | -The historical development tag `:35` does not require a compatibility alias: the consumer inventory in #52 found no active consumer that depends on it. +## Production upgrade guidance -## Traceability metadata +**Prefer a major-specific tag** such as `:35-fpm` when you want security and patch updates within the same Nextcloud major. For fully reproducible rollouts, resolve and pin an OCI digest. -Every image published under this convention must expose enough metadata to identify both this repository build and the relevant upstream runtime. +Do **not** use the moving `:latest` or `:stable-fpm` application aliases with unattended production upgrades: a new Nextcloud major can require application, database, and data migrations. Back up your installation, confirm compatibility, and follow Nextcloud's supported upgrade path before changing majors. -Use standard OCI labels where the standard already defines the meaning: +When a major reaches end of life, it stops receiving rebuilt, maintained images after its removal is approved. Previously published GHCR tags are not automatically deleted, but an existing tag does **not** mean its Nextcloud major is still supported. -- `org.opencontainers.image.source` — source repository URL; -- `org.opencontainers.image.revision` — source repository revision used for the build; -- `org.opencontainers.image.created` — build timestamp; -- `org.opencontainers.image.version` — human-readable image/server version or channel where applicable. +See the [official Nextcloud Server maintenance schedule](https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule) for current support dates. -The published metadata must also identify: +## Image references -- Nextcloud version or upstream revision; -- PHP version for the app runtime; -- runtime variant, such as `fpm`; -- resolved upstream/base image when applicable. +Examples of image identifiers: -Project-specific labels may be used for metadata that has no suitable OCI standard label, but they must not replace an existing OCI label. - -Traceability metadata does not replace an OCI digest when an immutable reference is required. - -## Examples - -Stable moving app channel: - -``` +```text ghcr.io/librecodecoop/nextcloud-docker-app:35-fpm -``` - -Specific stable Nextcloud release: - -``` -ghcr.io/librecodecoop/nextcloud-docker-app:35.0.1-fpm -``` - -Development channel following Nextcloud Server `master`: - -``` +ghcr.io/librecodecoop/nextcloud-docker-app:35.0.2-fpm ghcr.io/librecodecoop/nextcloud-docker-app:master-fpm -``` - -Moving web image: - -``` ghcr.io/librecodecoop/nextcloud-docker-web:main +ghcr.io/librecodecoop/nextcloud-docker-app@sha256: ``` -Immutable stable app image after resolving the digest of the selected stable build: - -``` -ghcr.io/librecodecoop/nextcloud-docker-app@sha256: -``` - -Immutable development app image after resolving the digest published for the `master-fpm` build: - -``` -ghcr.io/librecodecoop/nextcloud-docker-app@sha256: -``` - -Immutable web image: - -``` -ghcr.io/librecodecoop/nextcloud-docker-web@sha256: -``` - -## App image build sources - -The app image uses a single `.docker/app/Dockerfile` for both release and development builds. - -The Dockerfile models its two source modes as Docker build stages instead of runtime shell branching: - -- `NEXTCLOUD_SOURCE=release` selects the stage that keeps the Nextcloud payload provided by the selected official Nextcloud base image; -- `NEXTCLOUD_SOURCE=daily` selects the stage that replaces that payload with a verified upstream daily archive. - -The base runtime and the Nextcloud server payload are separate inputs. A development build can therefore follow Nextcloud Server `master` without introducing a Dockerfile or image identity tied to a future major. - -For daily builds, `NEXTCLOUD_DAILY_URL` is required. The Dockerfile downloads the matching `.sha512` file, verifies the archive, and prepares `/usr/src/nextcloud` in the layout expected by the official Nextcloud entrypoint. - -The repository Compose environment keeps `NEXTCLOUD_VERSION` as its user-facing version setting and maps it to the generic official base image. There is no second Compose stack for development images. - -The app image remains LibreSign-ready while being generic across Nextcloud versions. It keeps repository-level runtime requirements such as Poppler, UTF-8 locale support, and the PHP bz2 extension, but does not reinstall extensions already supplied by the official Nextcloud base image such as Imagick. Dependabot owns Docker and GitHub Actions updates in this repository. Renovate is deliberately restricted to custom regex-managed values that Dependabot cannot see, currently the explicit Trivy binary version used by CI. The two bots must not manage the same dependency. - -## Development publication - -The rolling Nextcloud Server `master` image is published as: - -``` -ghcr.io/librecodecoop/nextcloud-docker-app:master-fpm -``` - -Pull requests build, scan, and run runtime acceptance for both amd64 and arm64 without publishing. Pushes to `main`, the daily scheduled run, and manual workflow dispatches may publish only after both architectures pass those gates. - -The workflow resolves the official `nextcloud:stable-fpm` base to an OCI digest and records that digest in image metadata. It also records the SHA-512 of the exact upstream `latest-master.tar.bz2` artifact used for the build. This provides exact upstream artifact traceability without inventing a Nextcloud Git commit that the daily archive does not expose. - -Architecture-specific staging tags are implementation details used to assemble the multi-platform manifest. The public development-channel contract is `:master-fpm`. - -## Runtime acceptance - -The app image has a runtime acceptance test based on the same behavioral checks used by the official Nextcloud container projects. - -The test operates on an already-built local image. It does not rebuild the image and does not use the repository deployment Compose files: - -```bash -make test-app-image APP_IMAGE=scan/app:amd64 -``` - -For a local build of the current app image followed by the same acceptance test: - -```bash -make test-current-app-image -``` - -The Bats acceptance test creates an isolated Docker network and PostgreSQL container, starts the image with Nextcloud autoinstall variables, waits for the installation, runs `occ status` and `occ check`, and sends a FastCGI request through the FPM runtime. Test-created resources are removed on success and failure. - -CI must run this test against the exact locally loaded app images produced by the build step. Runtime acceptance is a publication gate alongside vulnerability scanning; a separate deployment-stack test is not required for this contract. - -## Implementation boundary - -This document defines the target contract and the generic app-image foundation now implements the release/daily source split. - -Remaining work in #47 must continue incrementally. In particular: +A release tag identifies the upstream Nextcloud version, but only an OCI digest identifies exact image content. Repository revision tags used during publishing are not necessarily immutable. -- keep the `:master-fpm` publication workflow aligned with the generic app-image foundation; -- extend traceability metadata when upstream exposes stronger revision identifiers; -- keep LibreSign-specific behavior out of the generic runtime; -- preserve scan and runtime-acceptance gates before publication; -- preserve the existing Compose environment until a replacement is explicitly validated. - -## Approved maintained releases and automated discovery - -The **reviewed** `.github/nextcloud-supported.json` file is the only source for the stable-image CI matrix. -It records the upstream source, schema version and each supported major's current patch release and EOL month. -The latest supported major is the highest major in this reviewed list; no major is hardcoded into the image workflow. - -The `Discover maintained Nextcloud versions` workflow checks the upstream -[Server maintenance wiki](https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule) -twice each day and on manual dispatch. Discovery uses -`scripts/nextcloud-versions.sh` and the small `scripts/parse-nextcloud-wiki.awk` parser to extract the **Currently supported/maintained versions** table -and verifies the independent **EOL Versions** table. It rejects changed table structures, -duplicate majors, expired EOL months, future release dates and invalid version formats. -It then checks the official `nextcloud-releases/server` release and the -`nextcloud:-fpm` Docker Hub tag for both AMD64 and ARM64. - -If the JSON changes, the watcher proposes an updating PR on -`automation/nextcloud-supported-versions`, without merging it or publishing images. -Generated PR commits use DCO sign-off and the verified GitHub Actions bot signature. -The bot uses GitHub's signing API, not the interactive Git Signing MCP service. -Because `GITHUB_TOKEN` PRs do not automatically trigger standard PR checks, -the watcher dispatches the stable workflow on that branch for validation. -Workflow dispatch on non-main branches can **only validate** and cannot push or promote images. - -If discovery fails, the workflow is red and emits a GitHub Actions error and job summary. -**No manifest changes or PR are created on failure**. The last approved JSON remains intact. -Enable GitHub Actions failure notifications so these errors do not go unnoticed. -GitHub Actions must also be permitted to create pull requests in repository/organization settings. - -After a human reviews and merges the manifest PR, the stable workflow uses the committed JSON -to build and scan each `nextcloud:-fpm` with Bats runtime acceptance. -The independent web image is built and scanned **once**, without tying it to a Nextcloud major. -Only successful jobs push architecture-specific staging tags. After all jobs pass, -`scripts/publish-images.sh`, using the same Bash manifest helper as the master channel, publishes the versioned multi-platform manifests, followed by: - -- The `:-fpm` channels for every approved, supported major. -- The `:-fpm` patch tags for the verified upstream releases. -- App `:stable-fpm` and historical `:latest` aliases for the **newest** approved major. -- Web `:main` and compatibility `:latest` aliases, independently of the app major. - -Images that reach EOL are **not deleted** from GHCR; they merely stop being rebuilt -once their removal is approved. Existing deployments should pin a major or OCI digest. -Do not point a live Nextcloud data volume at a moving cross-major `:latest` alias. -An unreconciled EOL month in the reviewed JSON blocks the release workflow rather than -building a potentially unsupported major. - -Offline regression tests include real wiki snapshots from 2023, 2024, 2025 and 2026, -including a malformed upstream revision, and mocked Docker publication commands: - -```bash -make test-ci-scripts -``` +Published OCI metadata identifies the source, revision, build time, version or channel, and relevant runtime information where available. This helps with traceability but does not replace digest pinning. -Stable publication is triggered by pushes to `main`, a weekly security rebuild or manual -workflow dispatch on `main`. The `master-fpm` development channel remains on its separate -daily upstream master workflow and does not depend on this reviewed JSON. +For maintainers: [version discovery, security gates, and publication workflows](maintenance.md). diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..550cad6 --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,96 @@ +# Image maintenance and release automation + +This document is for contributors maintaining this repository's Nextcloud images and GitHub Actions workflows. For user-facing image tags and production upgrade guidance, see [Container images](images.md). For the example Compose installation, see [Deployment](deployment.md). + +## Release channels and build sources + +The application uses one `.docker/app/Dockerfile` for stable releases and development builds. Its source modes are separate Docker build stages: + +- `NEXTCLOUD_SOURCE=release` keeps the Nextcloud payload from the selected official base image. +- `NEXTCLOUD_SOURCE=daily` replaces it with a verified Nextcloud Server daily archive. + +The base PHP runtime and Nextcloud server payload are separate inputs. The development channel therefore follows upstream `master` without being tied to the next numbered major. + +For daily builds, `NEXTCLOUD_DAILY_URL` identifies the upstream archive. The Dockerfile fetches its SHA-512 checksum and prepares the payload for the official Nextcloud entrypoint. The workflow also records the upstream base image digest and archive checksum for traceability. + +The generic image retains required runtime dependencies such as Poppler, UTF-8 locale support, and the PHP bz2 extension. Avoid reinstalling extensions already provided by the official Nextcloud base. Keep LibreSign-specific behavior outside this generic runtime. + +The repository Compose example retains `NEXTCLOUD_VERSION` as its user-facing setting for the **locally built official base image**. It does not control the GHCR publication matrix. + +## Reviewed stable-version inventory + +The file `.github/nextcloud-supported.json` is the authoritative **reviewed** inventory for stable-image CI. Its entries contain a major, the current stable patch release, and an EOL month, along with the upstream source and schema version. + +The [official Nextcloud Server maintenance wiki](https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule) is the primary source for determining supported majors. The watcher is not driven by the `nextcloud/docker` repository's image-retention list or by third-party EOL services. + +The `Discover maintained Nextcloud versions` workflow runs twice a day and on manual dispatch. It uses `scripts/nextcloud-versions.sh` and `scripts/parse-nextcloud-wiki.awk` to: + +1. Extract the **Currently supported/maintained versions** table and cross-check the **EOL Versions** table. +2. Reject missing headings, incompatible table structures, duplicate majors, expired EOL months, future release dates, and invalid release numbers. +3. Verify each candidate against an official stable `nextcloud-releases/server` release and the exact `nextcloud:-fpm` Docker Hub tag on AMD64 and ARM64. +4. Write the candidate JSON only when validation succeeds. + +This job does **not** directly publish or alter production images. + +## Reviewing a proposed version change + +If the candidate JSON differs from the reviewed file, the watcher proposes a pull request on `automation/nextcloud-supported-versions`. + +Review the upstream changes, the proposed additions and removals, the supported PHP/Nextcloud combinations, and the release/EOL transition **before merging**. The bot does not auto-merge. + +If a wiki edit cannot be parsed or the upstream sources disagree, the discovery job fails with a GitHub Actions error and job summary. **No JSON change or PR is created on failure** and the existing reviewed inventory is left intact. Enable workflow-failure notifications; a failed schedule should be visible. + +The repository or organization must allow GitHub Actions to create pull requests. Because pushes made with `GITHUB_TOKEN` do not trigger ordinary PR workflows, the watcher explicitly dispatches the stable-image workflow on its PR branch. Workflow dispatches outside `main` validate without publishing. + +**Signing policy:** the current watcher uses DCO sign-off and GitHub Actions bot commit signing. The Git Signing MCP is used for contributor commits, but is **not yet integrated with the watcher**. Review that limitation before enabling automated commits under a strict signing policy. + +## Stable builds and promotion + +After review and merge, `.github/workflows/docker-image.yml` loads the committed JSON to create the matrix. It builds each approved `nextcloud:-fpm` on AMD64 and ARM64 and builds the web image once. + +Trivy vulnerability checks and Bats runtime acceptance are publication gates. Successful jobs publish architecture-specific staging tags. Only when all required matrix jobs pass does `scripts/publish-images.sh` promote the multiarch manifests. + +Publication produces: + +- `:-fpm` for every approved supported major; +- `:-fpm` for the tested patch release; +- `:stable-fpm` and compatibility `:latest` for the **highest approved major**, without a hardcoded major number; +- `:main` and compatibility `:latest` for the independent web image. + +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. + +## Development image `master-fpm` + +The separate `.github/workflows/nextcloud-development.yml` workflow follows the latest upstream Nextcloud Server master archive. It builds and scans AMD64 and ARM64 images, runs runtime acceptance, and promotes `:master-fpm` when its release gates pass. + +This development channel is separate from the stable JSON and must not be labeled as a numbered, stable Nextcloud major. Its architecture-specific staging tags are internal publication details. + +## Testing and diagnostics + +The repository uses Bash, AWK, `jq`, and Bats rather than a separate Python implementation. + +```bash +make test-ci-scripts +make test-development-image-scripts +bash tests/test-scan-images.sh +make test-app-image APP_IMAGE=scan/app:amd64 +make test-current-app-image +``` + +The wiki parser regression tests include real historical snapshots from 2023 to 2026, a malformed revision, overlapping EOL entries, and a release listed before publication. Publication tests simulate Docker calls so they do not push to the registry. + +The runtime acceptance test uses an isolated Docker network and PostgreSQL container, verifies Nextcloud installation and `occ` status, and checks the PHP-FPM interface. Test-created resources are cleaned up. CI must run these tests against the images actually built by the workflow. + +For YAML syntax, Shell scripts, and publication changes, use `actionlint`, `ShellCheck`, and the Bats suite before requesting review. Do not weaken vulnerability gates to pass CI. + +## Other maintenance decisions + +Image metadata should use standard OCI labels wherever possible, including the source, revision, creation time, and version or channel. Record the resolved upstream revision or base image where the source permits. OCI digests are the immutable deployment reference; a commit-based staging tag can be rebuilt and is not a substitute. + +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. + +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.