400 lines
17 KiB
YAML
400 lines
17 KiB
YAML
# Builds + pushes two images to GHCR via GITHUB_TOKEN: the server image
|
||
# (ghcr.io/omnigent-ai/omnigent-server, referenced by every deploy template)
|
||
# and the host image (the `host` target of the same Dockerfile,
|
||
# ghcr.io/omnigent-ai/omnigent-host — default for `sandbox create --provider
|
||
# modal` and server-launched managed hosts). Dockerfile ARGs default to public
|
||
# registries, so no build-args needed.
|
||
#
|
||
# Tag scheme:
|
||
# :sha-<short> immutable per-commit pin, published on EVERY qualifying build.
|
||
# :vX.Y.Z[rcN] immutable version pin, published for every release + pre-release tag.
|
||
# :latest the highest FINAL release (max over vX.Y.Z) — tracks what
|
||
# `pip install omnigent` resolves to. Pre-releases never move it.
|
||
# :latest-rc the highest version OVERALL, max(release, rc) — the newest
|
||
# thing tagged, pre-release or not.
|
||
# :latest-dev the most recent main build (bleeding edge); moves on every
|
||
# qualifying main commit.
|
||
# :latest-nightly the most recent main build as of the daily cron; retagged
|
||
# from :latest-dev once a day (no rebuild).
|
||
# Ordering for :latest / :latest-rc uses PEP 440 (1.2.3rc1 < 1.2.3), which
|
||
# `sort -V` gets wrong, so the max is computed with .github/scripts/
|
||
# oss-publish-images/maxver.py (Python `packaging`).
|
||
#
|
||
# First run creates the GHCR packages PRIVATE; flip them to public once in the
|
||
# org package settings to allow unauthenticated pulls (cannot be done in CI).
|
||
name: Publish images (public)
|
||
|
||
on:
|
||
push:
|
||
branches: [main]
|
||
tags: ['v*']
|
||
# Only rebuild when something that lands in the image changes.
|
||
paths:
|
||
- 'deploy/docker/Dockerfile'
|
||
- 'deploy/docker/entrypoint.py'
|
||
- 'omnigent/**'
|
||
- 'web/**'
|
||
- 'sdks/**'
|
||
- 'pyproject.toml'
|
||
- 'setup.py'
|
||
- 'uv.lock'
|
||
- 'web/package-lock.json'
|
||
- '.github/workflows/oss-publish-images.yml'
|
||
# Daily nightly promotion (07:00 UTC). Retags the current :latest-dev as
|
||
# :latest-nightly — handled by promote-nightly, not a rebuild.
|
||
schedule:
|
||
- cron: '0 7 * * *'
|
||
workflow_dispatch:
|
||
inputs:
|
||
bump_latest:
|
||
description: 'Also move :latest to this build (manual release of latest). Off by default.'
|
||
type: boolean
|
||
default: false
|
||
force_nightly:
|
||
description: 'Promote :latest-dev -> :latest-nightly now (runs only the nightly job). Off by default.'
|
||
type: boolean
|
||
default: false
|
||
reconcile_floating:
|
||
description: 'Repoint :latest and :latest-rc onto the correct existing version images (no rebuild). Runs only the reconcile job. Off by default.'
|
||
type: boolean
|
||
default: false
|
||
|
||
# Read-only at the top level; write scopes live on the jobs below.
|
||
permissions:
|
||
contents: read
|
||
|
||
concurrency:
|
||
# Key by SHA so back-to-back merges each build; don't cancel mid-push.
|
||
group: oss-publish-images-${{ github.sha }}
|
||
cancel-in-progress: false
|
||
|
||
jobs:
|
||
build-and-push:
|
||
permissions:
|
||
contents: read
|
||
packages: write # push the image to GHCR via GITHUB_TOKEN
|
||
# Gated to this repository; inert in forks and mirrors. Skip the (re)build
|
||
# on schedule, force_nightly, and reconcile_floating dispatches — those only
|
||
# drive the promote-nightly / reconcile-floating jobs.
|
||
if: github.repository == 'omnigent-ai/omnigent' && github.event_name != 'schedule' && !inputs.force_nightly && !inputs.reconcile_floating
|
||
runs-on: ubuntu-latest
|
||
# Multi-arch: the linux/arm64 leg cross-builds under QEMU emulation on this
|
||
# amd64 runner, which roughly doubles the host-image build time (emulated
|
||
# npm/pip native steps). 30m was tight for two native amd64 builds; give the
|
||
# four-variant (server+host × amd64+arm64) build headroom.
|
||
timeout-minutes: 60
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
# Register binfmt handlers so Buildx can cross-build the linux/arm64
|
||
# variant on this amd64 runner (emulated). Without it the arm64 leg of
|
||
# the multi-arch builds below fails with "exec format error".
|
||
- name: Set up QEMU
|
||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||
|
||
- name: Set up Buildx
|
||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||
|
||
# Needed only for the PEP 440 max() on tag pushes; cheap on other events.
|
||
- name: Set up uv
|
||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||
with:
|
||
enable-cache: false
|
||
|
||
- name: Log in to GHCR
|
||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||
with:
|
||
registry: ghcr.io
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
# Compute the tag set for this event. ref / ref_name go through env (not
|
||
# inline ${{ }}) so a crafted tag name can't inject shell.
|
||
- name: Compute image tags
|
||
id: tags
|
||
env:
|
||
GH_REF: ${{ github.ref }}
|
||
GH_REF_NAME: ${{ github.ref_name }}
|
||
GH_REPO: ${{ github.repository }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
BUMP_LATEST: ${{ inputs.bump_latest }}
|
||
run: |
|
||
set -euo pipefail
|
||
IMAGE="ghcr.io/omnigent-ai/omnigent-server"
|
||
HOST_IMAGE="ghcr.io/omnigent-ai/omnigent-host"
|
||
OPENSHELL_IMAGE="ghcr.io/omnigent-ai/omnigent-server-openshell"
|
||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||
|
||
# Immutable per-commit pin, always.
|
||
TAGS="${IMAGE}:sha-${SHORT_SHA}"
|
||
HOST_TAGS="${HOST_IMAGE}:sha-${SHORT_SHA}"
|
||
OPENSHELL_TAGS="${OPENSHELL_IMAGE}:sha-${SHORT_SHA}"
|
||
|
||
# Append a floating/version tag to all images.
|
||
add_tag() {
|
||
TAGS="${TAGS},${IMAGE}:$1"
|
||
HOST_TAGS="${HOST_TAGS},${HOST_IMAGE}:$1"
|
||
OPENSHELL_TAGS="${OPENSHELL_TAGS},${OPENSHELL_IMAGE}:$1"
|
||
}
|
||
|
||
# Every qualifying main commit moves :latest-dev (bleeding edge).
|
||
if [ "${GH_REF}" = "refs/heads/main" ]; then
|
||
add_tag "latest-dev"
|
||
fi
|
||
|
||
if [[ "${GH_REF}" == refs/tags/v* ]]; then
|
||
# Immutable version pin for every release AND pre-release.
|
||
add_tag "${GH_REF_NAME}"
|
||
|
||
# Decide which floating release tags this version owns, using PEP 440
|
||
# ordering over the full tag list. :latest-rc => max(release, rc);
|
||
# :latest => max(final release).
|
||
ALL_TAGS=$(gh api "repos/${GH_REPO}/tags" --paginate --jq '.[].name')
|
||
decision=$(CUR="${GH_REF_NAME}" ALL_TAGS="${ALL_TAGS}" \
|
||
uv run --with packaging --no-project python .github/scripts/oss-publish-images/maxver.py)
|
||
IS_MAX_RC="${decision% *}"
|
||
IS_MAX_RELEASE="${decision#* }"
|
||
echo "version=${GH_REF_NAME} is_max_rc=${IS_MAX_RC} is_max_release=${IS_MAX_RELEASE}"
|
||
|
||
# :latest-rc tracks max(release, rc).
|
||
if [ "${IS_MAX_RC}" = "true" ]; then
|
||
add_tag "latest-rc"
|
||
fi
|
||
# :latest tracks the highest FINAL release only.
|
||
if [ "${IS_MAX_RELEASE}" = "true" ]; then
|
||
add_tag "latest"
|
||
fi
|
||
fi
|
||
|
||
# A manual dispatch can still force-move :latest (human approval).
|
||
if [ "${BUMP_LATEST}" = "true" ]; then
|
||
add_tag "latest"
|
||
fi
|
||
|
||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||
echo "host_tags=${HOST_TAGS}" >> "$GITHUB_OUTPUT"
|
||
echo "openshell_tags=${OPENSHELL_TAGS}" >> "$GITHUB_OUTPUT"
|
||
|
||
# No build-args: the Dockerfile ARGs default to public registries.
|
||
# Multi-arch: each tag publishes as a manifest list spanning amd64 + arm64,
|
||
# so the image runs natively on Apple Silicon / arm64 clusters. Amd64-only
|
||
# consumers (Modal, Daytona, CoreWeave) keep pulling the amd64 variant —
|
||
# the list is a superset, so nothing changes for them.
|
||
- name: Build and push
|
||
id: build-server
|
||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||
with:
|
||
context: .
|
||
file: deploy/docker/Dockerfile
|
||
push: true
|
||
platforms: linux/amd64,linux/arm64
|
||
tags: ${{ steps.tags.outputs.tags }}
|
||
cache-from: type=gha
|
||
cache-to: type=gha,mode=max
|
||
provenance: false
|
||
sbom: true
|
||
|
||
# Host image: same Dockerfile, `host` target, also multi-arch (amd64 +
|
||
# arm64). The harness CLIs it bakes in all ship arm64 — claude-code and
|
||
# codex publish linux-arm64 npm binaries, pi is pure-JS. Runs after the
|
||
# server build so it reuses the shared builder-stage layers from the gha
|
||
# cache (cached per platform).
|
||
- name: Build and push host image
|
||
id: build-host
|
||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||
with:
|
||
context: .
|
||
file: deploy/docker/Dockerfile
|
||
target: host
|
||
push: true
|
||
platforms: linux/amd64,linux/arm64
|
||
tags: ${{ steps.tags.outputs.host_tags }}
|
||
cache-from: type=gha
|
||
cache-to: type=gha,mode=max
|
||
provenance: false
|
||
sbom: true
|
||
|
||
# OpenShell server variant: the default server image plus the
|
||
# openshell SDK extra (OMNIGENT_EXTRAS=openshell). Used by the
|
||
# deploy/kubernetes/overlays/openshell kustomize overlay. Reuses
|
||
# the shared builder-stage layers from the gha cache.
|
||
- name: Build and push openshell server image
|
||
id: build-openshell
|
||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||
with:
|
||
context: .
|
||
file: deploy/docker/Dockerfile
|
||
push: true
|
||
platforms: linux/amd64,linux/arm64
|
||
tags: ${{ steps.tags.outputs.openshell_tags }}
|
||
build-args: |
|
||
OMNIGENT_EXTRAS=openshell
|
||
cache-from: type=gha
|
||
cache-to: type=gha,mode=max
|
||
provenance: false
|
||
sbom: true
|
||
outputs:
|
||
server-digest: ${{ steps.build-server.outputs.digest }}
|
||
host-digest: ${{ steps.build-host.outputs.digest }}
|
||
openshell-digest: ${{ steps.build-openshell.outputs.digest }}
|
||
|
||
generate-sbom:
|
||
# Runs in a separate job with read-only permissions so the Syft
|
||
# install script cannot influence the image push. Scans the
|
||
# already-pushed images by digest (immutable).
|
||
needs: build-and-push
|
||
permissions:
|
||
contents: read
|
||
packages: read
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 15
|
||
steps:
|
||
- name: Log in to GHCR (read-only)
|
||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||
with:
|
||
registry: ghcr.io
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Install Syft
|
||
uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
|
||
|
||
- name: Generate server SBOM
|
||
run: |
|
||
set -euo pipefail
|
||
syft "ghcr.io/omnigent-ai/omnigent-server@${{ needs.build-and-push.outputs.server-digest }}" \
|
||
-o cyclonedx-json=server-sbom.cdx.json \
|
||
-o spdx-json=server-sbom.spdx.json
|
||
|
||
- name: Generate host SBOM
|
||
run: |
|
||
set -euo pipefail
|
||
syft "ghcr.io/omnigent-ai/omnigent-host@${{ needs.build-and-push.outputs.host-digest }}" \
|
||
-o cyclonedx-json=host-sbom.cdx.json \
|
||
-o spdx-json=host-sbom.spdx.json
|
||
|
||
- name: Generate openshell server SBOM
|
||
run: |
|
||
set -euo pipefail
|
||
syft "ghcr.io/omnigent-ai/omnigent-server-openshell@${{ needs.build-and-push.outputs.openshell-digest }}" \
|
||
-o cyclonedx-json=openshell-sbom.cdx.json \
|
||
-o spdx-json=openshell-sbom.spdx.json
|
||
|
||
- name: Upload SBOMs
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||
with:
|
||
name: sbom
|
||
path: |
|
||
server-sbom.cdx.json
|
||
server-sbom.spdx.json
|
||
host-sbom.cdx.json
|
||
host-sbom.spdx.json
|
||
openshell-sbom.cdx.json
|
||
openshell-sbom.spdx.json
|
||
retention-days: 90
|
||
|
||
promote-nightly:
|
||
# Daily cron (or a manual force_nightly dispatch): move :latest-nightly to
|
||
# the current main build by retagging :latest-dev with `crane tag`
|
||
# (digest-preserving, no rebuild).
|
||
if: github.repository == 'omnigent-ai/omnigent' && (github.event_name == 'schedule' || inputs.force_nightly)
|
||
permissions:
|
||
contents: read
|
||
packages: write # retag within GHCR via GITHUB_TOKEN
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 10
|
||
steps:
|
||
- name: Set up crane
|
||
uses: imjasonh/setup-crane@59c71e96a00b28651f10369ba3359a6d730740a0 # v0.6
|
||
with:
|
||
version: v0.21.6
|
||
|
||
- name: Log in to GHCR
|
||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||
with:
|
||
registry: ghcr.io
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Promote latest-dev -> latest-nightly
|
||
run: |
|
||
set -euo pipefail
|
||
# crane tag points a new tag at an EXISTING manifest digest without
|
||
# re-serializing it, so :latest-nightly keeps :latest-dev's exact digest.
|
||
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell; do
|
||
if crane digest "${img}:latest-dev" >/dev/null 2>&1; then
|
||
crane tag "${img}:latest-dev" latest-nightly
|
||
echo "promoted ${img}:latest-dev -> :latest-nightly ($(crane digest "${img}:latest-nightly"))"
|
||
else
|
||
echo "::warning::${img}:latest-dev not found yet; skipping nightly promotion"
|
||
fi
|
||
done
|
||
|
||
reconcile-floating:
|
||
# Manual reconcile (workflow_dispatch with reconcile_floating=true): repoint
|
||
# :latest and :latest-rc onto the correct EXISTING version images, computed
|
||
# from the tag list with PEP 440 ordering. Retags with `crane tag`
|
||
# (digest-preserving). Idempotent — also a "fix the floating tags if they drift"
|
||
# button, and the way to backfill them for releases cut before this scheme.
|
||
if: github.repository == 'omnigent-ai/omnigent' && inputs.reconcile_floating
|
||
permissions:
|
||
contents: read
|
||
packages: write # retag within GHCR via GITHUB_TOKEN
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 10
|
||
steps:
|
||
- name: Checkout
|
||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||
|
||
- name: Set up crane
|
||
uses: imjasonh/setup-crane@59c71e96a00b28651f10369ba3359a6d730740a0 # v0.6
|
||
with:
|
||
version: v0.21.6
|
||
|
||
- name: Set up uv
|
||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||
with:
|
||
enable-cache: false
|
||
|
||
- name: Log in to GHCR
|
||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||
with:
|
||
registry: ghcr.io
|
||
username: ${{ github.actor }}
|
||
password: ${{ secrets.GITHUB_TOKEN }}
|
||
|
||
- name: Reconcile :latest and :latest-rc
|
||
env:
|
||
GH_REPO: ${{ github.repository }}
|
||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||
run: |
|
||
set -euo pipefail
|
||
ALL_TAGS=$(gh api "repos/${GH_REPO}/tags" --paginate --jq '.[].name')
|
||
read -r RC_TAG LATEST_TAG < <(ALL_TAGS="${ALL_TAGS}" \
|
||
uv run --with packaging --no-project python .github/scripts/oss-publish-images/reconcile_targets.py)
|
||
echo "targets: latest-rc<-${RC_TAG} latest<-${LATEST_TAG}"
|
||
|
||
# crane tag repoints a tag onto an EXISTING manifest digest without
|
||
# re-serializing it (unlike `imagetools create`, which wraps a
|
||
# single-platform image in a fresh manifest list and changes the
|
||
# digest). dst=floating tag, src=version tag.
|
||
retag() {
|
||
local img="$1" dst="$2" src="$3"
|
||
if [ "${src}" = "-" ]; then
|
||
echo "::warning::no source for ${img}:${dst}; skipping"
|
||
return
|
||
fi
|
||
if crane digest "${img}:${src}" >/dev/null 2>&1; then
|
||
crane tag "${img}:${src}" "${dst}"
|
||
echo "set ${img}:${dst} -> ${src} ($(crane digest "${img}:${dst}"))"
|
||
else
|
||
echo "::warning::${img}:${src} image not found; skipping ${img}:${dst}"
|
||
fi
|
||
}
|
||
|
||
for img in ghcr.io/omnigent-ai/omnigent-server ghcr.io/omnigent-ai/omnigent-host ghcr.io/omnigent-ai/omnigent-server-openshell; do
|
||
retag "${img}" "latest-rc" "${RC_TAG}"
|
||
retag "${img}" "latest" "${LATEST_TAG}"
|
||
done
|