Repository navigation
223 lines (200 loc) · 10.1 KB
/
Copy pathrelease-docker.yml
File metadata and controls
223 lines (200 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
name: Release Docker Image
# A release runs on a commit CI has already passed, and only then: CI is the gate, whole. This
# workflow used to fire on the push that changed Directory.Build.props, beside CI rather than after
# it, and test with a subset of what CI checks -- so 0.17.0 was published from a commit whose
# formatting, Native AOT and API suites were red. Now every green CI run on main or a hotfix branch
# asks one question: does the version Directory.Build.props holds still need publishing? Usually
# not, and the run ends there. The bump commit answers yes; so does a later fix of a bump whose CI
# was red, which is how a failed release recovers without a manual step.
#
# A manual run is a dry run: it tests the checked-out commit, builds and probes the image, and
# publishes nothing.
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main, 'hotfix/**']
workflow_dispatch:
# Two runs of this workflow publish the same three tags, and `latest` is whichever finishes last.
# Serialising them means a re-run cannot overtake the run it was meant to replace.
concurrency:
group: release-docker
cancel-in-progress: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
gate:
name: Version not yet published
# A CI run for a pull request, or one that did not pass, releases nothing.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: read
outputs:
version: ${{ steps.version.outputs.version }}
major_minor: ${{ steps.version.outputs.major_minor }}
image: ${{ steps.version.outputs.image }}
already: ${{ steps.published.outputs.already }}
steps:
# The commit CI passed, not whatever the branch points at by now.
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Read version and prepare image name
id: version
run: |
VERSION=$(grep -oP '<Version>\K[^<]+' Directory.Build.props)
IMAGE=$(echo "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "major_minor=$(echo $VERSION | cut -d. -f1,2)" >> "$GITHUB_OUTPUT"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
echo "Detected version: $VERSION, image: $IMAGE"
# Every green CI run reaches this workflow, and most carry a version that is already out —
# without this the job below would rebuild that version from the current commit and move its
# tags onto code that was never released under that number. The NuGet release is idempotent by construction (--skip-duplicate, and its tag
# and release steps leave an existing one alone); this is the same guard for the image. It fails
# closed: a registry that cannot be asked is not taken to mean "not published".
- name: Check whether this version is already published
id: published
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
IMAGE="${{ steps.version.outputs.image }}"
VERSION="${{ steps.version.outputs.version }}"
echo "$GH_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin > /dev/null
if out=$(docker manifest inspect "$IMAGE:$VERSION" 2>&1); then
echo "already=true" >> "$GITHUB_OUTPUT"
echo "::notice::$IMAGE:$VERSION is already published; nothing to release. Bump <Version> to publish."
elif printf '%s' "$out" | grep -qiE 'manifest unknown|no such manifest'; then
echo "already=false" >> "$GITHUB_OUTPUT"
else
echo "::error::Could not tell whether $IMAGE:$VERSION is already published: $out"
exit 1
fi
build-and-push:
needs: gate
# A manual run publishes nothing unless it asks to (see the push step), so it still builds and
# probes the image.
if: needs.gate.outputs.already != 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
# The NuGet release refuses to publish a version the changelog does not record. This workflow
# fires on the same version bump, and what it publishes cannot be withdrawn — an image tag
# stays where it is pushed. So it holds the same line, by the same test, and holds it before
# any build minutes are spent: a heading alone does not count, the section has to say
# something. Two gates that disagree about what "recorded" means would let one release half
# of a version.
- name: Verify the changelog records this version
run: |
VERSION="${{ needs.gate.outputs.version }}"
awk -v heading="## $VERSION" '
$0 == heading { capture = 1; next }
capture && /^## / { exit }
capture { print }
' CHANGELOG.md > "$RUNNER_TEMP/release-notes.md"
if ! grep -q '[^[:space:]]' "$RUNNER_TEMP/release-notes.md"; then
echo "::error::CHANGELOG.md records no content under '## $VERSION'. Record the release before publishing it."
exit 1
fi
echo "Changelog records $VERSION: $(wc -l < "$RUNNER_TEMP/release-notes.md") lines"
# Two gates guard this publish, one per kind of defect: CI catches code that is wrong (a release
# run is on a commit it passed; a manual run tests here instead), the healthcheck probe below
# catches an image that is wrong (0.6.0/0.7.0 shipped permanently-unhealthy images while every
# test was green). Neither implies the other.
- name: Setup .NET
if: github.event_name == 'workflow_dispatch'
uses: actions/setup-dotnet@v6
with:
dotnet-version: '10.0.x'
- name: Test
if: github.event_name == 'workflow_dispatch'
run: dotnet test --configuration Release --verbosity normal
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Publishing is irreversible: `latest` moves the moment the push lands, and every consumer
# that follows it gets whatever we shipped. So the image has to prove it works here, before
# the push step — not in a workflow that happens to run alongside this one.
- name: Build amd64 locally for verification
uses: docker/build-push-action@v7
with:
context: .
file: src/MorphDB.Service/Dockerfile
load: true
tags: morphdb:verify
platforms: linux/amd64
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Verify the image reports itself healthy
run: |
set -u
docker network create morphdb-verify
docker run -d --name verify-db --network morphdb-verify \
-e POSTGRES_USER=morph -e POSTGRES_PASSWORD=morph -e POSTGRES_DB=morphdb \
--health-cmd 'pg_isready -h 127.0.0.1 -U morph -d morphdb' --health-interval 2s --health-retries 15 \
postgres:16-alpine
for _ in $(seq 30); do
[ "$(docker inspect -f '{{.State.Health.Status}}' verify-db)" = healthy ] && break
sleep 2
done
docker run -d --name verify-api --network morphdb-verify \
-e 'ConnectionStrings__MorphDB=Host=verify-db;Port=5432;Database=morphdb;Username=morph;Password=morph' \
morphdb:verify
# The assertion this whole job exists for: the container's own HEALTHCHECK must reach
# healthy. 0.6.0 and 0.7.0 both shipped a HEALTHCHECK calling a wget that is not in the
# image, so every container was permanently unhealthy while the service was fine.
for attempt in $(seq 40); do
running=$(docker inspect -f '{{.State.Running}}' verify-api)
status=$(docker inspect -f '{{.State.Health.Status}}' verify-api)
echo "attempt ${attempt}: running=${running} health=${status}"
case "${running}:${status}" in
true:healthy) echo "Image reports itself healthy."; exit 0 ;;
true:starting) sleep 5 ;;
*) break ;;
esac
done
echo "::error::The image never reported healthy. Refusing to publish."
docker inspect -f '{{json .State.Health}}' verify-api || true
docker logs verify-api || true
exit 1
# A manual run stops here: it builds the image and probes it, but publishes nothing. Every step
# from here on writes tags that cannot be taken back. A release that failed after this point is
# recovered by re-running that run; one that failed before it and was fixed in a later commit,
# by that commit's green CI. Either way an image already published under this version is never
# pushed over.
- name: Build and push Docker image
if: github.event_name == 'workflow_run' && needs.gate.outputs.already != 'true'
uses: docker/build-push-action@v7
with:
context: .
file: src/MorphDB.Service/Dockerfile
push: true
tags: |
${{ needs.gate.outputs.image }}:${{ needs.gate.outputs.version }}
${{ needs.gate.outputs.image }}:${{ needs.gate.outputs.major_minor }}
${{ needs.gate.outputs.image }}:latest
labels: |
org.opencontainers.image.version=${{ needs.gate.outputs.version }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64