Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ volumes
tools
tmp
.tmp
.git
docs
tests
src
196 changes: 196 additions & 0 deletions .github/workflows/docker-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Builds the lablup/backend.ai-* service images from the docker/backend.ai-*
# dockerfiles (discovered via scripts/list-dockerfiles.sh --service) and pushes
# them to Docker Hub, multi-arch (linux/amd64 + linux/arm64).
#
# Authentication is OIDC-only — no registry secret is stored in this repository:
# - The Docker Hub org `lablup` has an OIDC connection whose ruleset accepts
# tokens with the subject claim
# `repo:lablup/backend.ai:environment:deploy-to-dockerhub`.
# - The `deploy-to-dockerhub` GitHub environment carries the env-scoped variable
# DOCKERHUB_OIDC_CONNECTION_ID (the connection UUID); `id-token: write` lets
# the job mint the OIDC token. Note the deliberate name difference: the
# repository/environment variable is DOCKERHUB_OIDC_CONNECTION_ID, while the
# env var consumed by docker/login-action is DOCKERHUB_OIDC_CONNECTIONID
# (no underscore before ID) — the login step maps one to the other.
# - To rotate or re-create the connection: update the connection in the Docker
# Hub org settings and set the new UUID in the environment variable — no
# workflow change needed.
#
# The build-push job installs the wheels staged in dist/, so a run needs the
# `wheels` artifact: produced in the same run when called from ci.yml, or taken
# from a previous ci.yml run on the same tag via the wheels-run-id input when
# dispatched manually. A manual dispatch only works while that artifact is
# still within its retention window — once it expires, re-run the ci.yml
# release job first to regenerate it.
name: Build and push service images

on:
workflow_call:
inputs:
wheels-run-id:
description: >-
ID of the workflow run that uploaded the `wheels` artifact.
Empty means the current run.
required: false
type: string
default: ''
workflow_dispatch:
inputs:
wheels-run-id:
description: >-
ID of the workflow run that uploaded the `wheels` artifact
(a ci.yml run on the same tag).
required: true
type: string

permissions:
contents: read

concurrency:
group: docker-images-${{ github.ref }}
cancel-in-progress: false

jobs:
prepare:
name: Prepare build matrix
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
python-version: ${{ steps.pyver.outputs.version }}
pkgver: ${{ steps.pkgver.outputs.version }}
is-prerelease: ${{ steps.release-type.outputs.is-prerelease }}
steps:
- name: Checkout the revision
uses: actions/checkout@v6
with:
lfs: false

- name: Build the service image matrix
id: set-matrix
run: |
MATRIX_JSON=$(scripts/list-dockerfiles.sh --service)
echo "Found service image matrix:"
echo "$MATRIX_JSON" | jq .
if [ "$(echo "$MATRIX_JSON" | jq '.include | length')" -eq 0 ]; then
echo "::error::scripts/list-dockerfiles.sh --service returned an empty matrix — no service images would be built. This is always a bug on a release."
exit 1
fi
echo "matrix=$MATRIX_JSON" >> "$GITHUB_OUTPUT"

- name: Resolve project Python version
id: pyver
run: |
PYTHON_VERSION=$(grep -m 1 -oP '(?<=CPython==)([^"]+)' pants.toml)
echo "version=$PYTHON_VERSION" >> "$GITHUB_OUTPUT"

- name: Set up Python as Runtime
uses: actions/setup-python@v6
with:
python-version: ${{ steps.pyver.outputs.version }}

- name: Resolve normalized package version
id: pkgver
run: |
pip install -U 'packaging>=21.3'
PKGVER=$(python3 scripts/normalize-version.py "$(cat VERSION)")
echo "version=$PKGVER" >> "$GITHUB_OUTPUT"

- name: Assert the tag matches the VERSION file
if: github.ref_type == 'tag'
Comment on lines +100 to +101
env:
REF_NAME: ${{ github.ref_name }}
PKGVER: ${{ steps.pkgver.outputs.version }}
run: |
TAGVER=$(python3 scripts/normalize-version.py "$REF_NAME")
if [ "$TAGVER" != "$PKGVER" ]; then
echo "::error::The tag '$REF_NAME' (normalized: $TAGVER) does not match the VERSION file (normalized: $PKGVER). Refusing to push image tags that would overwrite another release or re-point 'latest' backwards."
exit 1
fi

- name: Determine the release type
id: release-type
env:
PKGVER: ${{ steps.pkgver.outputs.version }}
run: |
IS_PRERELEASE=$(python3 scripts/determine-release-type.py "$PKGVER")
echo "is-prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"

