Skip to content

Commit 2a1a281

Browse files
committed
Add weekly Docker image refresh workflow for security updates
Implement automatic refresh of Docker images to include latest base image security updates without rebuilding binaries: - Add docker/Dockerfile.refresh for lightweight image rebuilds - Add .github/workflows/docker-refresh.yml scheduled weekly workflow - Add OCI labels (base.name, base.digest) for change detection - Add date-suffixed tags (e.g., 1.6.0-20260204) for all builds - Add automatic 'latest' tag detection (only for newest semver release) - Skip refresh if base image unchanged or source image doesn't exist The refresh workflow: - Runs weekly on main and release-* branches - Extracts version from Cargo.toml to find latest patch release - Compares base image digests to detect changes - Pushes to both GHCR and DockerHub Closes #4329
1 parent 392e9c5 commit 2a1a281

File tree

6 files changed

+421
-0
lines changed

6 files changed

+421
-0
lines changed

.github/workflows/docker-push-release.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,29 @@ jobs:
9898
echo Version: ${VERSION}
9999
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
100100
101+
- name: Check if this is the latest version
102+
id: is-latest
103+
env:
104+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
105+
run: |
106+
CURRENT_VERSION="${{ steps.version.outputs.VERSION }}"
107+
108+
# Get all release tags and find the highest version (excluding prereleases)
109+
LATEST_VERSION=$(gh api "repos/${{ github.repository }}/git/refs/tags" --jq \
110+
'[.[] | .ref | select(startswith("refs/tags/v")) | ltrimstr("refs/tags/v") | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))] | sort_by(split(".") | map(tonumber)) | last // empty')
111+
112+
echo "Current version: ${CURRENT_VERSION}"
113+
echo "Latest version overall: ${LATEST_VERSION}"
114+
115+
# Compare versions - current should be >= latest to update the latest tag
116+
if [[ "$CURRENT_VERSION" == "$LATEST_VERSION" ]] || [[ "$(printf '%s\n' "$CURRENT_VERSION" "$LATEST_VERSION" | sort -V | tail -1)" == "$CURRENT_VERSION" ]]; then
117+
echo "is_latest=true" >> $GITHUB_OUTPUT
118+
echo "This is the latest version - will update latest tag"
119+
else
120+
echo "is_latest=false" >> $GITHUB_OUTPUT
121+
echo "This is not the latest version - will not update latest tag"
122+
fi
123+
101124
- name: Extract metadata (tags, labels) for Docker
102125
id: meta
103126
uses: docker/metadata-action@v5
@@ -106,6 +129,8 @@ jobs:
106129
${{ env.GHCR_REGISTRY }}/${{ env.REPOSITORY_OWNER }}/${{ env.IMAGE_NAME }}
107130
${{ format('docker.io/{0}/{1}', env.REPOSITORY_OWNER, env.IMAGE_NAME) }}
108131
132+
flavor: |
133+
latest=${{ steps.is-latest.outputs.is_latest == 'true' && 'true' || 'false' }}
109134
tags: |
110135
type=semver,pattern={{version}},value=${{ steps.version.outputs.VERSION }}
111136
type=semver,pattern={{major}}.{{minor}},value=${{ steps.version.outputs.VERSION }}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
name: Refresh Docker Image
2+
3+
# This workflow refreshes existing Restate Docker images with updated base images
4+
# to include the latest security updates WITHOUT rebuilding the Rust binaries.
5+
#
6+
# How it works:
7+
# 1. Determines the current minor version from Cargo.toml (e.g., "1.6")
8+
# 2. Finds the latest patch release for that minor series (e.g., "1.6.2")
9+
# 3. Checks if the base image (debian:trixie-slim) has changed since the last build
10+
# 4. If changed, rebuilds the image using Dockerfile.refresh (copies binaries from
11+
# the existing image onto a fresh base image)
12+
# 5. Pushes with date-suffixed tags (e.g., 1.6.2-20260205) and updates version tags
13+
#
14+
# This allows security patches in the base OS to be deployed without waiting for
15+
# a new Restate release. The workflow is idempotent - it skips if the base image
16+
# hasn't changed (tracked via OCI labels: org.opencontainers.image.base.digest).
17+
18+
on:
19+
schedule:
20+
# Run weekly on Mondays at 00:00 UTC
21+
- cron: "0 0 * * 1"
22+
workflow_dispatch:
23+
24+
env:
25+
GHCR_REGISTRY: ghcr.io
26+
REPOSITORY_OWNER: ${{ github.repository_owner }}
27+
IMAGE_NAME: restate
28+
29+
jobs:
30+
refresh:
31+
runs-on: ubuntu-latest
32+
steps:
33+
- name: Checkout repository
34+
uses: actions/checkout@v4
35+
36+
- name: Determine minor version from Cargo.toml
37+
id: minor-version
38+
run: |
39+
# Extract minor version (e.g., "1.6" from "1.6.1-dev")
40+
MINOR=$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"([0-9]+\.[0-9]+).*/\1/')
41+
echo "minor=${MINOR}" >> $GITHUB_OUTPUT
42+
echo "Detected minor version: ${MINOR}"
43+
44+
- name: Get latest patch version for minor series
45+
id: latest-patch
46+
env:
47+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48+
run: |
49+
MINOR="${{ steps.minor-version.outputs.minor }}"
50+
51+
# Query GitHub for tags matching this minor series and get the latest
52+
VERSION=$(gh api "repos/${{ github.repository }}/git/refs/tags" --jq \
53+
"[.[] | .ref | select(startswith(\"refs/tags/v${MINOR}.\"))] | map(ltrimstr(\"refs/tags/v\")) | sort_by(split(\".\") | map(tonumber)) | last // empty")
54+
55+
if [[ -z "$VERSION" ]]; then
56+
echo "No released version found for ${MINOR}.x series"
57+
echo "skip=true" >> $GITHUB_OUTPUT
58+
else
59+
echo "version=${VERSION}" >> $GITHUB_OUTPUT
60+
echo "skip=false" >> $GITHUB_OUTPUT
61+
echo "Latest patch version: ${VERSION}"
62+
fi
63+
64+
- name: Check if source image exists
65+
if: steps.latest-patch.outputs.skip != 'true'
66+
id: source-check
67+
run: |
68+
VERSION="${{ steps.latest-patch.outputs.version }}"
69+
SOURCE_IMAGE="${{ env.GHCR_REGISTRY }}/${{ env.REPOSITORY_OWNER }}/${{ env.IMAGE_NAME }}:${VERSION}"
70+
71+
if docker manifest inspect "${SOURCE_IMAGE}" > /dev/null 2>&1; then
72+
echo "skip=false" >> $GITHUB_OUTPUT
73+
echo "image=${SOURCE_IMAGE}" >> $GITHUB_OUTPUT
74+
echo "Source image exists: ${SOURCE_IMAGE}"
75+
else
76+
echo "skip=true" >> $GITHUB_OUTPUT
77+
echo "Source image not found: ${SOURCE_IMAGE}"
78+
fi
79+
80+
- name: Determine base image
81+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true'
82+
id: base-image
83+
run: |
84+
SOURCE_IMAGE="${{ steps.source-check.outputs.image }}"
85+
86+
# Try to get base image name from OCI label
87+
BASE_NAME=$(docker buildx imagetools inspect "${SOURCE_IMAGE}" --raw | \
88+
jq -r '.manifests[0].annotations["org.opencontainers.image.base.name"] // empty' 2>/dev/null || true)
89+
90+
# Fallback: parse from Dockerfile
91+
if [[ -z "$BASE_NAME" ]]; then
92+
BASE_NAME=$(grep 'FROM.*AS runtime' docker/Dockerfile | head -1 | awk '{print $2}')
93+
echo "Base image from Dockerfile: ${BASE_NAME}"
94+
else
95+
echo "Base image from OCI label: ${BASE_NAME}"
96+
fi
97+
98+
echo "name=${BASE_NAME}" >> $GITHUB_OUTPUT
99+
100+
- name: Check if base image has changed
101+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true'
102+
id: base-changed
103+
run: |
104+
SOURCE_IMAGE="${{ steps.source-check.outputs.image }}"
105+
BASE_IMAGE="${{ steps.base-image.outputs.name }}"
106+
107+
# Get current base image digest
108+
CURRENT_BASE_DIGEST=$(docker buildx imagetools inspect "${BASE_IMAGE}" --raw | \
109+
jq -r '.manifests[] | select(.platform.architecture == "amd64") | .digest' 2>/dev/null | head -1 || true)
110+
111+
echo "Current base image digest: ${CURRENT_BASE_DIGEST}"
112+
113+
# Try to get the base digest from the existing image's OCI label
114+
EXISTING_BASE_DIGEST=$(docker buildx imagetools inspect "${SOURCE_IMAGE}" --raw | \
115+
jq -r '.manifests[0].annotations["org.opencontainers.image.base.digest"] // empty' 2>/dev/null || true)
116+
117+
echo "Existing base image digest: ${EXISTING_BASE_DIGEST}"
118+
119+
if [[ -n "$EXISTING_BASE_DIGEST" && "$CURRENT_BASE_DIGEST" == "$EXISTING_BASE_DIGEST" ]]; then
120+
echo "skip=true" >> $GITHUB_OUTPUT
121+
echo "Base image unchanged - skipping refresh"
122+
else
123+
echo "skip=false" >> $GITHUB_OUTPUT
124+
echo "current_digest=${CURRENT_BASE_DIGEST}" >> $GITHUB_OUTPUT
125+
if [[ -z "$EXISTING_BASE_DIGEST" ]]; then
126+
echo "No existing base digest label found - will rebuild"
127+
else
128+
echo "Base image changed - will rebuild"
129+
fi
130+
fi
131+
132+
- name: Set up Docker containerd snapshotter
133+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true' && steps.base-changed.outputs.skip != 'true'
134+
uses: docker/setup-docker-action@v4
135+
with:
136+
daemon-config: |
137+
{
138+
"features": {
139+
"containerd-snapshotter": true
140+
}
141+
}
142+
143+
- name: Set up Docker Buildx
144+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true' && steps.base-changed.outputs.skip != 'true'
145+
uses: docker/setup-buildx-action@v3
146+
147+
- name: Log into GitHub container registry
148+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true' && steps.base-changed.outputs.skip != 'true'
149+
uses: docker/login-action@v3
150+
with:
151+
registry: ${{ env.GHCR_REGISTRY }}
152+
username: ${{ github.actor }}
153+
password: ${{ secrets.GITHUB_TOKEN }}
154+
155+
- name: Log into DockerHub
156+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true' && steps.base-changed.outputs.skip != 'true'
157+
uses: docker/login-action@v3
158+
with:
159+
username: ${{ secrets.DOCKER_USERNAME }}
160+
password: ${{ secrets.DOCKER_PASSWORD }}
161+
162+
- name: Determine if this is the latest version (for latest tag)
163+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true' && steps.base-changed.outputs.skip != 'true'
164+
id: is-latest
165+
env:
166+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
167+
run: |
168+
CURRENT_VERSION="${{ steps.latest-patch.outputs.version }}"
169+
170+
# Get all release tags and find the highest version
171+
LATEST_VERSION=$(gh api "repos/${{ github.repository }}/git/refs/tags" --jq \
172+
'[.[] | .ref | select(startswith("refs/tags/v")) | ltrimstr("refs/tags/v") | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))] | sort_by(split(".") | map(tonumber)) | last // empty')
173+
174+
echo "Current version: ${CURRENT_VERSION}"
175+
echo "Latest version overall: ${LATEST_VERSION}"
176+
177+
if [[ "$CURRENT_VERSION" == "$LATEST_VERSION" ]]; then
178+
echo "is_latest=true" >> $GITHUB_OUTPUT
179+
echo "This is the latest version - will update latest tag"
180+
else
181+
echo "is_latest=false" >> $GITHUB_OUTPUT
182+
echo "This is not the latest version - will not update latest tag"
183+
fi
184+
185+
- name: Extract metadata (tags, labels) for Docker
186+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true' && steps.base-changed.outputs.skip != 'true'
187+
id: meta
188+
uses: docker/metadata-action@v5
189+
with:
190+
images: |
191+
${{ env.GHCR_REGISTRY }}/${{ env.REPOSITORY_OWNER }}/${{ env.IMAGE_NAME }}
192+
docker.io/${{ env.REPOSITORY_OWNER }}/${{ env.IMAGE_NAME }}
193+
flavor: |
194+
latest=${{ steps.is-latest.outputs.is_latest == 'true' && 'true' || 'false' }}
195+
tags: |
196+
type=semver,pattern={{version}},value=${{ steps.latest-patch.outputs.version }}
197+
type=semver,pattern={{version}}-{{date 'YYYYMMDD'}},value=${{ steps.latest-patch.outputs.version }}
198+
type=semver,pattern={{major}}.{{minor}},value=${{ steps.latest-patch.outputs.version }}
199+
labels: |
200+
org.opencontainers.image.base.name=${{ steps.base-image.outputs.name }}
201+
org.opencontainers.image.base.digest=${{ steps.base-changed.outputs.current_digest }}
202+
203+
- name: Build and push refreshed Docker image
204+
if: steps.latest-patch.outputs.skip != 'true' && steps.source-check.outputs.skip != 'true' && steps.base-changed.outputs.skip != 'true'
205+
uses: docker/build-push-action@v6
206+
with:
207+
context: .
208+
file: docker/Dockerfile.refresh
209+
platforms: linux/amd64,linux/arm64
210+
push: true
211+
build-args: |
212+
SOURCE_IMAGE=${{ steps.source-check.outputs.image }}
213+
BASE_IMAGE=${{ steps.base-image.outputs.name }}
214+
tags: ${{ steps.meta.outputs.tags }}
215+
labels: ${{ steps.meta.outputs.labels }}
216+
217+
- name: Summary
218+
run: |
219+
if [[ "${{ steps.latest-patch.outputs.skip }}" == "true" ]]; then
220+
echo "### Skipped: No released version found for ${{ steps.minor-version.outputs.minor }}.x series" >> $GITHUB_STEP_SUMMARY
221+
elif [[ "${{ steps.source-check.outputs.skip }}" == "true" ]]; then
222+
echo "### Skipped: Source image not found" >> $GITHUB_STEP_SUMMARY
223+
elif [[ "${{ steps.base-changed.outputs.skip }}" == "true" ]]; then
224+
echo "### Skipped: Base image unchanged" >> $GITHUB_STEP_SUMMARY
225+
else
226+
echo "### Successfully refreshed Docker image" >> $GITHUB_STEP_SUMMARY
227+
echo "" >> $GITHUB_STEP_SUMMARY
228+
echo "- **Version**: ${{ steps.latest-patch.outputs.version }}" >> $GITHUB_STEP_SUMMARY
229+
echo "- **Base image**: ${{ steps.base-image.outputs.name }}" >> $GITHUB_STEP_SUMMARY
230+
echo "- **Updated latest tag**: ${{ steps.is-latest.outputs.is_latest }}" >> $GITHUB_STEP_SUMMARY
231+
echo "" >> $GITHUB_STEP_SUMMARY
232+
echo "**Tags pushed:**" >> $GITHUB_STEP_SUMMARY
233+
echo '```' >> $GITHUB_STEP_SUMMARY
234+
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
235+
echo '```' >> $GITHUB_STEP_SUMMARY
236+
fi

