diff --git a/.docker/web/Dockerfile b/.docker/web/Dockerfile index b719fcc..46aa759 100644 --- a/.docker/web/Dockerfile +++ b/.docker/web/Dockerfile @@ -1,4 +1,4 @@ -FROM nginx:alpine +FROM nginx:alpine@sha256:892bb2152464f0c5d8972593a5f5afaf8054552580b699d95f8ff4af08c2d33d RUN apk upgrade --no-cache diff --git a/.github/actions/build-and-scan/action.yml b/.github/actions/build-and-scan/action.yml index 9128b2b..558ea74 100644 --- a/.github/actions/build-and-scan/action.yml +++ b/.github/actions/build-and-scan/action.yml @@ -5,7 +5,11 @@ inputs: description: Either app or web required: true nextcloud_version: - description: Official Nextcloud release tag for app builds (e.g. 35.0.2-fpm) + description: Official Nextcloud release tag for cache keys (e.g. 35.0.2-fpm) + required: false + default: '' + nextcloud_base_image: + description: Immutable approved Nextcloud image (tag and digest) required: false default: '' runs: @@ -36,7 +40,7 @@ runs: platforms: linux/amd64 load: true build-args: | - NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} + NEXTCLOUD_BASE_IMAGE=${{ inputs.nextcloud_base_image }} tags: scan/app:amd64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-amd64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-amd64 @@ -49,7 +53,7 @@ runs: platforms: linux/arm64 load: true build-args: | - NEXTCLOUD_BASE_IMAGE=nextcloud:${{ inputs.nextcloud_version }} + NEXTCLOUD_BASE_IMAGE=${{ inputs.nextcloud_base_image }} tags: scan/app:arm64 cache-from: type=gha,scope=app-${{ inputs.nextcloud_version }}-arm64 cache-to: type=gha,mode=max,scope=app-${{ inputs.nextcloud_version }}-arm64 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8c8a80f..10e5da3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,9 +11,7 @@ updates: - package-ecosystem: docker directory: /.docker/web schedule: - interval: weekly - cooldown: - default-days: 7 + interval: daily - package-ecosystem: github-actions directory: / diff --git a/.github/nextcloud-supported.json b/.github/nextcloud-supported.json index 7c73f88..845f8d3 100644 --- a/.github/nextcloud-supported.json +++ b/.github/nextcloud-supported.json @@ -4,17 +4,17 @@ "versions": [ { "major": 33, - "release": "33.0.10", + "image": "nextcloud:33.0.10-fpm@sha256:9b564a1f88b8ab80319fcdcb3982de3063d8c66e136121f2ed03274126d6858e", "eol": "2027-02" }, { "major": 34, - "release": "34.0.5", + "image": "nextcloud:34.0.5-fpm@sha256:debf3a90c0efc3a31f3c48e3c211ee3148983361c3d10a6a6bb8c13d7610463d", "eol": "2027-06" }, { "major": 35, - "release": "35.0.2", + "image": "nextcloud:35.0.2-fpm@sha256:2556e045d57fff50d2b50ddd160d2d0730e6d5b6f71c33b903d491baa8444f03", "eol": "2027-09" } ] diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index cbc458f..02784bf 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -8,8 +8,6 @@ on: branches: - main workflow_dispatch: - schedule: - - cron: '13 4 * * 2' permissions: contents: read @@ -90,6 +88,7 @@ jobs: with: component: app nextcloud_version: ${{ matrix.release }}-fpm + nextcloud_base_image: ${{ matrix.image }} - name: Upload amd64 security findings if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' diff --git a/docs/maintenance.md b/docs/maintenance.md index 550cad6..e05e51c 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -59,7 +59,7 @@ Publication produces: Expired EOL months in the approved JSON stop the release workflow instead of silently rebuilding an unsupported version. Existing EOL tags are **not deleted automatically**; they stop being rebuilt after the reviewed inventory changes. -Stable publication runs on pushes to `main`, a weekly rebuild schedule, or manual dispatch on `main`. Pull requests and non-`main` dispatches validate without publishing. +Stable publication runs on pushes to `main` or manual dispatch on `main`. Pull requests and non-`main` dispatches validate without publishing. ## Development image `master-fpm` @@ -94,3 +94,20 @@ Keep tag naming consistent with the official Nextcloud model. Do not introduce ` Dependabot manages Docker and GitHub Actions dependencies. Renovate is limited to regex-managed values Dependabot cannot discover (currently the explicitly pinned Trivy binary version), so the two bots do not manage the same dependency. The remaining implementation constraints tracked under #47 are to improve metadata when upstream identifiers become available, keep the stable and master publishing contracts aligned, preserve runtime and security gates, and maintain deployment compatibility until replacement approaches are validated. + + +## Automatic Docker base updates + +The supported Nextcloud JSON is the source of truth for the immutable Docker +base references. Renovate updates each supported image tag and digest in a +reviewable pull request. New major versions and EOL transitions remain managed +by the existing maintenance-wiki discovery workflow, which preserves the +Renovate-managed references for already-supported versions. + +Dependabot manages the independent Nginx Dockerfile (also digest-pinned) and +GitHub Actions. The stable Docker image workflow runs on pull requests and +reviewed merges to main. It has no scheduled rebuild. + +The separate maintenance-wiki discovery cron is only for changes to the +Nextcloud support inventory; it does not build or publish images. Docker base +updates alone do not detect changes in unpinned APT/APK packages. diff --git a/renovate.json b/renovate.json index d705793..218218c 100644 --- a/renovate.json +++ b/renovate.json @@ -15,6 +15,32 @@ "# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+version: (?v?\\d+\\.\\d+\\.\\d+)" ], "versioningTemplate": "semver-coerced" + }, + { + "customType": "regex", + "managerFilePatterns": [ + "/^\\.github\\/nextcloud-supported\\.json$/" + ], + "matchStrings": [ + "\"image\"\\s*:\\s*\"nextcloud:(?\\d+\\.\\d+\\.\\d+-fpm)@(?sha256:[a-f0-9]{64})\"" + ], + "datasourceTemplate": "docker", + "depNameTemplate": "nextcloud", + "versioningTemplate": "docker" + } + ], + "packageRules": [ + { + "matchManagers": [ + "custom.regex" + ], + "matchPackageNames": [ + "nextcloud" + ], + "matchUpdateTypes": [ + "major" + ], + "enabled": false } ] } diff --git a/scripts/nextcloud-versions.sh b/scripts/nextcloud-versions.sh index 49e8933..edda7d5 100644 --- a/scripts/nextcloud-versions.sh +++ b/scripts/nextcloud-versions.sh @@ -53,10 +53,10 @@ validate_manifest() { ($root.versions | type == "array" and length >= 1 and length <= 5) and ($root.versions | all(.[]; type == "object" and - (keys == ["eol","major","release"]) and + (keys == ["eol","image","major"]) and (.major | type == "number" and . >= 20 and . <= 99 and . == floor) and - (.release | type == "string" and test("^[0-9]{2,3}[.][0-9]+[.][0-9]+$")) and - ((.release | split(".")[0] | tonumber) == .major) and + (.image | type == "string" and test("^nextcloud:[0-9]{2,3}[.][0-9]+[.][0-9]+-fpm@sha256:[a-f0-9]{64}$")) and + ((.image | capture("^nextcloud:(?[0-9]{2,3})[.]").major | tonumber) == .major) and (.eol | type == "string" and test("^20[0-9]{2}-(0[1-9]|1[0-2])$") and . >= $month) )) and ([$root.versions[].major] as $majors | @@ -87,12 +87,13 @@ verify_release() { (.images | any(.[]; .os == "linux" and .architecture == "amd64")) and (.images | any(.[]; .os == "linux" and .architecture == "arm64")) ' <<< "$docker_tag" > /dev/null || error "Official image nextcloud:${release}-fpm lacks amd64 or arm64" + jq -er '.digest | select(test("^sha256:[a-f0-9]{64}$"))' <<< "$docker_tag" || error "No valid digest for nextcloud:${release}-fpm" } case "$command_name" in matrix) validate_manifest "$manifest" - matrix=$(jq -c '{include: [.versions[] | {major, tag: ((.major | tostring) + "-fpm"), release}]}' "$manifest") + matrix=$(jq -c '{include: [.versions[] | {major, tag: ((.major | tostring) + "-fpm"), image, release: (.image | split(":")[1] | split("-fpm@")[0])}]}' "$manifest") if [[ -n $github_output ]]; then printf 'matrix=%s\n' "$matrix" >> "$github_output" else @@ -116,14 +117,21 @@ case "$command_name" in [[ $released =~ ^20[0-9]{2}-[0-9]{2}-[0-9]{2}$ && $released < $today || $released == "$today" ]] \ || error "Nextcloud $major release date is invalid or in the future: $released" [[ $(date -u -d "$released" +%F 2>/dev/null) == "$released" ]] || error "Invalid release date: $released" - if [[ $skip_upstream == false ]]; then verify_release "$release"; fi + digest="sha256:$(printf "%064d" 0)" + if [[ $skip_upstream == false ]]; then digest=$(verify_release "$release"); fi + image='' + if [[ -f $manifest ]]; then + image=$(jq -r --argjson major "$major" '.versions[]? | select(.major == $major) | .image // empty' "$manifest") + fi + if [[ -z $image ]]; then image="nextcloud:${release}-fpm@${digest}"; fi + printf '%s\t%s\t%s\n' "$major" "$image" "$eol" >> "$tmpdir/images.tsv" done < "$tmpdir/releases.tsv" jq -Rn --arg source "$source_url" ' {schema_version: 1, source: $source, versions: ([inputs | split("\t") | - {major: (.[0] | tonumber), release: .[1], eol: .[2]}] | sort_by(.major))} - ' < "$tmpdir/releases.tsv" > "$tmpdir/manifest.json" + {major: (.[0] | tonumber), image: .[1], eol: .[2]}] | sort_by(.major))} + ' < "$tmpdir/images.tsv" > "$tmpdir/manifest.json" validate_manifest "$tmpdir/manifest.json" if [[ ! -f $manifest ]] || ! cmp -s "$tmpdir/manifest.json" "$manifest"; then diff --git a/scripts/publish-images.sh b/scripts/publish-images.sh index 000a757..db26a18 100644 --- a/scripts/publish-images.sh +++ b/scripts/publish-images.sh @@ -47,7 +47,7 @@ promote() { publish "$app_image" "${major}-fpm" "$prefix" publish "$app_image" "${release}-fpm" "$prefix" latest_prefix=$prefix - done < <(jq -r '.versions[] | [.major, .release] | @tsv' "$manifest_file") + done < <(jq -r '.versions[] | [.major, (.image | split(":")[1] | split("-fpm@")[0])] | @tsv' "$manifest_file") [[ -n ${latest_prefix:-} ]] || fail "No approved stable Nextcloud major" publish "$app_image" stable-fpm "$latest_prefix" diff --git a/tests/nextcloud-versions.bats b/tests/nextcloud-versions.bats index cf3bb7d..ab5a7ee 100644 --- a/tests/nextcloud-versions.bats +++ b/tests/nextcloud-versions.bats @@ -35,7 +35,7 @@ discover_fixture() { run discover_fixture 2026-supported 2026-10-20 [ "$status" -eq 0 ] [ "$(jq -r '[.versions[].major] | join(",")' "$inventory")" = 33,34,35 ] - [ "$(jq -r '.versions[-1].release' "$inventory")" = 35.0.2 ] + [[ "$(jq -r '.versions[-1].image' "$inventory")" == nextcloud:35.0.2-fpm@sha256:* ]] } @test "historical EOL formatting variations do not change the supported majors" { run discover_fixture 2025-eol-structure 2025-10-27 @@ -103,7 +103,7 @@ case "$url" in ;; *hub.docker.com*) tag="${url##*/}" - printf '{"name":"%s","images":[{"os":"linux","architecture":"amd64"},{"os":"linux","architecture":"arm64"}]}\n' "$tag" + printf '{"name":"%s","digest":"sha256:%064d","images":[{"os":"linux","architecture":"amd64"},{"os":"linux","architecture":"arm64"}]}\n' "$tag" 0 ;; *) exit 1 ;; esac @@ -132,3 +132,25 @@ MOCK [[ "$output" == *'lacks amd64 or arm64'* ]] [ ! -f "$inventory" ] } + +@test "the manifest rejects an altered or incomplete Docker digest" { + discover_fixture 2026-supported 2026-10-20 + jq '.versions[1].image = "nextcloud:34.0.5-fpm@sha256:invalid"' "$inventory" > "$TEST_ROOT/invalid.json" + run bash "$script" matrix --file "$TEST_ROOT/invalid.json" --today 2026-10-20 + [ "$status" -ne 0 ] +} + +@test "wiki discovery preserves Renovate-updated image refs" { + discover_fixture 2026-supported 2026-10-20 + jq '.versions[2].image = "nextcloud:35.0.3-fpm@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' "$inventory" > "$TEST_ROOT/renovate.json" + cp "$TEST_ROOT/renovate.json" "$inventory" + discover_fixture 2026-supported 2026-10-20 + [ "$(jq -r '.versions[2].image' "$inventory")" = "nextcloud:35.0.3-fpm@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ] + run bash "$script" matrix --file "$inventory" --today 2026-10-20 + [ "$status" -eq 0 ] +} + +@test "web Dockerfile uses a complete SHA256 digest" { + run grep -Eq '^FROM nginx:alpine@sha256:[a-f0-9]{64}$' .docker/web/Dockerfile + [ "$status" -eq 0 ] +} diff --git a/tests/publish-images.bats b/tests/publish-images.bats index f0ce99e..36d26b6 100644 --- a/tests/publish-images.bats +++ b/tests/publish-images.bats @@ -58,7 +58,7 @@ teardown() { grep -Fx "buildx imagetools create --tag ${WEB_IMAGE}:latest ${WEB_IMAGE}:sha-${SHA}-web-amd64 ${WEB_IMAGE}:sha-${SHA}-web-arm64" "$DOCKER_COMMAND_LOG" } @test "latest follows Nextcloud 36 when approved, without code changes" { - jq '.versions = (.versions[1:] + [{"major":36,"release":"36.0.0","eol":"2099-12"}])' \ + jq '.versions = (.versions[1:] + [{"major":36,"image":"nextcloud:36.0.0-fpm@sha256:0000000000000000000000000000000000000000000000000000000000000000","eol":"2099-12"}])' \ "$INVENTORY" > "$TEST_ROOT/next.json" run bash "$script" promote "$APP_IMAGE" "$WEB_IMAGE" "$SHA" "$TEST_ROOT/next.json" [ "$status" -eq 0 ]