Skip to content

Release Pipeline

Release Pipeline #38

name: Release Pipeline
on:
workflow_dispatch:
inputs:
bump_type:
description: 'Version bump type'
required: true
default: 'patch'
type: choice
options:
- major
- minor
- patch
force_build_all:
description: 'Force rebuild all components'
required: false
type: boolean
default: true
components:
description: 'Components to build (comma-separated: frontend,backend,operator,ambient-runner,state-sync,public-api,ambient-api-server) - leave empty for all'
required: false
type: string
default: ''
concurrency:
group: prod-release-deploy
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
new_tag: ${{ steps.next_version.outputs.new_tag }}
build-matrix: ${{ steps.matrix.outputs.build-matrix }}
merge-matrix: ${{ steps.matrix.outputs.merge-matrix }}
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
fetch-depth: 0 # Fetch all history for changelog generation
- name: Get Latest Tag
id: get_latest_tag
run: |
# List all existing tags for debugging
echo "All existing tags:"
git tag --list 'v*.*.*' --sort=-version:refname
# Get the latest tag using version sort, or use v0.0.0 if no tags exist
LATEST_TAG=$(git tag --list 'v*.*.*' --sort=-version:refname | head -n 1)
if [ -z "$LATEST_TAG" ]; then
exit 1
fi
echo "latest_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
echo "Latest tag: $LATEST_TAG"
- name: Calculate Next Version
id: next_version
run: |
LATEST_TAG="${{ steps.get_latest_tag.outputs.latest_tag }}"
# Remove 'v' prefix for calculation
VERSION=${LATEST_TAG#v}
# Split version into components
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
# Bump version based on input
case "${{ github.event.inputs.bump_type }}" in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch)
PATCH=$((PATCH + 1))
;;
esac
NEW_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
echo "new_tag=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "New version: $NEW_VERSION"
- name: Generate Changelog
id: changelog
run: |
LATEST_TAG="${{ steps.get_latest_tag.outputs.latest_tag }}"
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
echo "# Release $NEW_TAG" > RELEASE_CHANGELOG.md
echo "" >> RELEASE_CHANGELOG.md
echo "## Changes since $LATEST_TAG" >> RELEASE_CHANGELOG.md
echo "" >> RELEASE_CHANGELOG.md
# Generate changelog from commits
if [ "$LATEST_TAG" = "v0.0.0" ]; then
# First release - include all commits
git log --pretty=format:"- %s (%h)" >> RELEASE_CHANGELOG.md
else
# Get commits since last tag
git log ${LATEST_TAG}..HEAD --pretty=format:"- %s (%h)" >> RELEASE_CHANGELOG.md
fi
echo "" >> RELEASE_CHANGELOG.md
echo "" >> RELEASE_CHANGELOG.md
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LATEST_TAG}...${NEW_TAG}" >> RELEASE_CHANGELOG.md
cat RELEASE_CHANGELOG.md
- name: Create Tag
id: create_tag
uses: rickstaa/action-create-tag@v1
with:
tag: ${{ steps.next_version.outputs.new_tag }}
message: "Release ${{ steps.next_version.outputs.new_tag }}"
force_push_tag: false
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release Archive
id: create_archive
run: |
NEW_TAG="${{ steps.next_version.outputs.new_tag }}"
ARCHIVE_NAME="vteam-${NEW_TAG}.tar.gz"
# Create archive of entire repository at this tag
git archive --format=tar.gz --prefix=vteam-${NEW_TAG}/ HEAD > $ARCHIVE_NAME
echo "archive_name=$ARCHIVE_NAME" >> $GITHUB_OUTPUT
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.next_version.outputs.new_tag }}
name: "Release ${{ steps.next_version.outputs.new_tag }}"
body_path: RELEASE_CHANGELOG.md
draft: false
prerelease: false
files: |
${{ steps.create_archive.outputs.archive_name }}
RELEASE_CHANGELOG.md
- name: Build component matrices
id: matrix
run: |
ALL_COMPONENTS='[
{"name":"frontend","context":"./components/frontend","image":"quay.io/ambient_code/vteam_frontend","dockerfile":"./components/frontend/Dockerfile"},
{"name":"backend","context":"./components/backend","image":"quay.io/ambient_code/vteam_backend","dockerfile":"./components/backend/Dockerfile"},
{"name":"operator","context":"./components/operator","image":"quay.io/ambient_code/vteam_operator","dockerfile":"./components/operator/Dockerfile"},
{"name":"ambient-runner","context":"./components/runners","image":"quay.io/ambient_code/vteam_claude_runner","dockerfile":"./components/runners/ambient-runner/Dockerfile"},
{"name":"state-sync","context":"./components/runners/state-sync","image":"quay.io/ambient_code/vteam_state_sync","dockerfile":"./components/runners/state-sync/Dockerfile"},
{"name":"public-api","context":"./components/public-api","image":"quay.io/ambient_code/vteam_public_api","dockerfile":"./components/public-api/Dockerfile"},
{"name":"ambient-api-server","context":"./components/ambient-api-server","image":"quay.io/ambient_code/vteam_api_server","dockerfile":"./components/ambient-api-server/Dockerfile"}
]'
FORCE_ALL="${{ github.event.inputs.force_build_all }}"
SELECTED="${{ github.event.inputs.components }}"
if [ "$FORCE_ALL" == "true" ] || [ -z "$SELECTED" ]; then
FILTERED="$ALL_COMPONENTS"
else
FILTERED=$(echo "$ALL_COMPONENTS" | jq -c --arg sel "$SELECTED" '[.[] | select(.name as $n | $sel | split(",") | map(gsub("^\\s+|\\s+$";"")) | index($n))]')
fi
if [ "$(echo "$FILTERED" | jq 'length')" -eq 0 ]; then
echo "::error::No components matched the selection. Aborting release."
exit 1
fi
BUILD_MATRIX=$(echo "$FILTERED" | jq -c '.')
MERGE_MATRIX=$(echo "$FILTERED" | jq -c '[.[] | {name, image}]')
echo "build-matrix=$BUILD_MATRIX" >> $GITHUB_OUTPUT
echo "merge-matrix=$MERGE_MATRIX" >> $GITHUB_OUTPUT
echo "Components to build:"
echo "$FILTERED" | jq -r '.[].name'
build:
needs: release
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
strategy:
fail-fast: false
matrix:
# IMPORTANT: suffix values must match the hardcoded suffixes in
# merge-manifests. Update both together if arches change.
arch:
- runner: ubuntu-latest
platform: linux/amd64
suffix: amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
suffix: arm64
component: ${{ fromJSON(needs.release.outputs.build-matrix) }}
runs-on: ${{ matrix.arch.runner }}
steps:
- name: Checkout code from the tag generated above
uses: actions/checkout@v6
with:
ref: ${{ needs.release.outputs.new_tag }}
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Quay.io
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
- name: Log in to Red Hat Container Registry
uses: docker/login-action@v3
with:
registry: registry.redhat.io
username: ${{ secrets.REDHAT_USERNAME }}
password: ${{ secrets.REDHAT_PASSWORD }}
- name: Build and push ${{ matrix.component.name }} (${{ matrix.arch.suffix }})
uses: docker/build-push-action@v6
with:
context: ${{ matrix.component.context }}
file: ${{ matrix.component.dockerfile }}
platforms: ${{ matrix.arch.platform }}
push: true
tags: ${{ matrix.component.image }}:${{ needs.release.outputs.new_tag }}-${{ matrix.arch.suffix }}
build-args: AMBIENT_VERSION=${{ needs.release.outputs.new_tag }}
cache-from: type=gha,scope=${{ matrix.component.name }}-${{ matrix.arch.suffix }}
cache-to: type=gha,mode=max,scope=${{ matrix.component.name }}-${{ matrix.arch.suffix }}
merge-manifests:
needs: [release, build]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
component: ${{ fromJSON(needs.release.outputs.merge-matrix) }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Quay.io
uses: docker/login-action@v3
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_PASSWORD }}
- name: Create multi-arch manifest for ${{ matrix.component.name }}
# Suffixes (-amd64, -arm64) must match the arch matrix in the build job above.
# Arch-suffixed tags remain in the registry after merging. Clean these up
# via Quay tag expiration policies or a periodic job.
run: |
docker buildx imagetools create \
-t ${{ matrix.component.image }}:${{ needs.release.outputs.new_tag }} \
${{ matrix.component.image }}:${{ needs.release.outputs.new_tag }}-amd64 \
${{ matrix.component.image }}:${{ needs.release.outputs.new_tag }}-arm64
deploy-to-openshift:
runs-on: ubuntu-latest
needs: [release, merge-manifests]
steps:
- name: Checkout code from release tag
uses: actions/checkout@v6
with:
ref: ${{ needs.release.outputs.new_tag }}
- name: Install oc
uses: redhat-actions/oc-installer@v1
with:
oc_version: 'latest'
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
with:
kustomize-version: '5.4.3'
- name: Log in to OpenShift Cluster
run: |
oc login ${{ secrets.PROD_OPENSHIFT_SERVER }} --token=${{ secrets.PROD_OPENSHIFT_TOKEN }} --insecure-skip-tls-verify
- name: Deploy observability stack
run: |
oc apply -k components/manifests/observability/
- name: Determine which components were built
id: built
run: |
MATRIX='${{ needs.release.outputs.build-matrix }}'
NAMES=$(echo "$MATRIX" | jq -r '[.[].name] | join(",")')
echo "names=$NAMES" >> $GITHUB_OUTPUT
echo "Built components: $NAMES"
- name: Update kustomization with release image tags
working-directory: components/manifests/overlays/production
run: |
RELEASE_TAG="${{ needs.release.outputs.new_tag }}"
BUILT="${{ steps.built.outputs.names }}"
# Map component names to their deployment names and container names
# runner and state-sync are Jobs (no deployment); their tags come from the operator env
declare -A DEPLOY_MAP=(
["frontend"]="frontend:frontend"
["backend"]="backend-api:backend-api"
["operator"]="agentic-operator:agentic-operator"
["public-api"]="public-api:public-api"
["ambient-api-server"]="ambient-api-server:ambient-api-server"
)
for comp_image in \
"frontend:quay.io/ambient_code/vteam_frontend" \
"backend:quay.io/ambient_code/vteam_backend" \
"operator:quay.io/ambient_code/vteam_operator" \
"ambient-runner:quay.io/ambient_code/vteam_claude_runner" \
"state-sync:quay.io/ambient_code/vteam_state_sync" \
"public-api:quay.io/ambient_code/vteam_public_api" \
"ambient-api-server:quay.io/ambient_code/vteam_api_server"; do
COMP="${comp_image%%:*}"
IMAGE="${comp_image#*:}"
# Seed kustomize with the currently deployed tag so unbuilt components
# don't fall back to the repo's ":latest" placeholder
DEPLOY_INFO="${DEPLOY_MAP[$COMP]:-}"
if [ -n "$DEPLOY_INFO" ]; then
DEPLOY_NAME="${DEPLOY_INFO%%:*}"
CONTAINER_NAME="${DEPLOY_INFO#*:}"
CURRENT_TAG=$(oc get deployment "$DEPLOY_NAME" -n ambient-code \
-o jsonpath="{.spec.template.spec.containers[?(@.name=='${CONTAINER_NAME}')].image}" 2>/dev/null \
| grep -oP ':\K[^"]+$' || true)
if [ -n "$CURRENT_TAG" ]; then
kustomize edit set image ${IMAGE}:latest=${IMAGE}:${CURRENT_TAG}
fi
elif [ "$COMP" = "ambient-runner" ]; then
CURRENT_TAG=$(oc get deployment agentic-operator -n ambient-code \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="agentic-operator")].env[?(@.name=="AMBIENT_CODE_RUNNER_IMAGE")].value}' 2>/dev/null \
| grep -oP ':\K[^"]+$' || true)
if [ -n "$CURRENT_TAG" ]; then
kustomize edit set image ${IMAGE}:latest=${IMAGE}:${CURRENT_TAG}
fi
elif [ "$COMP" = "state-sync" ]; then
CURRENT_TAG=$(oc get deployment agentic-operator -n ambient-code \
-o jsonpath='{.spec.template.spec.containers[?(@.name=="agentic-operator")].env[?(@.name=="STATE_SYNC_IMAGE")].value}' 2>/dev/null \
| grep -oP ':\K[^"]+$' || true)
if [ -n "$CURRENT_TAG" ]; then
kustomize edit set image ${IMAGE}:latest=${IMAGE}:${CURRENT_TAG}
fi
fi
# Override with the new release tag if this component was built
if echo ",$BUILT," | grep -q ",$COMP,"; then
kustomize edit set image ${IMAGE}:latest=${IMAGE}:${RELEASE_TAG}
fi
done
- name: Validate kustomization
working-directory: components/manifests/overlays/production
run: |
kustomize build . > /dev/null
echo "✅ Kustomization validation passed"
- name: Apply production overlay with kustomize
working-directory: components/manifests/overlays/production
run: |
oc apply -k . -n ambient-code
- name: Update frontend environment variables
run: |
oc set env deployment/frontend -n ambient-code -c frontend \
GITHUB_APP_SLUG="ambient-code" \
FEEDBACK_URL="https://forms.gle/7XiWrvo6No922DUz6"
- name: Update operator environment variables
run: |
RELEASE_TAG="${{ needs.release.outputs.new_tag }}"
BUILT="${{ steps.built.outputs.names }}"
ARGS=""
if echo ",$BUILT," | grep -q ",ambient-runner,"; then
ARGS="$ARGS AMBIENT_CODE_RUNNER_IMAGE=quay.io/ambient_code/vteam_claude_runner:${RELEASE_TAG}"
fi
if echo ",$BUILT," | grep -q ",state-sync,"; then
ARGS="$ARGS STATE_SYNC_IMAGE=quay.io/ambient_code/vteam_state_sync:${RELEASE_TAG}"
fi
if [ -n "$ARGS" ]; then
oc set env deployment/agentic-operator -n ambient-code -c agentic-operator $ARGS
fi
- name: Update agent registry ConfigMap with release image tags
run: |
RELEASE_TAG="${{ needs.release.outputs.new_tag }}"
BUILT="${{ steps.built.outputs.names }}"
REGISTRY=$(oc get configmap ambient-agent-registry -n ambient-code \
-o jsonpath='{.data.agent-registry\.json}')
if echo ",$BUILT," | grep -q ",ambient-runner,"; then
REGISTRY=$(echo "$REGISTRY" | sed \
"s|quay.io/ambient_code/vteam_claude_runner[@:][^\"]*|quay.io/ambient_code/vteam_claude_runner:${RELEASE_TAG}|g")
fi
if echo ",$BUILT," | grep -q ",state-sync,"; then
REGISTRY=$(echo "$REGISTRY" | sed \
"s|quay.io/ambient_code/vteam_state_sync[@:][^\"]*|quay.io/ambient_code/vteam_state_sync:${RELEASE_TAG}|g")
fi
oc patch configmap ambient-agent-registry -n ambient-code --type=merge \
-p "{\"data\":{\"agent-registry.json\":$(echo "$REGISTRY" | jq -Rs .)}}"