|
22 | 22 | jobs: |
23 | 23 | release: |
24 | 24 | runs-on: ubuntu-latest |
| 25 | + timeout-minutes: 5 |
25 | 26 | outputs: |
26 | 27 | version: ${{ steps.version.outputs.version }} |
27 | 28 | released: ${{ steps.version.outputs.version != '' }} |
|
33 | 34 | - name: Resolve version from tag |
34 | 35 | id: version |
35 | 36 | run: | |
36 | | - # Priority: workflow_dispatch input > pushed tag > latest tag on HEAD |
37 | 37 | TAG="${{ inputs.tag || github.ref_name }}" |
38 | 38 | if [[ -z "$TAG" || "$TAG" == "main" || "$TAG" == "dev" ]]; then |
39 | 39 | TAG=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || echo "") |
|
65 | 65 | needs: release |
66 | 66 | if: needs.release.outputs.released == 'true' |
67 | 67 | runs-on: ubuntu-latest |
| 68 | + timeout-minutes: 30 |
68 | 69 | strategy: |
| 70 | + fail-fast: false |
69 | 71 | matrix: |
70 | 72 | include: |
71 | 73 | - goos: linux |
@@ -119,6 +121,7 @@ jobs: |
119 | 121 | needs: [release, build-binaries] |
120 | 122 | if: needs.release.outputs.released == 'true' |
121 | 123 | runs-on: ubuntu-latest |
| 124 | + timeout-minutes: 10 |
122 | 125 | steps: |
123 | 126 | - name: Download all binary assets |
124 | 127 | env: |
@@ -146,46 +149,106 @@ jobs: |
146 | 149 | --repo "${{ github.repository }}" \ |
147 | 150 | --clobber |
148 | 151 |
|
149 | | - # Build and push Docker images to GHCR + Docker Hub |
150 | | - docker-images: |
| 152 | + # Build Docker images per (variant × platform) on native runners (no QEMU). |
| 153 | + # Each job pushes a digest-only image; the merge job combines them into a |
| 154 | + # multi-arch manifest and pushes the final tags to both GHCR and Docker Hub. |
| 155 | + # Variants: base (backend only), latest (+ web UI + Python), full (+ all skills). |
| 156 | + # OTel and Tailscale variants are not built — users build from source if needed. |
| 157 | + docker-build: |
151 | 158 | needs: release |
152 | 159 | if: needs.release.outputs.released == 'true' |
153 | | - runs-on: ubuntu-latest |
| 160 | + timeout-minutes: 90 |
154 | 161 | strategy: |
| 162 | + fail-fast: false |
155 | 163 | matrix: |
| 164 | + variant: [base, latest, full] |
| 165 | + platform: [linux/amd64, linux/arm64] |
156 | 166 | include: |
157 | | - # Base: backend API-only, no web UI, no runtimes |
158 | 167 | - variant: base |
159 | | - suffix: "-base" |
160 | | - enable_otel: "false" |
161 | 168 | enable_embedui: "false" |
162 | 169 | enable_python: "false" |
163 | 170 | enable_full_skills: "false" |
164 | | - # Latest: backend + embedded web UI + Python |
165 | 171 | - variant: latest |
166 | | - suffix: "" |
167 | | - enable_otel: "false" |
168 | 172 | enable_embedui: "true" |
169 | 173 | enable_python: "true" |
170 | 174 | enable_full_skills: "false" |
171 | | - # Full: all runtimes + skills pre-installed |
172 | 175 | - variant: full |
173 | | - suffix: "-full" |
174 | | - enable_otel: "false" |
175 | 176 | enable_embedui: "true" |
176 | 177 | enable_python: "true" |
177 | 178 | enable_full_skills: "true" |
178 | | - # OTel: latest + OpenTelemetry tracing |
179 | | - - variant: otel |
180 | | - suffix: "-otel" |
181 | | - enable_otel: "true" |
182 | | - enable_embedui: "true" |
183 | | - enable_python: "true" |
184 | | - enable_full_skills: "false" |
| 179 | + - platform: linux/amd64 |
| 180 | + runner: ubuntu-latest |
| 181 | + arch: amd64 |
| 182 | + - platform: linux/arm64 |
| 183 | + runner: ubuntu-24.04-arm |
| 184 | + arch: arm64 |
| 185 | + runs-on: ${{ matrix.runner }} |
185 | 186 | steps: |
186 | 187 | - uses: actions/checkout@v4 |
187 | 188 |
|
188 | | - - uses: docker/setup-qemu-action@v3 |
| 189 | + - uses: docker/setup-buildx-action@v3 |
| 190 | + |
| 191 | + - name: Log in to GHCR |
| 192 | + uses: docker/login-action@v3 |
| 193 | + with: |
| 194 | + registry: ghcr.io |
| 195 | + username: ${{ github.actor }} |
| 196 | + password: ${{ secrets.GITHUB_TOKEN }} |
| 197 | + |
| 198 | + - name: Build and push by digest |
| 199 | + id: build |
| 200 | + uses: docker/build-push-action@v6 |
| 201 | + with: |
| 202 | + context: . |
| 203 | + platforms: ${{ matrix.platform }} |
| 204 | + outputs: type=image,name=${{ env.GHCR_IMAGE }},push-by-digest=true,name-canonical=true,push=true |
| 205 | + build-args: | |
| 206 | + ENABLE_OTEL=false |
| 207 | + ENABLE_EMBEDUI=${{ matrix.enable_embedui }} |
| 208 | + ENABLE_PYTHON=${{ matrix.enable_python }} |
| 209 | + ENABLE_FULL_SKILLS=${{ matrix.enable_full_skills }} |
| 210 | + VERSION=v${{ needs.release.outputs.version }} |
| 211 | + cache-from: type=gha,scope=${{ matrix.variant }}-${{ matrix.arch }} |
| 212 | + cache-to: type=gha,mode=max,scope=${{ matrix.variant }}-${{ matrix.arch }} |
| 213 | + provenance: false |
| 214 | + |
| 215 | + - name: Export digest |
| 216 | + run: | |
| 217 | + mkdir -p /tmp/digests |
| 218 | + digest="${{ steps.build.outputs.digest }}" |
| 219 | + touch "/tmp/digests/${digest#sha256:}" |
| 220 | +
|
| 221 | + - uses: actions/upload-artifact@v4 |
| 222 | + with: |
| 223 | + name: digests-${{ matrix.variant }}-${{ matrix.arch }} |
| 224 | + path: /tmp/digests/* |
| 225 | + if-no-files-found: error |
| 226 | + retention-days: 1 |
| 227 | + |
| 228 | + # Combine per-platform digests into multi-arch manifests and push final tags |
| 229 | + # to both GHCR and Docker Hub. One job per variant runs in parallel. |
| 230 | + docker-merge: |
| 231 | + needs: [release, docker-build] |
| 232 | + if: needs.release.outputs.released == 'true' |
| 233 | + runs-on: ubuntu-latest |
| 234 | + timeout-minutes: 15 |
| 235 | + strategy: |
| 236 | + fail-fast: false |
| 237 | + matrix: |
| 238 | + include: |
| 239 | + - variant: base |
| 240 | + suffix: "-base" |
| 241 | + - variant: latest |
| 242 | + suffix: "" |
| 243 | + - variant: full |
| 244 | + suffix: "-full" |
| 245 | + steps: |
| 246 | + - name: Download digests |
| 247 | + uses: actions/download-artifact@v4 |
| 248 | + with: |
| 249 | + path: /tmp/digests |
| 250 | + pattern: digests-${{ matrix.variant }}-* |
| 251 | + merge-multiple: true |
189 | 252 |
|
190 | 253 | - uses: docker/setup-buildx-action@v3 |
191 | 254 |
|
@@ -214,32 +277,92 @@ jobs: |
214 | 277 | type=raw,value=latest,enable=${{ matrix.suffix == '' }},suffix= |
215 | 278 | type=raw,value=${{ matrix.variant }},enable=${{ matrix.suffix != '' }} |
216 | 279 |
|
217 | | - - name: Build and push |
218 | | - uses: docker/build-push-action@v6 |
219 | | - with: |
220 | | - context: . |
221 | | - platforms: linux/amd64,linux/arm64 |
222 | | - push: true |
223 | | - tags: ${{ steps.meta.outputs.tags }} |
224 | | - labels: ${{ steps.meta.outputs.labels }} |
225 | | - build-args: | |
226 | | - ENABLE_OTEL=${{ matrix.enable_otel }} |
227 | | - ENABLE_EMBEDUI=${{ matrix.enable_embedui }} |
228 | | - ENABLE_PYTHON=${{ matrix.enable_python }} |
229 | | - ENABLE_FULL_SKILLS=${{ matrix.enable_full_skills }} |
230 | | - VERSION=v${{ needs.release.outputs.version }} |
231 | | - cache-from: type=gha,scope=${{ matrix.variant }} |
232 | | - cache-to: type=gha,mode=max,scope=${{ matrix.variant }} |
| 280 | + - name: Create manifest list and push |
| 281 | + working-directory: /tmp/digests |
| 282 | + run: | |
| 283 | + # Compose -t flags from metadata-action tags (newline-separated) |
| 284 | + TAG_ARGS=() |
| 285 | + while IFS= read -r tag; do |
| 286 | + [ -n "$tag" ] && TAG_ARGS+=("-t" "$tag") |
| 287 | + done <<< "${{ steps.meta.outputs.tags }}" |
| 288 | + # Compose source digest refs (pushed to GHCR by docker-build) |
| 289 | + DIGEST_ARGS=() |
| 290 | + for f in *; do |
| 291 | + DIGEST_ARGS+=("${{ env.GHCR_IMAGE }}@sha256:$f") |
| 292 | + done |
| 293 | + set -x |
| 294 | + docker buildx imagetools create "${TAG_ARGS[@]}" "${DIGEST_ARGS[@]}" |
| 295 | +
|
| 296 | + - name: Inspect manifest |
| 297 | + run: | |
| 298 | + docker buildx imagetools inspect \ |
| 299 | + "${{ env.GHCR_IMAGE }}:v${{ needs.release.outputs.version }}${{ matrix.suffix }}" |
233 | 300 |
|
234 | | - # Build and push web UI Docker image |
235 | | - docker-web: |
| 301 | + # Build web UI Docker image per platform on native runners. |
| 302 | + docker-web-build: |
236 | 303 | needs: release |
237 | 304 | if: needs.release.outputs.released == 'true' |
238 | | - runs-on: ubuntu-latest |
| 305 | + timeout-minutes: 30 |
| 306 | + strategy: |
| 307 | + fail-fast: false |
| 308 | + matrix: |
| 309 | + platform: [linux/amd64, linux/arm64] |
| 310 | + include: |
| 311 | + - platform: linux/amd64 |
| 312 | + runner: ubuntu-latest |
| 313 | + arch: amd64 |
| 314 | + - platform: linux/arm64 |
| 315 | + runner: ubuntu-24.04-arm |
| 316 | + arch: arm64 |
| 317 | + runs-on: ${{ matrix.runner }} |
239 | 318 | steps: |
240 | 319 | - uses: actions/checkout@v4 |
241 | 320 |
|
242 | | - - uses: docker/setup-qemu-action@v3 |
| 321 | + - uses: docker/setup-buildx-action@v3 |
| 322 | + |
| 323 | + - name: Log in to GHCR |
| 324 | + uses: docker/login-action@v3 |
| 325 | + with: |
| 326 | + registry: ghcr.io |
| 327 | + username: ${{ github.actor }} |
| 328 | + password: ${{ secrets.GITHUB_TOKEN }} |
| 329 | + |
| 330 | + - name: Build and push by digest |
| 331 | + id: build |
| 332 | + uses: docker/build-push-action@v6 |
| 333 | + with: |
| 334 | + context: ui/web |
| 335 | + platforms: ${{ matrix.platform }} |
| 336 | + outputs: type=image,name=${{ env.GHCR_IMAGE }}-web,push-by-digest=true,name-canonical=true,push=true |
| 337 | + cache-from: type=gha,scope=web-${{ matrix.arch }} |
| 338 | + cache-to: type=gha,mode=max,scope=web-${{ matrix.arch }} |
| 339 | + provenance: false |
| 340 | + |
| 341 | + - name: Export digest |
| 342 | + run: | |
| 343 | + mkdir -p /tmp/digests |
| 344 | + digest="${{ steps.build.outputs.digest }}" |
| 345 | + touch "/tmp/digests/${digest#sha256:}" |
| 346 | +
|
| 347 | + - uses: actions/upload-artifact@v4 |
| 348 | + with: |
| 349 | + name: digests-web-${{ matrix.arch }} |
| 350 | + path: /tmp/digests/* |
| 351 | + if-no-files-found: error |
| 352 | + retention-days: 1 |
| 353 | + |
| 354 | + docker-web-merge: |
| 355 | + needs: [release, docker-web-build] |
| 356 | + if: needs.release.outputs.released == 'true' |
| 357 | + runs-on: ubuntu-latest |
| 358 | + timeout-minutes: 15 |
| 359 | + steps: |
| 360 | + - name: Download digests |
| 361 | + uses: actions/download-artifact@v4 |
| 362 | + with: |
| 363 | + path: /tmp/digests |
| 364 | + pattern: digests-web-* |
| 365 | + merge-multiple: true |
243 | 366 |
|
244 | 367 | - uses: docker/setup-buildx-action@v3 |
245 | 368 |
|
@@ -267,22 +390,31 @@ jobs: |
267 | 390 | type=raw,value=v${{ needs.release.outputs.version }} |
268 | 391 | type=raw,value=latest |
269 | 392 |
|
270 | | - - name: Build and push |
271 | | - uses: docker/build-push-action@v6 |
272 | | - with: |
273 | | - context: ui/web |
274 | | - platforms: linux/amd64,linux/arm64 |
275 | | - push: true |
276 | | - tags: ${{ steps.meta.outputs.tags }} |
277 | | - labels: ${{ steps.meta.outputs.labels }} |
278 | | - cache-from: type=gha,scope=web |
279 | | - cache-to: type=gha,mode=max,scope=web |
| 393 | + - name: Create manifest list and push |
| 394 | + working-directory: /tmp/digests |
| 395 | + run: | |
| 396 | + TAG_ARGS=() |
| 397 | + while IFS= read -r tag; do |
| 398 | + [ -n "$tag" ] && TAG_ARGS+=("-t" "$tag") |
| 399 | + done <<< "${{ steps.meta.outputs.tags }}" |
| 400 | + DIGEST_ARGS=() |
| 401 | + for f in *; do |
| 402 | + DIGEST_ARGS+=("${{ env.GHCR_IMAGE }}-web@sha256:$f") |
| 403 | + done |
| 404 | + set -x |
| 405 | + docker buildx imagetools create "${TAG_ARGS[@]}" "${DIGEST_ARGS[@]}" |
| 406 | +
|
| 407 | + - name: Inspect manifest |
| 408 | + run: | |
| 409 | + docker buildx imagetools inspect \ |
| 410 | + "${{ env.GHCR_IMAGE }}-web:v${{ needs.release.outputs.version }}" |
280 | 411 |
|
281 | 412 | # Notify Discord on new release (runs even if docker jobs fail) |
282 | 413 | notify-discord: |
283 | | - needs: [release, build-binaries, docker-images, docker-web] |
| 414 | + needs: [release, build-binaries, docker-merge, docker-web-merge] |
284 | 415 | if: always() && needs.release.outputs.released == 'true' && !cancelled() |
285 | 416 | runs-on: ubuntu-latest |
| 417 | + timeout-minutes: 5 |
286 | 418 | steps: |
287 | 419 | - name: Send Discord notification |
288 | 420 | env: |
|
0 commit comments