Skip to content

Commit d2e55bb

Browse files
authored
chore: establish versioned image release policy
Build immutable prerelease and source tags from an exact main-reachable SHA, then promote an independently accepted digest without rebuilding.
1 parent 5e29d94 commit d2e55bb

8 files changed

Lines changed: 739 additions & 14 deletions

File tree

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
name: Promote Image
2+
run-name: Promote ${{ inputs.source_version }} to ${{ inputs.stable_version }}
3+
4+
on:
5+
workflow_dispatch:
6+
inputs:
7+
source_sha:
8+
description: Full 40-character source commit SHA of the accepted image
9+
required: true
10+
type: string
11+
source_version:
12+
description: Existing immutable prerelease tag
13+
required: true
14+
type: string
15+
source_digest:
16+
description: Existing accepted multi-architecture image digest (sha256:...)
17+
required: true
18+
type: string
19+
stable_version:
20+
description: Stable version to create or verify (vX.Y.Z)
21+
required: true
22+
type: string
23+
acceptance_reference:
24+
description: Task, report, or URL containing independent acceptance evidence
25+
required: true
26+
type: string
27+
28+
concurrency:
29+
group: myduckserver-image-publish
30+
cancel-in-progress: false
31+
32+
env:
33+
IMAGE: docker.io/apecloud/myduckserver
34+
SOURCE_REPOSITORY: https://github.com/apecloud/myduckserver
35+
36+
jobs:
37+
promote-image:
38+
runs-on: ubuntu-latest
39+
permissions:
40+
contents: read
41+
steps:
42+
- name: Validate promotion inputs
43+
id: metadata
44+
shell: bash
45+
env:
46+
SOURCE_SHA: ${{ inputs.source_sha }}
47+
SOURCE_VERSION: ${{ inputs.source_version }}
48+
SOURCE_DIGEST: ${{ inputs.source_digest }}
49+
STABLE_VERSION: ${{ inputs.stable_version }}
50+
ACCEPTANCE_REFERENCE: ${{ inputs.acceptance_reference }}
51+
run: |
52+
set -euo pipefail
53+
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
54+
echo "promotion workflow must be dispatched from the main branch" >&2
55+
exit 1
56+
fi
57+
if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then
58+
echo "source_sha must be a lowercase, full 40-character commit SHA" >&2
59+
exit 1
60+
fi
61+
if [[ ! "$SOURCE_VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-(dev\.[0-9]{8}\.[1-9][0-9]*|rc\.[1-9][0-9]*)$ ]]; then
62+
echo "source_version must be an immutable dev or rc version" >&2
63+
exit 1
64+
fi
65+
if [[ ! "$STABLE_VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
66+
echo "stable_version must match vX.Y.Z" >&2
67+
exit 1
68+
fi
69+
if [[ ! "$SOURCE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
70+
echo "source_digest must be a full sha256 digest" >&2
71+
exit 1
72+
fi
73+
if [[ "${SOURCE_VERSION%%-*}" != "$STABLE_VERSION" ]]; then
74+
echo "source_version and stable_version must have the same vX.Y.Z base" >&2
75+
exit 1
76+
fi
77+
if [[ -z "${ACCEPTANCE_REFERENCE//[[:space:]]/}" ]]; then
78+
echo "acceptance_reference must contain a non-whitespace value" >&2
79+
exit 1
80+
fi
81+
if [[ "$ACCEPTANCE_REFERENCE" == *$'\n'* || "$ACCEPTANCE_REFERENCE" == *$'\r'* ]]; then
82+
echo "acceptance_reference must be a single line" >&2
83+
exit 1
84+
fi
85+
echo "sha_tag=sha-${SOURCE_SHA:0:8}" >> "$GITHUB_OUTPUT"
86+
87+
- name: Checkout release history
88+
uses: actions/checkout@v4
89+
with:
90+
ref: ${{ github.sha }}
91+
fetch-depth: 0
92+
persist-credentials: false
93+
94+
- name: Verify source is reachable from main
95+
shell: bash
96+
env:
97+
SOURCE_SHA: ${{ inputs.source_sha }}
98+
WORKFLOW_SHA: ${{ github.sha }}
99+
run: |
100+
set -euo pipefail
101+
actual_workflow_sha="$(git rev-parse HEAD)"
102+
if [[ "$actual_workflow_sha" != "$WORKFLOW_SHA" ]]; then
103+
echo "checked out workflow commit $actual_workflow_sha, expected $WORKFLOW_SHA" >&2
104+
exit 1
105+
fi
106+
if ! git cat-file -e "$SOURCE_SHA^{commit}"; then
107+
echo "source commit $SOURCE_SHA is missing from release history" >&2
108+
exit 1
109+
fi
110+
if ! git merge-base --is-ancestor "$SOURCE_SHA" "$WORKFLOW_SHA"; then
111+
echo "source_sha must be reachable from main at workflow commit $WORKFLOW_SHA" >&2
112+
exit 1
113+
fi
114+
115+
- name: Set up Docker Buildx
116+
uses: docker/setup-buildx-action@v3
117+
118+
- name: Log in to Docker Hub
119+
uses: docker/login-action@v3
120+
with:
121+
registry: docker.io
122+
username: ${{ secrets.DOCKER_REGISTRY_USER }}
123+
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
124+
125+
- name: Verify accepted source image
126+
id: source
127+
shell: bash
128+
env:
129+
SOURCE_SHA: ${{ inputs.source_sha }}
130+
SOURCE_VERSION: ${{ inputs.source_version }}
131+
SOURCE_DIGEST: ${{ inputs.source_digest }}
132+
SHA_TAG: ${{ steps.metadata.outputs.sha_tag }}
133+
STABLE_VERSION: ${{ inputs.stable_version }}
134+
run: |
135+
set -euo pipefail
136+
inspect_digest() {
137+
docker buildx imagetools inspect "$1" --format '{{json .Manifest}}' |
138+
jq -r '.digest // empty'
139+
}
140+
141+
source_version_digest="$(inspect_digest "$IMAGE:$SOURCE_VERSION")"
142+
source_sha_digest="$(inspect_digest "$IMAGE:$SHA_TAG")"
143+
if [[ "$source_version_digest" != "$SOURCE_DIGEST" || "$source_sha_digest" != "$SOURCE_DIGEST" ]]; then
144+
echo "source version, source commit tag, and accepted digest must match" >&2
145+
exit 1
146+
fi
147+
148+
docker buildx imagetools inspect "$IMAGE@$SOURCE_DIGEST" \
149+
--format '{{json .Image}}' > accepted-image.json
150+
jq -e \
151+
--arg source "$SOURCE_REPOSITORY" \
152+
--arg version "$SOURCE_VERSION" \
153+
--arg revision "$SOURCE_SHA" \
154+
'
155+
(keys | sort) == ["linux/amd64", "linux/arm64"] and
156+
all(.[];
157+
.config.Labels["org.opencontainers.image.source"] == $source and
158+
.config.Labels["org.opencontainers.image.version"] == $version and
159+
.config.Labels["org.opencontainers.image.revision"] == $revision
160+
)
161+
' accepted-image.json >/dev/null
162+
163+
stable_digest="$(inspect_digest "$IMAGE:$STABLE_VERSION" 2>/dev/null || true)"
164+
if [[ -n "$stable_digest" && "$stable_digest" != "$SOURCE_DIGEST" ]]; then
165+
echo "refusing to overwrite immutable stable tag $IMAGE:$STABLE_VERSION" >&2
166+
exit 1
167+
fi
168+
169+
previous_latest="$(inspect_digest "$IMAGE:latest" 2>/dev/null || true)"
170+
echo "stable_exists=$([[ -n "$stable_digest" ]] && echo true || echo false)" >> "$GITHUB_OUTPUT"
171+
echo "previous_latest=${previous_latest:-none}" >> "$GITHUB_OUTPUT"
172+
173+
- name: Promote accepted digest without rebuilding
174+
shell: bash
175+
env:
176+
SOURCE_DIGEST: ${{ inputs.source_digest }}
177+
STABLE_VERSION: ${{ inputs.stable_version }}
178+
STABLE_EXISTS: ${{ steps.source.outputs.stable_exists }}
179+
run: |
180+
set -euo pipefail
181+
tags=(--tag "$IMAGE:latest")
182+
if [[ "$STABLE_EXISTS" != "true" ]]; then
183+
tags+=(--tag "$IMAGE:$STABLE_VERSION")
184+
fi
185+
docker buildx imagetools create "${tags[@]}" "$IMAGE@$SOURCE_DIGEST"
186+
187+
- name: Verify promotion and record evidence
188+
shell: bash
189+
env:
190+
SOURCE_SHA: ${{ inputs.source_sha }}
191+
SOURCE_VERSION: ${{ inputs.source_version }}
192+
SOURCE_DIGEST: ${{ inputs.source_digest }}
193+
SHA_TAG: ${{ steps.metadata.outputs.sha_tag }}
194+
STABLE_VERSION: ${{ inputs.stable_version }}
195+
ACCEPTANCE_REFERENCE: ${{ inputs.acceptance_reference }}
196+
PREVIOUS_LATEST: ${{ steps.source.outputs.previous_latest }}
197+
WORKFLOW_SHA: ${{ github.sha }}
198+
run: |
199+
set -euo pipefail
200+
inspect_digest() {
201+
docker buildx imagetools inspect "$1" --format '{{json .Manifest}}' |
202+
jq -r '.digest // empty'
203+
}
204+
205+
stable_digest="$(inspect_digest "$IMAGE:$STABLE_VERSION")"
206+
latest_digest="$(inspect_digest "$IMAGE:latest")"
207+
if [[ "$stable_digest" != "$SOURCE_DIGEST" || "$latest_digest" != "$SOURCE_DIGEST" ]]; then
208+
echo "stable version and latest must point to accepted digest $SOURCE_DIGEST" >&2
209+
exit 1
210+
fi
211+
212+
docker buildx imagetools inspect "$IMAGE@$SOURCE_DIGEST" \
213+
--format '{{json .Manifest}}' > promoted-manifest.json
214+
jq -e \
215+
--arg digest "$SOURCE_DIGEST" \
216+
'
217+
.digest == $digest and
218+
(.manifests | length) == 2 and
219+
([.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64")] | length) == 1 and
220+
([.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64")] | length) == 1 and
221+
all(.manifests[]; .digest | test("^sha256:[0-9a-f]{64}$"))
222+
' promoted-manifest.json >/dev/null
223+
amd64_digest="$(
224+
jq -er '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' \
225+
promoted-manifest.json
226+
)"
227+
arm64_digest="$(
228+
jq -er '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64") | .digest' \
229+
promoted-manifest.json
230+
)"
231+
232+
jq -n \
233+
--arg image "$IMAGE" \
234+
--arg source_sha "$SOURCE_SHA" \
235+
--arg workflow_sha "$WORKFLOW_SHA" \
236+
--arg workflow_run_id "$GITHUB_RUN_ID" \
237+
--arg workflow_run_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \
238+
--arg source_version "$SOURCE_VERSION" \
239+
--arg sha_tag "$SHA_TAG" \
240+
--arg stable_version "$STABLE_VERSION" \
241+
--arg digest "$SOURCE_DIGEST" \
242+
--arg amd64_digest "$amd64_digest" \
243+
--arg arm64_digest "$arm64_digest" \
244+
--arg previous_latest "$PREVIOUS_LATEST" \
245+
--arg acceptance_reference "$ACCEPTANCE_REFERENCE" \
246+
'{
247+
image: $image,
248+
source_sha: $source_sha,
249+
workflow_sha: $workflow_sha,
250+
workflow_run_id: $workflow_run_id,
251+
workflow_run_url: $workflow_run_url,
252+
source_version: $source_version,
253+
sha_tag: $sha_tag,
254+
stable_version: $stable_version,
255+
digest: $digest,
256+
platforms: {
257+
"linux/amd64": $amd64_digest,
258+
"linux/arm64": $arm64_digest
259+
},
260+
previous_latest: $previous_latest,
261+
acceptance_reference: $acceptance_reference
262+
}' > promotion-metadata.json
263+
264+
{
265+
echo "## Promoted accepted image"
266+
echo
267+
echo "- Acceptance: $ACCEPTANCE_REFERENCE"
268+
echo "- Source: \`$SOURCE_SHA\`"
269+
echo "- Stable tag: \`$IMAGE:$STABLE_VERSION\`"
270+
echo "- Latest: \`$IMAGE:latest\`"
271+
echo "- Digest: \`$SOURCE_DIGEST\`"
272+
echo "- Previous latest: \`$PREVIOUS_LATEST\`"
273+
} >> "$GITHUB_STEP_SUMMARY"
274+
275+
- name: Upload promotion evidence
276+
uses: actions/upload-artifact@v4
277+
with:
278+
name: promotion-metadata-${{ inputs.stable_version }}
279+
path: promotion-metadata.json
280+
if-no-files-found: error

0 commit comments

Comments
 (0)