diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 8c512cf77..5d9d03bc4 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -2,12 +2,11 @@ name: Container # Advisory: builds the application image the same way release.yml does # (same Dockerfile, buildah, linux/amd64, OCI) without pushing, then scans it -# with Trivy. Only the application layer is verified: the build uses the -# published builder-latest / runtime-latest base images (the Dockerfile -# defaults), so changes to build/base/** and build/packages/** are not -# covered here. Until release.yml has published the Wolfi base images and -# moved builder-latest / runtime-latest to them, those tags still point at the -# legacy Alpine/nginx images and this build is expected to fail. +# with Trivy. The build uses the runtime/builder base images whose content hash +# matches the checked-out revision, calculated by the same script as in +# release.yml. If a revision changes build/base/** or build/packages/** and the +# matching images are not published yet, the build warns and falls back to the +# builder-latest / runtime-latest base images, so those changes are not covered. on: pull_request: @@ -17,13 +16,128 @@ on: permissions: {} +env: + # Keep in sync with release.yml, they feed the base image content hash + # (tests/ci/base-images.sh checks it). + RUNTIME_IMAGE: ghcr.io/cattr-app/server-runtime + + RUNTIME_CONFIG: build/base/runtime/apko.yaml + BUILDER_CONFIG: build/base/builder/apko.yaml + PHP_CONFIG: build/packages/php83.yaml + CADDY_CONFIG: build/packages/caddy.yaml + NODEJS_CONFIG: build/packages/nodejs-24.yaml + PEBBLE_CONFIG: build/packages/pebble.yaml + COMPOSER_CONFIG: build/packages/composer.yaml + COREPACK_CONFIG: build/packages/corepack.yaml + + IMAGE_BUILD_REVISION: '7' + + OCI_SOURCE: https://github.com/cattr-app/server-application + OCI_VENDOR: Cattr + + RUNTIME_DESCRIPTION: >- + Cattr server runtime image with PHP 8.3, Nginx and Pebble on Wolfi + + BUILDER_DESCRIPTION: >- + Cattr server build image with PHP 8.3, Composer, Node.js and Corepack on Wolfi + concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + base-images: + name: Resolve base images + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + + # The same Wolfi SDK as release.yml: the apko and melange versions it + # reports are part of the base image hash. + container: + image: ghcr.io/wolfi-dev/sdk@sha256:74863718530747316d12ce3dc116108143a3bac483db0b3a985b25babfdb1a19 + + defaults: + run: + shell: bash + + outputs: + runtime_tag: ${{ steps.resolve.outputs.runtime_tag }} + builder_tag: ${{ steps.resolve.outputs.builder_tag }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # The base image revisions are taken from the git history + fetch-depth: 0 + + - name: Configure Git safe directory + run: | + git config --global --add safe.directory "${GITHUB_WORKSPACE}" + + - name: Setup crane + uses: imjasonh/setup-crane@feee3b6bb0d4c68370f256a4502498c9227e5c6b # v0.7 + with: + version: v0.22.1 + + - name: Calculate base image tags + id: meta + env: + RUNTIME_FILES_HASH: ${{ hashFiles( + 'build/base/runtime/**', + 'build/packages/php83.yaml', + 'build/packages/caddy.yaml', + 'build/packages/pebble.yaml', + 'build/base/rebuild' + ) }} + BUILDER_FILES_HASH: ${{ hashFiles( + 'build/base/builder/**', + 'build/packages/php83.yaml', + 'build/packages/nodejs-24.yaml', + 'build/packages/pebble.yaml', + 'build/packages/composer.yaml', + 'build/packages/corepack.yaml', + 'build/base/rebuild' + ) }} + run: bash scripts/base-image-meta.sh + + # Public images, no registry login is needed to read them. + - name: Resolve published base images + id: resolve + env: + RUNTIME_TAG: ${{ steps.meta.outputs.runtime_tag }} + BUILDER_TAG: ${{ steps.meta.outputs.builder_tag }} + run: | + set -euo pipefail + + # Sets the variable named by $3 to the tag if the image is published, + # otherwise to the floating -latest tag. + resolve() { + local kind="$1" tag="$2" var="$3" + + if crane manifest --platform linux/amd64 "${RUNTIME_IMAGE}:${tag}" >/dev/null 2>&1 && + crane validate --platform linux/amd64 --remote "${RUNTIME_IMAGE}:${tag}" >/dev/null 2>&1; then + echo "Using ${RUNTIME_IMAGE}:${tag}" + printf -v "${var}" '%s' "${tag}" + else + echo "::warning::${RUNTIME_IMAGE}:${tag} is not published; falling back to ${kind}-latest, so changes to the ${kind} base image are not covered" + printf -v "${var}" '%s' "${kind}-latest" + fi + } + + resolve runtime "${RUNTIME_TAG}" runtime_effective + resolve builder "${BUILDER_TAG}" builder_effective + + { + echo "runtime_tag=${runtime_effective}" + echo "builder_tag=${builder_effective}" + } >> "${GITHUB_OUTPUT}" + build: name: Container build + needs: base-images runs-on: ubuntu-24.04 timeout-minutes: 60 permissions: @@ -52,6 +166,8 @@ jobs: oci: true build-args: | APP_VERSION=0.0.0-ci + BUILDER_BASE_IMAGE_TAG=${{ needs.base-images.outputs.builder_tag }} + RUNTIME_BASE_IMAGE_TAG=${{ needs.base-images.outputs.runtime_tag }} layers: true - name: Export image diff --git a/.github/workflows/release-check.yml b/.github/workflows/release-check.yml index 645be0c3b..50c8e6bb2 100644 --- a/.github/workflows/release-check.yml +++ b/.github/workflows/release-check.yml @@ -5,16 +5,22 @@ on: paths: - '.github/workflows/release.yml' - '.github/workflows/release-check.yml' + - '.github/workflows/container.yml' - 'scripts/release-tags.sh' - 'tests/ci/release-tags.sh' + - 'scripts/base-image-meta.sh' + - 'tests/ci/base-images.sh' push: branches: - main paths: - '.github/workflows/release.yml' - '.github/workflows/release-check.yml' + - '.github/workflows/container.yml' - 'scripts/release-tags.sh' - 'tests/ci/release-tags.sh' + - 'scripts/base-image-meta.sh' + - 'tests/ci/base-images.sh' permissions: contents: read @@ -30,3 +36,6 @@ jobs: - name: Test release tag classification run: bash tests/ci/release-tags.sh + + - name: Test base image workflow consistency + run: bash tests/ci/base-images.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 127c5d0d5..73361d2e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -164,124 +164,7 @@ jobs: 'build/packages/corepack.yaml', 'build/base/rebuild' ) }} - run: | - set -euo pipefail - - for file in \ - "${RUNTIME_CONFIG}" \ - "${BUILDER_CONFIG}" \ - "${PHP_CONFIG}" \ - "${CADDY_CONFIG}" \ - "${NODEJS_CONFIG}" \ - "${PEBBLE_CONFIG}" \ - "${COMPOSER_CONFIG}" \ - "${COREPACK_CONFIG}" - do - if [[ ! -f "${file}" ]]; then - echo "::error::Required build configuration not found: ${file}" - exit 1 - fi - done - - runtime_files_hash="${RUNTIME_FILES_HASH}" - builder_files_hash="${BUILDER_FILES_HASH}" - - if [[ -z "${runtime_files_hash}" ]]; then - echo "::error::Unable to calculate runtime configuration hash" - exit 1 - fi - - if [[ -z "${builder_files_hash}" ]]; then - echo "::error::Unable to calculate builder configuration hash" - exit 1 - fi - - apko_version="$(apko version 2>&1 | tr '\n' ' ')" - melange_version="$(melange version 2>&1 | tr '\n' ' ')" - - # Keep the OCI revision of immutable base images tied to the last - # commit that actually changed their build inputs, rather than to - # every unrelated Cattr application release. - runtime_revision="$( - git log \ - -1 \ - --format='%H' \ - -- \ - "${RUNTIME_CONFIG}" \ - "${PHP_CONFIG}" \ - "${CADDY_CONFIG}" \ - "${PEBBLE_CONFIG}" \ - build/base/rebuild - )" - - builder_revision="$( - git log \ - -1 \ - --format='%H' \ - -- \ - "${BUILDER_CONFIG}" \ - "${PHP_CONFIG}" \ - "${NODEJS_CONFIG}" \ - "${PEBBLE_CONFIG}" \ - "${COMPOSER_CONFIG}" \ - "${COREPACK_CONFIG}" \ - build/base/rebuild - )" - - if [[ -z "${runtime_revision}" ]]; then - echo "::error::Unable to determine runtime source revision" - exit 1 - fi - - if [[ -z "${builder_revision}" ]]; then - echo "::error::Unable to determine builder source revision" - exit 1 - fi - - # Static OCI metadata is part of the image identity as changing it - # changes the resulting OCI manifest/config digest. - runtime_hash="$( - printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ - "${IMAGE_BUILD_REVISION}" \ - "${apko_version}" \ - "${melange_version}" \ - "${runtime_files_hash}" \ - "${OCI_SOURCE}" \ - "${OCI_VENDOR}" \ - "${RUNTIME_DESCRIPTION}" | - sha256sum | - cut -d' ' -f1 - )" - - builder_hash="$( - printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ - "${IMAGE_BUILD_REVISION}" \ - "${apko_version}" \ - "${melange_version}" \ - "${builder_files_hash}" \ - "${OCI_SOURCE}" \ - "${OCI_VENDOR}" \ - "${BUILDER_DESCRIPTION}" | - sha256sum | - cut -d' ' -f1 - )" - - runtime_tag="runtime-${runtime_hash}" - builder_tag="builder-${builder_hash}" - - { - echo "image=${RUNTIME_IMAGE}" - echo "runtime_tag=${runtime_tag}" - echo "runtime_revision=${runtime_revision}" - echo "builder_tag=${builder_tag}" - echo "builder_revision=${builder_revision}" - } >> "${GITHUB_OUTPUT}" - - echo "Runtime image:" - echo " ${RUNTIME_IMAGE}:${runtime_tag}" - - echo "Builder image:" - echo " ${RUNTIME_IMAGE}:${builder_tag}" + run: bash scripts/base-image-meta.sh - name: Check existing runtime images id: check diff --git a/scripts/base-image-meta.sh b/scripts/base-image-meta.sh new file mode 100644 index 000000000..96814118f --- /dev/null +++ b/scripts/base-image-meta.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Calculates the content-addressed tags of the runtime and builder base images +# (runtime-, builder-) and writes them to GITHUB_OUTPUT. Shared by +# release.yml, which publishes the images, and container.yml, which must build +# the application against the images that match the checked-out revision. +# +# Expects the *_CONFIG, IMAGE_BUILD_REVISION, OCI_*, *_DESCRIPTION and +# RUNTIME_IMAGE variables from the workflow environment, plus +# RUNTIME_FILES_HASH / BUILDER_FILES_HASH from hashFiles(). It must run in the +# Wolfi SDK container because apko and melange versions are part of the hash. +set -euo pipefail + +for file in \ + "${RUNTIME_CONFIG}" \ + "${BUILDER_CONFIG}" \ + "${PHP_CONFIG}" \ + "${CADDY_CONFIG}" \ + "${NODEJS_CONFIG}" \ + "${PEBBLE_CONFIG}" \ + "${COMPOSER_CONFIG}" \ + "${COREPACK_CONFIG}" +do + if [[ ! -f "${file}" ]]; then + echo "::error::Required build configuration not found: ${file}" + exit 1 + fi +done + +runtime_files_hash="${RUNTIME_FILES_HASH}" +builder_files_hash="${BUILDER_FILES_HASH}" + +if [[ -z "${runtime_files_hash}" ]]; then + echo "::error::Unable to calculate runtime configuration hash" + exit 1 +fi + +if [[ -z "${builder_files_hash}" ]]; then + echo "::error::Unable to calculate builder configuration hash" + exit 1 +fi + +apko_version="$(apko version 2>&1 | tr '\n' ' ')" +melange_version="$(melange version 2>&1 | tr '\n' ' ')" + +# Keep the OCI revision of immutable base images tied to the last +# commit that actually changed their build inputs, rather than to +# every unrelated Cattr application release. +runtime_revision="$( + git log \ + -1 \ + --format='%H' \ + -- \ + "${RUNTIME_CONFIG}" \ + "${PHP_CONFIG}" \ + "${CADDY_CONFIG}" \ + "${PEBBLE_CONFIG}" \ + build/base/rebuild +)" + +builder_revision="$( + git log \ + -1 \ + --format='%H' \ + -- \ + "${BUILDER_CONFIG}" \ + "${PHP_CONFIG}" \ + "${NODEJS_CONFIG}" \ + "${PEBBLE_CONFIG}" \ + "${COMPOSER_CONFIG}" \ + "${COREPACK_CONFIG}" \ + build/base/rebuild +)" + +if [[ -z "${runtime_revision}" ]]; then + echo "::error::Unable to determine runtime source revision" + exit 1 +fi + +if [[ -z "${builder_revision}" ]]; then + echo "::error::Unable to determine builder source revision" + exit 1 +fi + +# Static OCI metadata is part of the image identity as changing it +# changes the resulting OCI manifest/config digest. +runtime_hash="$( + printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + "${IMAGE_BUILD_REVISION}" \ + "${apko_version}" \ + "${melange_version}" \ + "${runtime_files_hash}" \ + "${OCI_SOURCE}" \ + "${OCI_VENDOR}" \ + "${RUNTIME_DESCRIPTION}" | + sha256sum | + cut -d' ' -f1 +)" + +builder_hash="$( + printf '%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + "${IMAGE_BUILD_REVISION}" \ + "${apko_version}" \ + "${melange_version}" \ + "${builder_files_hash}" \ + "${OCI_SOURCE}" \ + "${OCI_VENDOR}" \ + "${BUILDER_DESCRIPTION}" | + sha256sum | + cut -d' ' -f1 +)" + +runtime_tag="runtime-${runtime_hash}" +builder_tag="builder-${builder_hash}" + +{ + echo "image=${RUNTIME_IMAGE}" + echo "runtime_tag=${runtime_tag}" + echo "runtime_revision=${runtime_revision}" + echo "builder_tag=${builder_tag}" + echo "builder_revision=${builder_revision}" +} >> "${GITHUB_OUTPUT}" + +echo "Runtime image:" +echo " ${RUNTIME_IMAGE}:${runtime_tag}" + +echo "Builder image:" +echo " ${RUNTIME_IMAGE}:${builder_tag}" diff --git a/tests/ci/base-images.sh b/tests/ci/base-images.sh new file mode 100644 index 000000000..1557dba96 --- /dev/null +++ b/tests/ci/base-images.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +# release.yml publishes the base images and container.yml looks them up by the +# same content hash, so everything feeding that hash must be identical in both. + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +release="${root}/.github/workflows/release.yml" +container="${root}/.github/workflows/container.yml" + +same() { + local what="$1" from_release="$2" from_container="$3" + [[ -n "${from_release}" ]] || { echo "Nothing found in release.yml for: ${what}" >&2; exit 1; } + [[ "${from_release}" == "${from_container}" ]] || { + echo "${what} differs between release.yml and container.yml" >&2 + exit 1 + } +} + +# Top-level scalar and folded (>-) env entries +env_entry() { + awk -v key="$2" ' + $0 ~ "^ " key ":" { found = 1; print; next } + found && /^ / { print; next } + found { exit } + ' "$1" +} + +for key in RUNTIME_IMAGE RUNTIME_CONFIG BUILDER_CONFIG PHP_CONFIG CADDY_CONFIG NODEJS_CONFIG \ + PEBBLE_CONFIG COMPOSER_CONFIG COREPACK_CONFIG IMAGE_BUILD_REVISION OCI_SOURCE OCI_VENDOR \ + RUNTIME_DESCRIPTION BUILDER_DESCRIPTION; do + same "env ${key}" "$(env_entry "${release}" "${key}")" "$(env_entry "${container}" "${key}")" +done + +hash_inputs() { + awk -v key="$2" ' + $0 ~ key ": \\$\\{\\{ hashFiles\\(" { found = 1 } + found { print } + found && /\) \}\}/ { exit } + ' "$1" +} + +for key in RUNTIME_FILES_HASH BUILDER_FILES_HASH; do + same "${key} inputs" "$(hash_inputs "${release}" "${key}")" "$(hash_inputs "${container}" "${key}")" +done + +sdk() { + grep -o 'ghcr.io/wolfi-dev/sdk@sha256:[0-9a-f]\{64\}' "$1" | sort -u +} + +same 'Wolfi SDK image' "$(sdk "${release}")" "$(sdk "${container}")" + +grep -Fq 'run: bash scripts/base-image-meta.sh' "${release}" +grep -Fq 'run: bash scripts/base-image-meta.sh' "${container}" +grep -Fq 'BUILDER_BASE_IMAGE_TAG=${{ needs.base-images.outputs.builder_tag }}' "${container}" +grep -Fq 'RUNTIME_BASE_IMAGE_TAG=${{ needs.base-images.outputs.runtime_tag }}' "${container}" + +echo 'Base image workflow checks passed.'