Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
215 changes: 215 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
name: release

# Builds and pushes service images to GHCR, and (on tag pushes) cuts a GitHub
# Release. Deploy to clusters stays a manual `kubectl apply` step — see
# backend/CLAUDE.md "Deploy".
#
# Triggers:
# - Push a version tag:
# backend-vX.Y.Z -> platform-api + platform-worker @ backend-X.Y.Z
# frontend-vX.Y.Z -> platform-frontend @ frontend-X.Y.Z
# vX.Y.Z -> all three (coordinated release)
# (the git tag carries the leading `v`; the image tag strips it)
# - Manual workflow_dispatch with service + version inputs (no Release cut).
#
# Images are amd64-only here. For a multi-arch (amd64+arm64) build, use the
# local fallback: kustomize/scripts/build_and_push.sh.

on:
push:
tags:
- 'backend-v*'
- 'frontend-v*'
- 'v*'
workflow_dispatch:
inputs:
service:
description: Which images to build
type: choice
options: [backend, frontend, all]
required: true
version:
description: Version X.Y.Z (no leading v)
type: string
required: true

permissions:
contents: write # create GitHub Release
packages: write # push images to GHCR

env:
REGISTRY: ghcr.io
IMAGE_OWNER: biosimulations

jobs:
plan:
runs-on: ubuntu-latest
outputs:
build_backend: ${{ steps.resolve.outputs.build_backend }}
build_frontend: ${{ steps.resolve.outputs.build_frontend }}
version: ${{ steps.resolve.outputs.version }}
make_release: ${{ steps.resolve.outputs.make_release }}
steps:
- uses: actions/checkout@v4

- name: Resolve service + version from trigger
id: resolve
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
service='${{ inputs.service }}'
version='${{ inputs.version }}'
make_release=false
else
tag='${{ github.ref_name }}'
case "$tag" in
backend-v*) service=backend; version="${tag#backend-v}" ;;
frontend-v*) service=frontend; version="${tag#frontend-v}" ;;
v*) service=all; version="${tag#v}" ;;
*) echo "::error::Unrecognized tag '$tag'"; exit 1 ;;
esac
make_release=true
fi

if ! echo "$version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Version '$version' is not X.Y.Z"; exit 1
fi

case "$service" in
backend) build_backend=true; build_frontend=false ;;
frontend) build_backend=false; build_frontend=true ;;
all) build_backend=true; build_frontend=true ;;
*) echo "::error::Unknown service '$service'"; exit 1 ;;
esac

{
echo "build_backend=$build_backend"
echo "build_frontend=$build_frontend"
echo "version=$version"
echo "make_release=$make_release"
} >> "$GITHUB_OUTPUT"
echo "Resolved: service=$service version=$version release=$make_release"

- name: Guard — tag version matches source files
run: |
set -euo pipefail
version='${{ steps.resolve.outputs.version }}'
if [ "${{ steps.resolve.outputs.build_backend }}" = "true" ]; then
src=$(grep -oE '[0-9]+\.[0-9]+\.[0-9]+' backend/biosim_server/version.py | head -1)
if [ "$src" != "$version" ]; then
echo "::error::backend version.py is $src but release version is $version"; exit 1
fi
fi
if [ "${{ steps.resolve.outputs.build_frontend }}" = "true" ]; then
src=$(jq -r .version frontend/package.json)
if [ "$src" != "$version" ]; then
echo "::error::frontend package.json is $src but release version is $version"; exit 1
fi
fi

backend:
needs: plan
if: needs.plan.outputs.build_backend == 'true'
runs-on: ubuntu-latest
strategy:
matrix:
service: [api, worker]
steps:
- uses: actions/checkout@v4

- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push platform-${{ matrix.service }}
uses: docker/build-push-action@v6
with:
context: backend
file: backend/Dockerfile.${{ matrix.service }}
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/platform-${{ matrix.service }}:backend-${{ needs.plan.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max

frontend:
needs: plan
if: needs.plan.outputs.build_frontend == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- name: Install node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Build Nuxt output
run: |
npm ci
npm run build

- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push platform-frontend
uses: docker/build-push-action@v6
with:
context: frontend
file: frontend/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/platform-frontend:frontend-${{ needs.plan.outputs.version }}

release:
needs: [plan, backend, frontend]
# Run after the builds that applied. always() lets this run even when one
# build job was skipped (e.g. backend-only tag skips frontend); the !failure
# / !cancelled checks ensure any build that DID run succeeded first.
if: ${{ always() && needs.plan.outputs.make_release == 'true' && !failure() && !cancelled() }}
runs-on: ubuntu-latest
steps:
- name: Compose image list
id: images
run: |
set -euo pipefail
v='${{ needs.plan.outputs.version }}'
lines=""
if [ "${{ needs.plan.outputs.build_backend }}" = "true" ]; then
lines="${lines}- \`${REGISTRY}/${IMAGE_OWNER}/platform-api:backend-${v}\`"$'\n'
lines="${lines}- \`${REGISTRY}/${IMAGE_OWNER}/platform-worker:backend-${v}\`"$'\n'
fi
if [ "${{ needs.plan.outputs.build_frontend }}" = "true" ]; then
lines="${lines}- \`${REGISTRY}/${IMAGE_OWNER}/platform-frontend:frontend-${v}\`"$'\n'
fi
{
echo "body<<EOF"
echo "Published images (linux/amd64):"
echo ""
printf '%s' "$lines"
echo "EOF"
} >> "$GITHUB_OUTPUT"

- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
generate_release_notes: true
body: ${{ steps.images.outputs.body }}
18 changes: 17 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ platform/

Services version **independently**:

- **Backend** — `backend/biosim_server/version.py`. Git tag `backend-vX.Y.Z`. Image tag `backend-X.Y.Z` (rebuilds `platform-api` + `platform-worker`).
- **Backend** — `backend/biosim_server/version.py` (+ `backend/pyproject.toml`, kept in lockstep). Git tag `backend-vX.Y.Z`. Image tag `backend-X.Y.Z` (rebuilds `platform-api` + `platform-worker`). `backend/scripts/bump-backend.sh [patch|minor|major|X.Y.Z]` bumps both files + commits + tags from any branch.
- **Frontend** — `frontend/package.json`. Git tag `frontend-vX.Y.Z`. Image tag `frontend-X.Y.Z`. `frontend/scripts/bump-frontend.sh` bumps + commits + tags from any branch.
- **Coordinated releases** — plain `vX.Y.Z` tags, **only on `main`**. Bump both version files to the same `X.Y.Z`, rebuild all three images at that version.

Expand All @@ -55,6 +55,21 @@ Each image is a multi-arch manifest (`linux/amd64` + `linux/arm64`) at a single
- `frontend [V]` — builds `platform-frontend` at `frontend-V`. V defaults to `frontend/package.json`. Runs `npm ci && npm run build` first; the image is runtime-only. Needs Node 22 on the host.
- `all V` — coordinated release; V required. Does NOT write versions into source files — bump those first.

This script is the **local / multi-arch fallback**. The normal path is the `release` workflow below, which builds amd64-only in CI. Use the script when you need an `arm64` layer (e.g. for the `biosim-local` overlay) or want to publish without pushing a tag.

## Release automation

`.github/workflows/release.yaml` builds + pushes images to GHCR and cuts a GitHub Release. **amd64-only.** Deploy to clusters stays a manual `kubectl apply` (see `backend/CLAUDE.md` → Deploy).

| Trigger | Builds | Image tag(s) | Release? |
|---|---|---|---|
| push tag `backend-vX.Y.Z` | api + worker | `backend-X.Y.Z` | yes |
| push tag `frontend-vX.Y.Z` | frontend | `frontend-X.Y.Z` | yes |
| push tag `vX.Y.Z` (on `main`) | all three | `backend-X.Y.Z` + `frontend-X.Y.Z` | yes |
| `workflow_dispatch` (service + version inputs) | per input | per input | no |

The git tag carries the `v`; the image tag strips it. A `plan` job guards that the tag's version matches the source version file(s) before any build runs. `workflow_dispatch` publishes images only (no Release) — use it for ad-hoc/re-builds.

## Local development

```
Expand All @@ -71,6 +86,7 @@ All three workflows run on every PR (path filters were removed — cross-service
- `.github/workflows/ci.yaml` (`backend-ci`) — backend test suite, ruff, mypy.
- `.github/workflows/frontend-ci.yaml` — `npm run lint` + `npm run typecheck`.
- `.github/workflows/smoke.yaml` — joint local-stack smoke. Boots compose infra + backend + frontend, verifies they talk to each other. Does not run a real biosimulations.org simulation.
- `.github/workflows/release.yaml` — tag/dispatch-triggered image build + push to GHCR + GitHub Release. See **Release automation** above. Does NOT run on PRs.

`lefthook.yml` runs the lint/typecheck/ruff/mypy checks locally on pre-commit and pre-push so most failures are caught before CI. Install with `lefthook install` after `brew install lefthook`.

Expand Down
33 changes: 21 additions & 12 deletions backend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,27 +253,36 @@ Backend release steps (run from repo root unless noted):
> considerations" for the full rationale and the longer-term `workflow.patched`
> pattern.

1. **Bump version** in `backend/biosim_server/version.py` and `backend/pyproject.toml`
2. **Update kustomize overlays** — set `newTag` in each overlay's `kustomization.yaml`:
- `kustomize/overlays/biosim-gke/kustomization.yaml` (amd64)
- `kustomize/overlays/biosim-rke/kustomization.yaml` (amd64)
- `kustomize/overlays/biosim-local/kustomization.yaml` (arm64)
3. **Build and push Docker images** (builds api + worker for amd64 + arm64; pushes to `ghcr.io/biosimulations/platform-{api,worker}`):
1. **Bump version + tag** (bumps `version.py` + `pyproject.toml`, commits, tags `backend-vX.Y.Z`):
```bash
bash kustomize/scripts/build_and_push.sh
bash backend/scripts/bump-backend.sh patch # or minor|major|X.Y.Z
```
4. **Commit, tag, and push**:
2. **Update kustomize overlays** — set `newTag: backend-X.Y.Z` in each overlay's `kustomization.yaml`:
- `kustomize/overlays/biosim-gke/kustomization.yaml`
- `kustomize/overlays/biosim-rke/kustomization.yaml`
- `kustomize/overlays/biosim-local/kustomization.yaml` (see arm64 note below)

Commit these alongside the bump (the script commits only the version files).
3. **Push the branch + tag** to build and publish images via CI:
```bash
git add -A && git commit -m "bump version to X.Y.Z and deploy"
git tag vX.Y.Z && git push origin <branch> && git push origin vX.Y.Z
git push origin <branch>
git push origin backend-vX.Y.Z
```
5. **Apply to cluster** (example for biosim-gke):
The `release` workflow (`.github/workflows/release.yaml`) builds + pushes
`platform-{api,worker}:backend-X.Y.Z` to GHCR (**amd64-only**) and cuts a
GitHub Release. Watch it under the repo's Actions tab.

> **arm64 / `biosim-local`:** CI publishes amd64 only. If you need an arm64
> layer at `backend-X.Y.Z` (e.g. for the `biosim-local` overlay on Apple
> silicon), build it locally instead: `bash kustomize/scripts/build_and_push.sh backend X.Y.Z`
> (multi-arch). Or keep `biosim-local` pinned to an older multi-arch tag.
4. **Apply to cluster** (example for biosim-gke) once images are published:
```bash
export KUBECONFIG=<path-to-kubeconfig>
cd kustomize/overlays/biosim-gke
kubectl kustomize . | kubectl apply -f -
```
6. **Verify**:
5. **Verify**:
```bash
kubectl get pods -n biosim-gke
```
Expand Down
70 changes: 70 additions & 0 deletions backend/scripts/bump-backend.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
#
# Bump the backend version in BOTH backend/biosim_server/version.py and
# backend/pyproject.toml (kept in lockstep), commit, and tag backend-vX.Y.Z
# on the current branch.
#
# Usage:
# backend/scripts/bump-backend.sh [patch|minor|major] (default: patch)
# backend/scripts/bump-backend.sh X.Y.Z (explicit version)
#
# Run from anywhere; the script locates the repo root via git.
# Does not push — run `git push && git push origin backend-vX.Y.Z`
# yourself once you're happy with the bump. Pushing the tag triggers the
# `release` workflow, which builds + pushes the images and cuts a Release.

set -euo pipefail

REPO_ROOT=$(git rev-parse --show-toplevel)
VERSION_PY="$REPO_ROOT/backend/biosim_server/version.py"
PYPROJECT="$REPO_ROOT/backend/pyproject.toml"

for f in "$VERSION_PY" "$PYPROJECT"; do
[[ -f "$f" ]] || { echo "error: $f not found" >&2; exit 1; }
done

if [[ -n "$(git -C "$REPO_ROOT" status --porcelain "$VERSION_PY" "$PYPROJECT")" ]]; then
echo "error: version.py or pyproject.toml has uncommitted changes; commit or stash first" >&2
exit 1
fi

OLD=$(grep -oE '[0-9]+\.[0-9]+\.[0-9]+' "$VERSION_PY" | head -1)
[[ -n "$OLD" ]] || { echo "error: could not read current version from version.py" >&2; exit 1; }

LEVEL="${1:-patch}"
if [[ "$LEVEL" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
NEW="$LEVEL"
else
IFS='.' read -r MAJ MIN PAT <<< "$OLD"
case "$LEVEL" in
major) NEW="$((MAJ + 1)).0.0" ;;
minor) NEW="${MAJ}.$((MIN + 1)).0" ;;
patch) NEW="${MAJ}.${MIN}.$((PAT + 1))" ;;
*) echo "error: expected patch|minor|major or X.Y.Z, got '$LEVEL'" >&2; exit 1 ;;
esac
fi

TAG="backend-v$NEW"
if git -C "$REPO_ROOT" rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "error: tag $TAG already exists" >&2
exit 1
fi

# version.py is a single line with no trailing newline — preserve that.
printf '__version__ = "%s"' "$NEW" > "$VERSION_PY"

# pyproject.toml: bump the [tool.poetry] version line.
perl -0pi -e "s/^version = \"\Q$OLD\E\"/version = \"$NEW\"/m" "$PYPROJECT"

git -C "$REPO_ROOT" add "$VERSION_PY" "$PYPROJECT"
git -C "$REPO_ROOT" commit -m "Bump backend to $NEW"
git -C "$REPO_ROOT" tag "$TAG"

BRANCH=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)
echo
echo "Bumped backend $OLD -> $NEW on branch $BRANCH"
echo "Created tag: $TAG"
echo
echo "To publish (triggers the release workflow):"
echo " git push origin $BRANCH"
echo " git push origin $TAG"
Loading