diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8f8fa2c --- /dev/null +++ b/.github/workflows/release.yaml @@ -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<> "$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 }} diff --git a/CLAUDE.md b/CLAUDE.md index 453a2e7..a8a3787 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. @@ -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 ``` @@ -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`. diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 074c980..c2007bc 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 && git push origin vX.Y.Z + git push origin + 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= cd kustomize/overlays/biosim-gke kubectl kustomize . | kubectl apply -f - ``` -6. **Verify**: +5. **Verify**: ```bash kubectl get pods -n biosim-gke ``` diff --git a/backend/scripts/bump-backend.sh b/backend/scripts/bump-backend.sh new file mode 100755 index 0000000..5be9128 --- /dev/null +++ b/backend/scripts/bump-backend.sh @@ -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"