chore(release): v0.3.14 #191
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build, push & deploy | |
| # On every push to main, build images for whatever changed (api / admin / app) | |
| # and push to GHCR under ghcr.io/cleanslice/ranch-* tagged with the commit | |
| # SHA only — `:latest` and the rolling deploy are reserved for tag pushes | |
| # (v*), which release-platform.yaml dispatches automatically after a version | |
| # bump. This keeps `:latest` deterministic and avoids the push-vs-tag race | |
| # where a parallel main-branch build would overwrite the tag-run's :latest | |
| # with a 0.0.0-<sha> image and then deploy that stale tag. | |
| # | |
| # Required repo secrets: | |
| # GHCR_PAT — GitHub PAT with write:packages + read:packages | |
| # KUBECONFIG_B64 — base64 of ranch cluster kubeconfig | |
| # | |
| # Required repo variables (Settings → Variables): | |
| # API_URL — e.g. https://api.ranch.cleanslice.org (baked into admin + app Nuxt build) | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - 'api/**' | |
| - 'admin/**' | |
| - 'app/**' | |
| - 'runtime/**' | |
| - 'rancher/**' | |
| tags: | |
| - 'v*' | |
| workflow_dispatch: | |
| inputs: | |
| target: | |
| description: 'Which app to build (api, admin, app, all)' | |
| required: true | |
| default: 'all' | |
| type: choice | |
| options: [api, admin, app, all] | |
| env: | |
| REGISTRY: ghcr.io | |
| REGISTRY_USER: dmitriyzhuk | |
| IMAGE_PREFIX: ghcr.io/cleanslice/ranch | |
| jobs: | |
| changes: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| api: ${{ steps.tag.outputs.all || steps.filter.outputs.api || steps.manual.outputs.api }} | |
| admin: ${{ steps.tag.outputs.all || steps.filter.outputs.admin || steps.manual.outputs.admin }} | |
| app: ${{ steps.tag.outputs.all || steps.filter.outputs.app || steps.manual.outputs.app }} | |
| version: ${{ steps.version.outputs.value }} | |
| tag: ${{ steps.version.outputs.tag }} | |
| # Release runs are the only ones that publish :latest and roll out | |
| # deployments. Push-on-main builds keep their :sha images on GHCR for | |
| # debugging but do not touch the live cluster. | |
| is_release: ${{ steps.tag.outputs.all || 'false' }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - id: tag | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| run: echo "all=true" >> $GITHUB_OUTPUT | |
| - id: version | |
| run: | | |
| if [[ "${{ github.ref }}" == refs/tags/v* ]]; then | |
| VALUE="${GITHUB_REF_NAME#v}" | |
| echo "value=${VALUE}" >> $GITHUB_OUTPUT | |
| echo "tag=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT | |
| else | |
| echo "value=0.0.0-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT | |
| echo "tag=${GITHUB_SHA}" >> $GITHUB_OUTPUT | |
| fi | |
| - id: filter | |
| if: github.event_name == 'push' && !startsWith(github.ref, 'refs/tags/') | |
| uses: dorny/paths-filter@v3 | |
| with: | |
| filters: | | |
| api: | |
| - 'api/**' | |
| - 'runtime/**' | |
| - 'rancher/**' | |
| admin: | |
| - 'admin/**' | |
| app: | |
| - 'app/**' | |
| - id: manual | |
| if: github.event_name == 'workflow_dispatch' | |
| run: | | |
| case "${{ inputs.target }}" in | |
| api) echo "api=true" >> $GITHUB_OUTPUT; echo "admin=false" >> $GITHUB_OUTPUT; echo "app=false" >> $GITHUB_OUTPUT ;; | |
| admin) echo "api=false" >> $GITHUB_OUTPUT; echo "admin=true" >> $GITHUB_OUTPUT; echo "app=false" >> $GITHUB_OUTPUT ;; | |
| app) echo "api=false" >> $GITHUB_OUTPUT; echo "admin=false" >> $GITHUB_OUTPUT; echo "app=true" >> $GITHUB_OUTPUT ;; | |
| all) echo "api=true" >> $GITHUB_OUTPUT; echo "admin=true" >> $GITHUB_OUTPUT; echo "app=true" >> $GITHUB_OUTPUT ;; | |
| esac | |
| build-api: | |
| needs: changes | |
| if: needs.changes.outputs.api == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # Bake runtime/ source + deps into the api image so paddock CLI's | |
| # dynamic-import of <repo>/runtime/src/runtime.ts works in prod. | |
| # Runtime lives in a sibling repo, so we clone it into the build | |
| # context root. | |
| - name: Checkout runtime | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: CleanSlice/runtime | |
| token: ${{ secrets.GHCR_PAT }} | |
| path: runtime | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ env.REGISTRY_USER }} | |
| password: ${{ secrets.GHCR_PAT }} | |
| - uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ./api/Dockerfile | |
| platforms: linux/amd64 | |
| push: true | |
| build-args: | | |
| RANCH_VERSION=${{ needs.changes.outputs.version }} | |
| tags: | | |
| ${{ env.IMAGE_PREFIX }}-api:${{ needs.changes.outputs.tag }} | |
| ${{ needs.changes.outputs.is_release == 'true' && format('{0}-api:latest', env.IMAGE_PREFIX) || '' }} | |
| cache-from: type=gha,scope=ranch-api | |
| cache-to: type=gha,mode=max,scope=ranch-api | |
| build-admin: | |
| needs: changes | |
| if: needs.changes.outputs.admin == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ env.REGISTRY_USER }} | |
| password: ${{ secrets.GHCR_PAT }} | |
| - uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ./admin/Dockerfile | |
| platforms: linux/amd64 | |
| push: true | |
| build-args: | | |
| API_URL=${{ vars.API_URL }} | |
| RANCH_VERSION=${{ needs.changes.outputs.version }} | |
| tags: | | |
| ${{ env.IMAGE_PREFIX }}-admin:${{ needs.changes.outputs.tag }} | |
| ${{ needs.changes.outputs.is_release == 'true' && format('{0}-admin:latest', env.IMAGE_PREFIX) || '' }} | |
| cache-from: type=gha,scope=ranch-admin | |
| cache-to: type=gha,mode=max,scope=ranch-admin | |
| build-app: | |
| needs: changes | |
| if: needs.changes.outputs.app == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v3 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ env.REGISTRY_USER }} | |
| password: ${{ secrets.GHCR_PAT }} | |
| - uses: docker/build-push-action@v6 | |
| with: | |
| context: . | |
| file: ./app/Dockerfile | |
| platforms: linux/amd64 | |
| push: true | |
| build-args: | | |
| API_URL=${{ vars.API_URL }} | |
| RANCH_VERSION=${{ needs.changes.outputs.version }} | |
| tags: | | |
| ${{ env.IMAGE_PREFIX }}-app:${{ needs.changes.outputs.tag }} | |
| ${{ needs.changes.outputs.is_release == 'true' && format('{0}-app:latest', env.IMAGE_PREFIX) || '' }} | |
| cache-from: type=gha,scope=ranch-app | |
| cache-to: type=gha,mode=max,scope=ranch-app | |
| deploy: | |
| needs: [changes, build-api, build-admin, build-app] | |
| # Deploy only from tag pushes (release runs). Push-on-main builds publish | |
| # their :sha image but never touch the live cluster — this avoids racing | |
| # with the tag-run that should be the authoritative source of :latest. | |
| if: | | |
| always() && | |
| needs.changes.outputs.is_release == 'true' && | |
| (needs.build-api.result == 'success' || | |
| needs.build-admin.result == 'success' || | |
| needs.build-app.result == 'success') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Configure kubectl | |
| run: | | |
| mkdir -p ~/.kube | |
| echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > ~/.kube/config | |
| chmod 600 ~/.kube/config | |
| kubectl version --client=true | |
| - name: Rollout ranch-api | |
| if: needs.changes.outputs.api == 'true' && needs.build-api.result == 'success' | |
| env: | |
| DEPLOY: ranch-api | |
| NS: platform | |
| run: | | |
| kubectl -n "$NS" rollout restart deploy/"$DEPLOY" | |
| if ! kubectl -n "$NS" rollout status deploy/"$DEPLOY" --timeout=10m; then | |
| echo "::group::describe deploy/$DEPLOY" | |
| kubectl -n "$NS" describe deploy/"$DEPLOY" || true | |
| echo "::endgroup::" | |
| echo "::group::pods for $DEPLOY" | |
| { kubectl -n "$NS" get pods -o wide | head -1; kubectl -n "$NS" get pods -o wide | grep "^$DEPLOY-" || true; } | |
| echo "::endgroup::" | |
| echo "::group::describe pods" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| kubectl -n "$NS" describe "$p" | tail -80 || true | |
| echo "---" | |
| done | |
| echo "::endgroup::" | |
| echo "::group::recent namespace events" | |
| kubectl -n "$NS" get events --sort-by=.lastTimestamp | tail -50 || true | |
| echo "::endgroup::" | |
| echo "::group::pod logs (current)" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| echo "=== $p ===" | |
| kubectl -n "$NS" logs "$p" --tail=200 --all-containers --timestamps || true | |
| done | |
| echo "::endgroup::" | |
| echo "::group::pod logs (previous)" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| echo "=== $p ===" | |
| kubectl -n "$NS" logs "$p" --previous --tail=200 --all-containers --timestamps || true | |
| done | |
| echo "::endgroup::" | |
| exit 1 | |
| fi | |
| - name: Rollout ranch-admin | |
| if: needs.changes.outputs.admin == 'true' && needs.build-admin.result == 'success' | |
| env: | |
| DEPLOY: ranch-admin | |
| NS: platform | |
| run: | | |
| kubectl -n "$NS" rollout restart deploy/"$DEPLOY" | |
| if ! kubectl -n "$NS" rollout status deploy/"$DEPLOY" --timeout=10m; then | |
| echo "::group::describe deploy/$DEPLOY" | |
| kubectl -n "$NS" describe deploy/"$DEPLOY" || true | |
| echo "::endgroup::" | |
| echo "::group::pods for $DEPLOY" | |
| { kubectl -n "$NS" get pods -o wide | head -1; kubectl -n "$NS" get pods -o wide | grep "^$DEPLOY-" || true; } | |
| echo "::endgroup::" | |
| echo "::group::describe pods" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| kubectl -n "$NS" describe "$p" | tail -80 || true | |
| echo "---" | |
| done | |
| echo "::endgroup::" | |
| echo "::group::recent namespace events" | |
| kubectl -n "$NS" get events --sort-by=.lastTimestamp | tail -50 || true | |
| echo "::endgroup::" | |
| echo "::group::pod logs (current)" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| echo "=== $p ===" | |
| kubectl -n "$NS" logs "$p" --tail=200 --all-containers --timestamps || true | |
| done | |
| echo "::endgroup::" | |
| echo "::group::pod logs (previous)" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| echo "=== $p ===" | |
| kubectl -n "$NS" logs "$p" --previous --tail=200 --all-containers --timestamps || true | |
| done | |
| echo "::endgroup::" | |
| exit 1 | |
| fi | |
| - name: Rollout ranch-app | |
| if: needs.changes.outputs.app == 'true' && needs.build-app.result == 'success' | |
| env: | |
| DEPLOY: ranch-app | |
| NS: platform | |
| run: | | |
| kubectl -n "$NS" rollout restart deploy/"$DEPLOY" | |
| if ! kubectl -n "$NS" rollout status deploy/"$DEPLOY" --timeout=10m; then | |
| echo "::group::describe deploy/$DEPLOY" | |
| kubectl -n "$NS" describe deploy/"$DEPLOY" || true | |
| echo "::endgroup::" | |
| echo "::group::pods for $DEPLOY" | |
| { kubectl -n "$NS" get pods -o wide | head -1; kubectl -n "$NS" get pods -o wide | grep "^$DEPLOY-" || true; } | |
| echo "::endgroup::" | |
| echo "::group::describe pods" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| kubectl -n "$NS" describe "$p" | tail -80 || true | |
| echo "---" | |
| done | |
| echo "::endgroup::" | |
| echo "::group::recent namespace events" | |
| kubectl -n "$NS" get events --sort-by=.lastTimestamp | tail -50 || true | |
| echo "::endgroup::" | |
| echo "::group::pod logs (current)" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| echo "=== $p ===" | |
| kubectl -n "$NS" logs "$p" --tail=200 --all-containers --timestamps || true | |
| done | |
| echo "::endgroup::" | |
| echo "::group::pod logs (previous)" | |
| for p in $(kubectl -n "$NS" get pods -o name | grep "/$DEPLOY-" || true); do | |
| echo "=== $p ===" | |
| kubectl -n "$NS" logs "$p" --previous --tail=200 --all-containers --timestamps || true | |
| done | |
| echo "::endgroup::" | |
| exit 1 | |
| fi | |
| - name: Smoke test | |
| if: | | |
| needs.changes.outputs.api == 'true' || | |
| needs.changes.outputs.admin == 'true' || | |
| needs.changes.outputs.app == 'true' | |
| run: | | |
| # `kubectl rollout status` returns as soon as the pod is Ready, but | |
| # Traefik takes a few seconds to swap its upstream — so a curl | |
| # immediately after rollout occasionally hits the old (now-gone) | |
| # endpoint and gets a 503. Retry with a short backoff instead of | |
| # racing the ingress. | |
| probe() { | |
| local name="$1" url="$2" | |
| local code | |
| for i in 1 2 3 4 5 6 7 8 9 10 11 12; do | |
| code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$url" || echo "000") | |
| if [ "$code" = "200" ]; then | |
| echo "$name=$code" | |
| return 0 | |
| fi | |
| echo "attempt $i: $name=$code, retrying in 5s..." | |
| sleep 5 | |
| done | |
| echo "$name=$code (gave up after 12 attempts)" | |
| return 1 | |
| } | |
| probe api https://api.ranch.cleanslice.org/setup/status | |
| probe admin https://admin.ranch.cleanslice.org/login | |
| probe app https://ranch.cleanslice.org/ |