Skip to content

Commit 464a3e1

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 464a3e1

File tree

6 files changed

+406
-0
lines changed

6 files changed

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