.github/workflows/docker.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,49 @@ jobs:
155155
run: |
156156
echo "IMAGE_NAME=${GITHUB_REPOSITORY#$GITHUB_REPOSITORY_OWNER/}" >> $GITHUB_ENV
157157
158+
- name: Extract base image from Dockerfile
159+
id: base-image
160+
run: |
161+
BASE_IMAGE=$(grep 'FROM.*AS runtime' docker/Dockerfile | head -1 | awk '{print $2}')
162+
echo "name=${BASE_IMAGE}" >> $GITHUB_OUTPUT
163+
echo "Base image: ${BASE_IMAGE}"
164+
165+
# Get the base image digest for the amd64 platform
166+
BASE_DIGEST=$(docker buildx imagetools inspect "${BASE_IMAGE}" --raw | \
167+
jq -r '.manifests[] | select(.platform.architecture == "amd64") | .digest' 2>/dev/null | head -1 || true)
168+
echo "digest=${BASE_DIGEST}" >> $GITHUB_OUTPUT
169+
echo "Base image digest: ${BASE_DIGEST}"
170+
171+
- name: Check if this is the latest version
172+
id: is-latest
173+
env:
174+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
175+
run: |
176+
# Only set 'latest' tag for semver tags that are the latest (or newer) version
177+
if [[ ! "${{ github.ref }}" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
178+
echo "is_latest=false" >> $GITHUB_OUTPUT
179+
echo "Not a semver tag - will not update latest tag"
180+
exit 0
181+
fi
182+
183+
CURRENT_VERSION="${GITHUB_REF#refs/tags/v}"
184+
185+
# Get all release tags and find the highest version (excluding prereleases)
186+
LATEST_VERSION=$(gh api "repos/${{ github.repository }}/git/refs/tags" --jq \
187+
'[.[] | .ref | select(startswith("refs/tags/v")) | ltrimstr("refs/tags/v") | select(test("^[0-9]+\\.[0-9]+\\.[0-9]+$"))] | sort_by(split(".") | map(tonumber)) | last // empty')
188+
189+
echo "Current version: ${CURRENT_VERSION}"
190+
echo "Latest version overall: ${LATEST_VERSION}"
191+
192+
# Compare versions - current should be >= latest to update the latest tag
193+
if [[ "$CURRENT_VERSION" == "$LATEST_VERSION" ]] || [[ "$(printf '%s\n' "$CURRENT_VERSION" "$LATEST_VERSION" | sort -V | tail -1)" == "$CURRENT_VERSION" ]]; then
194+
echo "is_latest=true" >> $GITHUB_OUTPUT
195+
echo "This is the latest version - will update latest tag"
196+
else
197+
echo "is_latest=false" >> $GITHUB_OUTPUT
198+
echo "This is not the latest version - will not update latest tag"
199+
fi
200+
158201
- name: Extract metadata (tags, labels) for Docker
159202
id: meta
160203
uses: docker/metadata-action@v5
@@ -164,11 +207,16 @@ jobs:
164207
${{ inputs.pushToDockerHub && format('docker.io/{0}/{1}', env.REPOSITORY_OWNER, env.IMAGE_NAME) || '' }}
165208
166209
flavor: |
210+
latest=${{ (steps.is-latest.outputs.is_latest == 'true') && 'true' || 'false' }}
167211
${{ inputs.debug && 'prefix=debug-,onlatest=true' || '' }}
168212
tags: |
169213
type=ref,event=branch
170214
type=semver,pattern={{version}}
215+
type=semver,pattern={{version}}-{{date 'YYYYMMDD'}}
171216
type=semver,pattern={{major}}.{{minor}}
217+
labels: |
218+
org.opencontainers.image.base.name=${{ steps.base-image.outputs.name }}
219+
org.opencontainers.image.base.digest=${{ steps.base-image.outputs.digest }}
172220
173221
- name: Build${{(inputs.uploadImageAsTarball == '' || github.ref == 'refs/heads/main') && ' and push ' || ' '}}Docker image
174222
id: build

docker/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ RUN cp docker/scripts/download-restate-debug-symbols.sh target/ && \
104104
FROM upload-$UPLOAD_DEBUGINFO AS upload
105105

106106
# We do not need the Rust toolchain to run the server binary!
107+
# NOTE: When modifying this runtime stage, also update docker/Dockerfile.refresh to keep them in sync.
107108
FROM debian:trixie-slim AS runtime
108109
# useful for health checks
109110
RUN apt-get update && apt-get install --no-install-recommends -y jq curl && rm -rf /var/lib/apt/lists/*

docker/Dockerfile.refresh

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright (c) 2023 - 2026 Restate Software, Inc., Restate GmbH.
2+
# All rights reserved.
3+
#
4+
# Use of this software is governed by the Business Source License
5+
# included in the LICENSE file.
6+
#
7+
# As of the Change Date specified in that file, in accordance with
8+
# the Business Source License, use of this software will be governed
9+
# by the Apache License, Version 2.0.
10+
11+
# Refreshes an existing Restate image with an updated base image to include
12+
# the latest security updates without rebuilding the binaries.
13+
#
14+
# Usage:
15+
# docker buildx build \
16+
# --build-arg SOURCE_IMAGE=ghcr.io/restatedev/restate:1.6.0 \
17+
# --build-arg BASE_IMAGE=debian:trixie-slim \
18+
# -f docker/Dockerfile.refresh .
19+
20+
ARG SOURCE_IMAGE
21+
ARG BASE_IMAGE
22+
23+
FROM ${SOURCE_IMAGE} AS source
24+
25+
FROM ${BASE_IMAGE} AS runtime
26+
RUN apt-get update && apt-get install --no-install-recommends -y jq curl && rm -rf /var/lib/apt/lists/*
27+
# Create symlink for debug symbols fallback path (for read-only /usr/local/bin scenarios)
28+
RUN mkdir -p /usr/local/bin/.debug && \
29+
ln -s /tmp/.debug/restate-server.debug /usr/local/bin/.debug/restate-server.debug
30+
COPY --from=source /NOTICE /NOTICE
31+
COPY --from=source /LICENSE /LICENSE
32+
COPY --from=source /etc/ssl /etc/ssl
33+
COPY --from=source /usr/local/bin/restate-server /usr/local/bin/
34+
COPY --from=source /usr/local/bin/restatectl /usr/local/bin/
35+
COPY --from=source /usr/local/bin/restate /usr/local/bin/
36+
COPY --from=source /usr/local/bin/download-restate-debug-symbols.sh /usr/local/bin/
37+
WORKDIR /
38+
ENTRYPOINT ["/usr/local/bin/restate-server"]

0 commit comments

Comments
 (0)