diff --git a/.github/workflows/nextcloud-development.yml b/.github/workflows/nextcloud-development.yml index 137628f..e0fbe4e 100644 --- a/.github/workflows/nextcloud-development.yml +++ b/.github/workflows/nextcloud-development.yml @@ -1,4 +1,4 @@ -name: Validate Nextcloud master image +name: Validate and publish Nextcloud master image on: pull_request: @@ -10,6 +10,10 @@ on: - 'scripts/scan-images.sh' - 'trivy.yaml' - '.github/workflows/nextcloud-development.yml' + - 'scripts/resolve-nextcloud-upstream.sh' + - 'scripts/push-master-architecture.sh' + - 'scripts/publish-master-manifest.sh' + - 'tests/nextcloud-development-image.bats' push: branches: - main @@ -19,19 +23,56 @@ on: - 'scripts/scan-images.sh' - 'trivy.yaml' - '.github/workflows/nextcloud-development.yml' + - 'scripts/resolve-nextcloud-upstream.sh' + - 'scripts/push-master-architecture.sh' + - 'scripts/publish-master-manifest.sh' + - 'tests/nextcloud-development-image.bats' + schedule: + - cron: '17 3 * * *' workflow_dispatch: permissions: contents: read concurrency: - group: nextcloud-master-validation-${{ github.ref }} + group: nextcloud-master-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true +env: + APP_IMAGE: ghcr.io/librecodecoop/nextcloud-docker-app + NEXTCLOUD_DAILY_URL: https://download.nextcloud.com/server/daily/latest-master.tar.bz2 + NEXTCLOUD_BASE_TAG: stable-fpm + jobs: + script-tests: + name: Test development image scripts + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + 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 development image scripts + run: bats tests/nextcloud-development-image.bats + validate: + needs: + - script-tests name: master-fpm / linux/${{ matrix.arch }} runs-on: ubuntu-latest + permissions: + contents: read + packages: write strategy: fail-fast: false @@ -62,6 +103,11 @@ jobs: detik-install: false file-install: false + - name: Resolve upstream inputs + id: upstream + shell: bash + run: bash scripts/resolve-nextcloud-upstream.sh "${NEXTCLOUD_BASE_TAG}" "${NEXTCLOUD_DAILY_URL}" >> "${GITHUB_OUTPUT}" + - name: Build Nextcloud master app image uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: @@ -69,9 +115,19 @@ jobs: platforms: linux/${{ matrix.arch }} load: true build-args: | - NEXTCLOUD_BASE_IMAGE=nextcloud:stable-fpm + NEXTCLOUD_BASE_IMAGE=${{ steps.upstream.outputs.base_image }} NEXTCLOUD_SOURCE=daily - NEXTCLOUD_DAILY_URL=https://download.nextcloud.com/server/daily/latest-master.tar.bz2 + NEXTCLOUD_DAILY_URL=${{ env.NEXTCLOUD_DAILY_URL }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.created=${{ steps.upstream.outputs.created }} + org.opencontainers.image.version=master-fpm + coop.librecode.nextcloud.channel=master + coop.librecode.nextcloud.source=${{ env.NEXTCLOUD_DAILY_URL }} + coop.librecode.nextcloud.source.sha512=${{ steps.upstream.outputs.source_sha512 }} + coop.librecode.nextcloud.base.digest=${{ steps.upstream.outputs.base_digest }} + coop.librecode.runtime.variant=fpm tags: scan/master:${{ matrix.arch }} cache-from: type=gha,scope=master-${{ matrix.arch }} cache-to: type=gha,mode=max,scope=master-${{ matrix.arch }} @@ -95,3 +151,41 @@ jobs: env: APP_IMAGE: scan/master:${{ matrix.arch }} run: bats tests/app-image.bats + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Push validated architecture image + if: github.event_name != 'pull_request' + shell: bash + run: bash scripts/push-master-architecture.sh "${APP_IMAGE}" "scan/master:${{ matrix.arch }}" "${GITHUB_SHA}" "${{ matrix.arch }}" + + publish: + name: Publish master-fpm manifest + if: github.event_name != 'pull_request' + needs: + - validate + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish multi-platform master-fpm + shell: bash + run: bash scripts/publish-master-manifest.sh "${APP_IMAGE}" "${GITHUB_SHA}" diff --git a/Makefile b/Makefile index 62dae15..95454a2 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-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-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-development-image-scripts: + bats tests/nextcloud-development-image.bats + test-ncdd: bats tests/ncdd.bats diff --git a/docs/images.md b/docs/images.md index 8dfb323..7a47144 100644 --- a/docs/images.md +++ b/docs/images.md @@ -166,6 +166,20 @@ The repository Compose environment keeps `NEXTCLOUD_VERSION` as its user-facing 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. @@ -192,8 +206,8 @@ This document defines the target contract and the generic app-image foundation n Remaining work in #47 must continue incrementally. In particular: -- publish development images from the generic foundation using the documented `:master-fpm` contract; -- add the remaining OCI traceability metadata to published images; +- 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. diff --git a/scripts/publish-master-manifest.sh b/scripts/publish-master-manifest.sh new file mode 100644 index 0000000..47f3464 --- /dev/null +++ b/scripts/publish-master-manifest.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail + +app_image=${1:?Usage: publish-master-manifest.sh } +revision=${2:?Usage: publish-master-manifest.sh } + +docker buildx imagetools create \ + --tag "${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/push-master-architecture.sh b/scripts/push-master-architecture.sh new file mode 100644 index 0000000..6268c2c --- /dev/null +++ b/scripts/push-master-architecture.sh @@ -0,0 +1,20 @@ +#!/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 } + +case "${arch}" in + amd64|arm64) ;; + *) + 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}" diff --git a/scripts/resolve-nextcloud-upstream.sh b/scripts/resolve-nextcloud-upstream.sh new file mode 100644 index 0000000..5707a58 --- /dev/null +++ b/scripts/resolve-nextcloud-upstream.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +base_tag=${1:?Usage: resolve-nextcloud-upstream.sh } +daily_url=${2:?Usage: resolve-nextcloud-upstream.sh } + +token_response="$(curl -fsSL "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/nextcloud:pull")" +token="$(printf '%s' "${token_response}" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" +if [ -z "${token}" ]; then + echo "Could not obtain Docker Hub token" >&2 + exit 1 +fi + +headers="$(mktemp)" +checksum_file="$(mktemp)" +trap 'rm -f "$headers" "$checksum_file"' EXIT + +curl -fsSLI \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' \ + -D "${headers}" \ + -o /dev/null \ + "https://registry-1.docker.io/v2/library/nextcloud/manifests/${base_tag}" + +base_digest="$(awk 'BEGIN { IGNORECASE=1 } /^docker-content-digest:/ { gsub("\r", "", $2); print $2; exit }' "${headers}")" +if [[ ! "${base_digest}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "Could not resolve nextcloud:${base_tag} digest" >&2 + exit 1 +fi + +archive_name="$(basename "${daily_url}")" +curl -fsSL "${daily_url}.sha512" -o "${checksum_file}" +source_sha512="$(awk -v archive="${archive_name}" '$2 == archive { print $1; exit }' "${checksum_file}")" +if [[ ! "${source_sha512}" =~ ^[0-9a-fA-F]{128}$ ]]; then + echo "Could not resolve SHA-512 for ${archive_name}" >&2 + exit 1 +fi + +printf 'base_image=nextcloud@%s\n' "${base_digest}" +printf 'base_digest=%s\n' "${base_digest}" +printf 'source_sha512=%s\n' "${source_sha512,,}" +printf 'created=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" diff --git a/tests/nextcloud-development-image.bats b/tests/nextcloud-development-image.bats new file mode 100644 index 0000000..970c53c --- /dev/null +++ b/tests/nextcloud-development-image.bats @@ -0,0 +1,115 @@ +#!/usr/bin/env bats + +setup() { + TEST_ROOT="$(mktemp -d)" + BIN_DIR="$TEST_ROOT/bin" + mkdir -p "$BIN_DIR" + export PATH="$BIN_DIR:$PATH" +} + +teardown() { + rm -rf "$TEST_ROOT" +} + +@test "resolve upstream emits validated metadata" { + cat > "$BIN_DIR/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +args="$*" +if [[ "$args" == *"auth.docker.io/token"* ]]; then + printf '{"token":"test-token"}' +elif [[ "$args" == *"registry-1.docker.io"* ]]; then + headers="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-D" ]; then + headers=$2 + shift 2 + else + shift + fi + done + printf 'docker-content-digest: sha256:%064d\r\n' 0 > "$headers" +elif [[ "$args" == *".sha512"* ]]; then + output="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + output=$2 + shift 2 + else + shift + fi + done + printf '%0128d latest-master.tar.bz2\n' 0 > "$output" +else + exit 1 +fi +EOF + chmod +x "$BIN_DIR/curl" + + run bash scripts/resolve-nextcloud-upstream.sh stable-fpm https://download.nextcloud.com/server/daily/latest-master.tar.bz2 + + [ "$status" -eq 0 ] + [[ "$output" == *"base_image=nextcloud@sha256:"* ]] + [[ "$output" == *"source_sha512="* ]] + [[ "$output" == *"created="* ]] +} + +@test "resolve upstream rejects an invalid base digest" { + cat > "$BIN_DIR/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +args="$*" +if [[ "$args" == *"auth.docker.io/token"* ]]; then + printf '{"token":"test-token"}' +elif [[ "$args" == *"registry-1.docker.io"* ]]; then + while [ "$#" -gt 0 ]; do + if [ "$1" = "-D" ]; then + printf 'docker-content-digest: invalid\r\n' > "$2" + exit 0 + fi + shift + done +fi +EOF + chmod +x "$BIN_DIR/curl" + + run bash scripts/resolve-nextcloud-upstream.sh stable-fpm https://download.nextcloud.com/server/daily/latest-master.tar.bz2 + + [ "$status" -ne 0 ] + [[ "$output" == *"Could not resolve nextcloud:stable-fpm digest"* ]] +} + +@test "architecture publication uses only the requested staging tag" { + cat > "$BIN_DIR/docker" <> "$TEST_ROOT/docker.log" +EOF + chmod +x "$BIN_DIR/docker" + + run bash scripts/push-master-architecture.sh ghcr.io/example/app scan/master:amd64 abc123 amd64 + + [ "$status" -eq 0 ] + grep -Fx "tag scan/master:amd64 ghcr.io/example/app:master-fpm-abc123-amd64" "$TEST_ROOT/docker.log" + grep -Fx "push ghcr.io/example/app:master-fpm-abc123-amd64" "$TEST_ROOT/docker.log" +} + +@test "architecture publication rejects unsupported architectures" { + run bash scripts/push-master-architecture.sh ghcr.io/example/app scan/master:s390x abc123 s390x + + [ "$status" -eq 2 ] + [[ "$output" == *"Unsupported architecture: s390x"* ]] +} + +@test "manifest publication combines exactly amd64 and arm64" { + cat > "$BIN_DIR/docker" <> "$TEST_ROOT/docker.log" +EOF + chmod +x "$BIN_DIR/docker" + + run bash scripts/publish-master-manifest.sh ghcr.io/example/app abc123 + + [ "$status" -eq 0 ] + grep -Fx "buildx imagetools create --tag ghcr.io/example/app:master-fpm ghcr.io/example/app:master-fpm-abc123-amd64 ghcr.io/example/app:master-fpm-abc123-arm64" "$TEST_ROOT/docker.log" + grep -Fx "buildx imagetools inspect ghcr.io/example/app:master-fpm" "$TEST_ROOT/docker.log" +}