diff --git a/.github/workflows/docker.publish.yml b/.github/workflows/docker.publish.yml index 52083af..3594ded 100644 --- a/.github/workflows/docker.publish.yml +++ b/.github/workflows/docker.publish.yml @@ -126,9 +126,13 @@ jobs: set -euo pipefail retry() { - local attempt=1 + local attempt=1 max_attempts=5 until "$@"; do - (( attempt >= 5 )) && return 1 + if (( attempt >= max_attempts )); then + echo "Command failed after ${max_attempts} attempts: $*" >&2 + return 1 + fi + echo "Retrying failed command in $((attempt * 2)) seconds: $*" >&2 sleep "$((attempt * 2))" attempt=$((attempt + 1)) done @@ -136,39 +140,75 @@ jobs: get_httpd_digest() { local output digest - output="$(docker buildx imagetools inspect httpd:alpine 2>&1)" || { echo "$output" >&2; return 1; } - digest="$(printf '%s\n' "$output" | awk '$1 == "Digest:" {print $2; exit}')" - [[ -n "$digest" ]] || return 1 + output="$(docker buildx imagetools inspect httpd:alpine 2>&1)" || { + echo "$output" >&2 + return 1 + } + digest="$(awk '$1 == "Digest:" {print $2; exit}' <<<"$output")" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { + echo 'Could not resolve a valid httpd:alpine digest.' >&2 + return 1 + } printf '%s\n' "$digest" } get_scriptomatic_sha() { - git ls-remote https://github.com/infocyph/Scriptomatic.git refs/heads/main | awk 'NF {print $1; exit}' + local output sha + output="$(git ls-remote https://github.com/infocyph/Scriptomatic.git refs/heads/main 2>&1)" || { + echo "$output" >&2 + return 1 + } + sha="$(awk 'NF {print $1; exit}' <<<"$output")" + [[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { + echo 'Could not resolve a valid Scriptomatic main revision.' >&2 + return 1 + } + printf '%s\n' "$sha" } get_toolset_release() { - gh api repos/infocyph/Toolset/releases/latest --jq '.tag_name // empty' + local tag + tag="$(gh api repos/infocyph/Toolset/releases/latest --jq '.tag_name // empty')" || return 1 + [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?(-rc\.[0-9]+)?$ ]] || { + echo "Could not resolve a valid latest Toolset release: $tag" >&2 + return 1 + } + printf '%s\n' "$tag" } get_installer_sha() { - local file + local release="$1" file checksum_output sha file="$(mktemp)" - curl --fail --silent --show-error --location \ + if ! curl --fail --silent --show-error --location \ --retry 5 --retry-all-errors --retry-delay 2 \ --connect-timeout 15 --max-time 120 \ - https://github.com/infocyph/Toolset/releases/latest/download/install.sh \ - -o "$file" - test -s "$file" - sha256sum "$file" | awk '{print $1}' + "https://github.com/infocyph/Toolset/releases/download/${release}/install.sh" \ + -o "$file"; then + rm -f "$file" + return 1 + fi + if [[ ! -s "$file" ]]; then + echo "Toolset installer is empty for release $release." >&2 + rm -f "$file" + return 1 + fi + checksum_output="$(sha256sum "$file")" || { + rm -f "$file" + return 1 + } rm -f "$file" + sha="${checksum_output%% *}" + [[ "$sha" =~ ^[0-9a-f]{64}$ ]] || { + echo "Could not calculate Toolset installer checksum for $release." >&2 + return 1 + } + printf '%s\n' "$sha" } HTTPD_ALPINE_DIGEST="$(retry get_httpd_digest)" SCRIPTOMATIC_MAIN_SHA="$(retry get_scriptomatic_sha)" TOOLSET_RELEASE="$(retry get_toolset_release)" - TOOLSET_INSTALLER_SHA256="$(retry get_installer_sha)" - - [[ -n "$HTTPD_ALPINE_DIGEST" && -n "$SCRIPTOMATIC_MAIN_SHA" && -n "$TOOLSET_RELEASE" && -n "$TOOLSET_INSTALLER_SHA256" ]] + TOOLSET_INSTALLER_SHA256="$(retry get_installer_sha "$TOOLSET_RELEASE")" { echo "HTTPD_ALPINE_DIGEST=$HTTPD_ALPINE_DIGEST" @@ -177,6 +217,15 @@ jobs: echo "TOOLSET_INSTALLER_SHA256=$TOOLSET_INSTALLER_SHA256" } >> "$GITHUB_ENV" + { + echo '## Upstream snapshot' + echo + echo "- httpd:alpine: \`$HTTPD_ALPINE_DIGEST\`" + echo "- Scriptomatic main: \`$SCRIPTOMATIC_MAIN_SHA\`" + echo "- Toolset release: \`$TOOLSET_RELEASE\`" + echo "- Toolset installer SHA-256: \`$TOOLSET_INSTALLER_SHA256\`" + } >> "$GITHUB_STEP_SUMMARY" + - name: Build fresh amd64 release candidate uses: docker/build-push-action@v7 with: @@ -187,6 +236,11 @@ jobs: no-cache: true push: false tags: infocyph/apache:publish-candidate + build-args: | + HTTPD_ALPINE_REF=httpd:alpine@${{ env.HTTPD_ALPINE_DIGEST }} + SCRIPTOMATIC_REF=${{ env.SCRIPTOMATIC_MAIN_SHA }} + TOOLSET_RELEASE=${{ env.TOOLSET_RELEASE }} + TOOLSET_INSTALLER_SHA256=${{ env.TOOLSET_INSTALLER_SHA256 }} cache-to: type=gha,scope=apache-publish-amd64,mode=max - name: Record candidate runtime resolution @@ -229,6 +283,11 @@ jobs: no-cache: true push: false tags: infocyph/apache:publish-candidate-arm64 + build-args: | + HTTPD_ALPINE_REF=httpd:alpine@${{ env.HTTPD_ALPINE_DIGEST }} + SCRIPTOMATIC_REF=${{ env.SCRIPTOMATIC_MAIN_SHA }} + TOOLSET_RELEASE=${{ env.TOOLSET_RELEASE }} + TOOLSET_INSTALLER_SHA256=${{ env.TOOLSET_INSTALLER_SHA256 }} cache-to: type=gha,scope=apache-publish-arm64,mode=max - name: Final arm64 release-candidate gate @@ -243,29 +302,6 @@ jobs: chromacat --version ' - - name: Revalidate rolling upstreams before publish - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - current_httpd="$(docker buildx imagetools inspect httpd:alpine | awk '$1 == "Digest:" {print $2; exit}')" - current_scriptomatic="$(git ls-remote https://github.com/infocyph/Scriptomatic.git refs/heads/main | awk 'NF {print $1; exit}')" - current_toolset="$(gh api repos/infocyph/Toolset/releases/latest --jq '.tag_name // empty')" - file="$(mktemp)" - trap 'rm -f "$file"' EXIT - curl --fail --silent --show-error --location \ - --retry 5 --retry-all-errors --retry-delay 2 \ - --connect-timeout 15 --max-time 120 \ - https://github.com/infocyph/Toolset/releases/latest/download/install.sh \ - -o "$file" - current_installer="$(sha256sum "$file" | awk '{print $1}')" - - test "$current_httpd" = "$HTTPD_ALPINE_DIGEST" - test "$current_scriptomatic" = "$SCRIPTOMATIC_MAIN_SHA" - test "$current_toolset" = "$TOOLSET_RELEASE" - test "$current_installer" = "$TOOLSET_INSTALLER_SHA256" - - name: Log in to Docker Hub uses: docker/login-action@v4 with: @@ -331,6 +367,11 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + HTTPD_ALPINE_REF=httpd:alpine@${{ env.HTTPD_ALPINE_DIGEST }} + SCRIPTOMATIC_REF=${{ env.SCRIPTOMATIC_MAIN_SHA }} + TOOLSET_RELEASE=${{ env.TOOLSET_RELEASE }} + TOOLSET_INSTALLER_SHA256=${{ env.TOOLSET_INSTALLER_SHA256 }} cache-from: | type=gha,scope=apache-publish-amd64 type=gha,scope=apache-publish-arm64 @@ -362,8 +403,8 @@ jobs: local ref="$1" output digest for _ in $(seq 1 12); do output="$(docker buildx imagetools inspect "$ref" 2>&1 || true)" - digest="$(printf '%s\n' "$output" | awk '/^Digest:/ {print $2; exit}')" - if [[ -n "$digest" ]]; then + digest="$(awk '/^Digest:/ {print $2; exit}' <<<"$output")" + if [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then printf '%s\n' "$digest" return 0 fi diff --git a/Dockerfile b/Dockerfile index 0a0dabb..f112076 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -FROM httpd:alpine +ARG HTTPD_ALPINE_REF=httpd:alpine +FROM ${HTTPD_ALPINE_REF} LABEL org.opencontainers.image.source="https://github.com/infocyph/docker-apache" LABEL org.opencontainers.image.description="Hardened LocalDevStack Apache backend with PHP-FPM, TLS and HTTP/2 support" @@ -6,6 +7,9 @@ LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.authors="infocyph,abmmhasan" ARG TZ=Asia/Dhaka +ARG SCRIPTOMATIC_REF=main +ARG TOOLSET_RELEASE=latest +ARG TOOLSET_INSTALLER_SHA256= ENV APACHE_LOG_DIR=/var/log/apache2 \ SERVER_NAME=localhost \ @@ -39,17 +43,29 @@ COPY scripts/healthcheck.sh /usr/local/bin/healthcheck RUN set -eux; \ curl -fsSL --retry 3 --retry-all-errors --retry-delay 1 \ --connect-timeout 10 --max-time 120 \ - "https://raw.githubusercontent.com/infocyph/Scriptomatic/main/bash/banner.sh" \ + "https://raw.githubusercontent.com/infocyph/Scriptomatic/${SCRIPTOMATIC_REF}/bash/banner.sh" \ -o /usr/local/bin/show-banner; \ test -s /usr/local/bin/show-banner; \ bash -n /usr/local/bin/show-banner; \ + if [ "$TOOLSET_RELEASE" = latest ]; then \ + toolset_installer_url="https://github.com/infocyph/Toolset/releases/latest/download/install.sh"; \ + else \ + toolset_installer_url="https://github.com/infocyph/Toolset/releases/download/${TOOLSET_RELEASE}/install.sh"; \ + fi; \ curl -fsSL --retry 3 --retry-all-errors --retry-delay 1 \ --connect-timeout 10 --max-time 120 \ - "https://github.com/infocyph/Toolset/releases/latest/download/install.sh" \ + "$toolset_installer_url" \ -o /tmp/toolset-install.sh; \ test -s /tmp/toolset-install.sh; \ + if [ -n "$TOOLSET_INSTALLER_SHA256" ]; then \ + printf '%s %s\n' "$TOOLSET_INSTALLER_SHA256" /tmp/toolset-install.sh | sha256sum -c -; \ + fi; \ bash -n /tmp/toolset-install.sh; \ - bash /tmp/toolset-install.sh --prefix /usr/local/bin chromacat; \ + if [ "$TOOLSET_RELEASE" = latest ]; then \ + bash /tmp/toolset-install.sh --latest --prefix /usr/local/bin chromacat; \ + else \ + bash /tmp/toolset-install.sh --release "$TOOLSET_RELEASE" --prefix /usr/local/bin chromacat; \ + fi; \ chromacat --version; \ rm -f /tmp/toolset-install.sh; \ chmod +x \ diff --git a/tests/release-contract.sh b/tests/release-contract.sh index 1d4f5ca..1e6bb73 100755 --- a/tests/release-contract.sh +++ b/tests/release-contract.sh @@ -16,6 +16,14 @@ for contract in \ "releases/tags/\${MANUAL_RELEASE_TAG}" \ 'PUBLISH_RELEASE_TAG' \ 'Enforce immutable release tags' \ + 'Snapshot rolling upstream inputs' \ + 'retry get_httpd_digest' \ + 'retry get_scriptomatic_sha' \ + 'retry get_toolset_release' \ + "HTTPD_ALPINE_REF=httpd:alpine@\${{ env.HTTPD_ALPINE_DIGEST }}" \ + "SCRIPTOMATIC_REF=\${{ env.SCRIPTOMATIC_MAIN_SHA }}" \ + "TOOLSET_RELEASE=\${{ env.TOOLSET_RELEASE }}" \ + "TOOLSET_INSTALLER_SHA256=\${{ env.TOOLSET_INSTALLER_SHA256 }}" \ 'linux/amd64,linux/arm64' \ 'provenance: mode=max' \ 'sbom: true'; do @@ -25,6 +33,35 @@ done grep -Fq "cron: '0 0 * * 0'" "$workflow" grep -Fq 'types: [published]' "$workflow" +for pinned_contract in \ + "HTTPD_ALPINE_REF=httpd:alpine@\${{ env.HTTPD_ALPINE_DIGEST }}" \ + "SCRIPTOMATIC_REF=\${{ env.SCRIPTOMATIC_MAIN_SHA }}" \ + "TOOLSET_RELEASE=\${{ env.TOOLSET_RELEASE }}" \ + "TOOLSET_INSTALLER_SHA256=\${{ env.TOOLSET_INSTALLER_SHA256 }}"; do + pinned_builds="$(grep -cF "$pinned_contract" "$workflow")" + if [[ "$pinned_builds" -ne 3 ]]; then + echo "Expected all three publish builds to use pinned input: $pinned_contract; found $pinned_builds." >&2 + exit 1 + fi +done + +if grep -Fq 'Revalidate rolling upstreams before publish' "$workflow"; then + echo 'Publish workflow must not re-resolve mutable upstreams after immutable snapshotting.' >&2 + exit 1 +fi +if grep -Fq 'imagetools inspect httpd:alpine | awk' "$workflow"; then + echo 'Unsafe pipefail-sensitive httpd digest pipeline detected.' >&2 + exit 1 +fi +if grep -Fq 'Scriptomatic.git refs/heads/main | awk' "$workflow"; then + echo 'Unsafe pipefail-sensitive Scriptomatic revision pipeline detected.' >&2 + exit 1 +fi +if grep -Fq 'Toolset/releases/latest/download/install.sh' "$workflow"; then + echo 'Publish workflow must checksum an exact Toolset release installer.' >&2 + exit 1 +fi + if grep -Fq 'actions/checkout@v4' "$workflow"; then echo 'Legacy checkout action detected.' >&2 exit 1 diff --git a/tests/static.sh b/tests/static.sh index e614061..f469c87 100755 --- a/tests/static.sh +++ b/tests/static.sh @@ -19,7 +19,10 @@ done shellcheck scripts/*.sh tests/*.sh -grep -Fq 'FROM httpd:alpine' Dockerfile +grep -Fq 'ARG HTTPD_ALPINE_REF=httpd:alpine' Dockerfile +grep -Fq "FROM \${HTTPD_ALPINE_REF}" Dockerfile +grep -Fq 'ARG SCRIPTOMATIC_REF=main' Dockerfile +grep -Fq 'ARG TOOLSET_RELEASE=latest' Dockerfile grep -Fq 'apk upgrade --no-cache' Dockerfile grep -Fq 'apache2-utils' Dockerfile if grep -Eq '^[[:space:]]+apache-mod-fcgid([[:space:]\\;]|$)' Dockerfile; then @@ -28,8 +31,11 @@ fi if grep -Eq '^[[:space:]]+apache2([[:space:]\\;]|$)' Dockerfile; then fail 'Alpine apache2 server package must not be installed' fi -grep -Fq 'Scriptomatic/main/bash/banner.sh' Dockerfile +grep -Fq "Scriptomatic/\${SCRIPTOMATIC_REF}/bash/banner.sh" Dockerfile grep -Fq 'Toolset/releases/latest/download/install.sh' Dockerfile +grep -Fq "Toolset/releases/download/\${TOOLSET_RELEASE}/install.sh" Dockerfile +grep -Fq -- "--release \"\$TOOLSET_RELEASE\"" Dockerfile +grep -Fq -- '--latest --prefix /usr/local/bin chromacat' Dockerfile helper_downloads="$(grep -c -- '--connect-timeout 10 --max-time 120' Dockerfile)" if [[ "$helper_downloads" -ne 2 ]]; then fail 'Both helper downloads must use bounded connect and total timeouts'