build-push:
name: Build and push ${{ matrix.name }}
needs: prepare
if: needs.prepare.outputs.matrix != '{"include":[]}'
runs-on: ubuntu-latest
timeout-minutes: 120
environment: deploy-to-dockerhub
permissions:
contents: read
actions: read # let download-artifact's REST path read the wheels artifact
id-token: write # mint the OIDC token exchanged for the Docker Hub login
strategy:
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
fail-fast: false
steps:
- name: Check the OIDC connection variable
env:
OIDC_CONNECTION_ID: ${{ vars.DOCKERHUB_OIDC_CONNECTION_ID }}
run: |
if [ -z "$OIDC_CONNECTION_ID" ]; then
echo "::error::The DOCKERHUB_OIDC_CONNECTION_ID variable of the deploy-to-dockerhub environment is empty — set it to the Docker Hub OIDC connection UUID before pushing images."
exit 1
fi

- name: Checkout the revision
uses: actions/checkout@v6
with:
lfs: false

- name: Download wheels
uses: actions/download-artifact@v6
with:
name: wheels
path: dist
run-id: ${{ inputs.wheels-run-id || github.run_id }}
github-token: ${{ github.token }}
Comment on lines +154 to +155

- name: Set up QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0

- name: Login to Docker Hub via OIDC
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
env:
DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTION_ID }}
Comment on lines +163 to +166
with:
username: lablup

- name: Generate image metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
images: ${{ matrix.image }}
flavor: |
latest=false
tags: |
type=raw,value=${{ needs.prepare.outputs.pkgver }}
type=raw,value=latest,enable=${{ needs.prepare.outputs.is-prerelease == 'false' }}
Comment on lines +178 to +179

- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max
sbom: true
cache-from: type=gha,scope=${{ matrix.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
build-args: |
PYTHON_VERSION=${{ needs.prepare.outputs.python-version }}
PKGVER=${{ needs.prepare.outputs.pkgver }}
1 change: 1 addition & 0 deletions changes/13595.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Publish the `lablup/backend.ai-*` service images to Docker Hub with a new multi-arch build workflow authenticating via OIDC (no stored registry credentials)
3 changes: 2 additions & 1 deletion scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,9 @@ current, see `AGENTS.md` in this directory.
| `.github/scripts/create-version-branch.sh` | Tags the `X.Y.0rc1` a release commit made and cuts the `X.Y` branch at that same commit | auto — `create-version-branch.yml` |
| `.github/scripts/sync-changelog-to-main.sh` | Opens the pull request carrying a final release's `CHANGELOG/X.Y.md` back to `main` | auto — `changelog-sync.yml` |
| `extract-release-changelog.py` | Extracts the tagged version's block for the GitHub release body | auto — `ci.yml` (release job) |
| `list-dockerfiles.sh` | Prints the `docker/` dockerfile build matrix (`--service` / `--infra`) as JSON; a new `backend.ai-*` dockerfile must be registered in its allowlist | auto — `sbom.yml`; `osv-scanner.yml` (also scheduled: weekly cron + push to `main`) |
| `list-dockerfiles.sh` | Prints the `docker/` dockerfile build matrix (`--service` / `--infra`) as JSON; a new `backend.ai-*` dockerfile must be registered in its allowlist | auto — `sbom.yml`; `docker-images.yml`; `osv-scanner.yml` (also scheduled: weekly cron + push to `main`) |
| `determine-release-type.py` | Sets `IS_PRERELEASE` from the `VERSION` file | auto — `ci.yml` (release job) |
| `normalize-version.py` | Prints the PEP 440-normalized version used for docker image tags, rejecting local segments (`+` is not a legal docker tag character) | auto — `docker-images.yml` |
| `build-wheels.sh` | Builds the platform-specific and generic wheels | auto — `ci.yml` (release job) |
| `build-scies.sh` | Builds the scie executables locally (CI runs the equivalent pants commands inline) | person |
| `diff-release.py` | Lists the commits between two refs with their original / backport PR numbers | person |
Expand Down
35 changes: 35 additions & 0 deletions scripts/normalize-version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Print the PEP 440-normalized form of a version string.

The normalized form is what the docker image tags use. A version with a local
segment (e.g., ``26.9.0+dev``) is rejected because ``+`` is not a legal
character in a docker tag. Turning the printed value into a CI variable is
the caller's business:

PKGVER=$(python scripts/normalize-version.py "$(cat VERSION)")
echo "version=$PKGVER" >> "$GITHUB_OUTPUT"
"""

import sys

from packaging.version import InvalidVersion, Version


def main() -> None:
raw = sys.argv[1].strip()
try:
version = Version(raw)
except InvalidVersion:
print(f"error: {raw!r} is not a valid PEP 440 version", file=sys.stderr)
sys.exit(1)
if version.local is not None:
print(
f"error: {raw!r} has a local version segment"
" ('+' is not a legal character in a docker tag)",
file=sys.stderr,
)
sys.exit(1)
print(version)
Comment on lines +12 to +31


if __name__ == "__main__":
main()
Loading