name: Release on: push: tags: - "v*.*.*" env: REGISTRY: ghcr.io IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }}/warmbly jobs: validate-tag: name: Validate Tag runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} major: ${{ steps.version.outputs.major }} minor: ${{ steps.version.outputs.minor }} steps: - name: Extract version id: version run: | TAG="${{ github.ref_name }}" VERSION="${TAG#v}" echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "major=$(echo "$VERSION" | cut -d. -f1)" >> $GITHUB_OUTPUT echo "minor=$(echo "$VERSION" | cut -d. -f1-2)" >> $GITHUB_OUTPUT echo "Release version: ${VERSION}" # Go services: the Dockerfiles build on $BUILDPLATFORM and cross-compile to # each target arch, so one amd64 runner produces both platforms without QEMU. build-go: name: Build ${{ matrix.service }} needs: validate-tag strategy: fail-fast: false matrix: service: [backend, consumer, worker, forms, updater, cli] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx 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 uses: docker/build-push-action@v6 with: context: . file: deploy/docker/${{ matrix.service }}.Dockerfile push: true build-args: | VERSION=${{ github.ref_name }} COMMIT=${{ github.sha }} BUILT_AT=${{ github.event.head_commit.timestamp }} tags: | ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=${{ matrix.service }} cache-to: type=gha,mode=max,scope=${{ matrix.service }} # Frontends (web dashboard, admin panel): the pnpm build runs once on the # build platform and its static output is served from nginx, so a single # buildx build produces both arches without a per-arch rebuild. build-frontend: name: Build ${{ matrix.service }} needs: validate-tag strategy: fail-fast: false matrix: service: [web, admin] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx 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 uses: docker/build-push-action@v6 with: context: ./${{ matrix.service }} file: ./${{ matrix.service }}/Dockerfile push: true tags: | ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=${{ matrix.service }} cache-to: type=gha,mode=max,scope=${{ matrix.service }} # Rust (tracking) and Elixir (realtime) have no cross-compiler; an emulated # arm64 build under QEMU runs for an hour or more. Build each arch on a # native runner and merge the digests into one manifest (the # Docker-documented multi-runner pattern). build-native: name: Build ${{ matrix.service }} (${{ matrix.platform }}) needs: validate-tag strategy: fail-fast: false matrix: service: [tracking, realtime] platform: [linux/amd64, linux/arm64] runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Prepare platform pair id: prep run: echo "pair=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT" env: PLATFORM: ${{ matrix.platform }} - name: Set up Docker Buildx 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 by digest id: build uses: docker/build-push-action@v6 with: context: ${{ matrix.service == 'tracking' && './tracking' || '.' }} file: ${{ matrix.service == 'tracking' && './tracking/Dockerfile' || format('deploy/docker/{0}.Dockerfile', matrix.service) }} platforms: ${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }} cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }} outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true - name: Export digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v4 with: name: digests-${{ matrix.service }}-${{ steps.prep.outputs.pair }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 merge-native: name: Merge ${{ matrix.service }} manifest needs: [validate-tag, build-native] strategy: fail-fast: false matrix: service: [tracking, realtime] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Download digests uses: actions/download-artifact@v4 with: path: /tmp/digests pattern: digests-${{ matrix.service }}-* merge-multiple: true - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Create manifest list and push working-directory: /tmp/digests run: | docker buildx imagetools create \ -t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }} \ -t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }} \ -t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }} \ -t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod \ $(printf '${{ env.IMAGE_PREFIX }}/${{ matrix.service }}@sha256:%s ' *) # The `warmbly` CLI is a plain static binary, so it cross-compiles for every # platform on one runner. # # Assets are named WITHOUT the version, so # releases/latest/download/warmbly_linux_amd64.tar.gz always resolves. That is # what lets the install script find the newest build with no GitHub API call, # which matters because the unauthenticated API is rate limited and a curl # installer that fails on a busy CI runner is not an installer. build-cli: name: Build CLI needs: validate-tag runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: go.mod cache: true - name: Cross-compile and package env: VERSION: ${{ github.ref_name }} COMMIT: ${{ github.sha }} BUILT_AT: ${{ github.event.head_commit.timestamp }} run: | set -euo pipefail ./scripts/build-cli.sh dist - name: Upload uses: actions/upload-artifact@v4 with: name: warmbly-cli path: dist/ retention-days: 1 create-release: name: Create GitHub Release needs: [validate-tag, build-go, build-frontend, merge-native, build-cli] runs-on: ubuntu-latest permissions: contents: write # The manifest step logs in to GHCR and inspects each published image; # without read access that fails on a private package. packages: read steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Download the CLI binaries uses: actions/download-artifact@v4 with: name: warmbly-cli path: /tmp/cli # The installer verifies what it pulled against this file, so it is what # makes "curl | sh" checkable after the fact rather than only before it. # One line per service, because the thing that reads it is a POSIX shell. - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Publish the image manifest env: TAG: ${{ github.ref_name }} run: | set -euo pipefail { printf '{\n' printf ' "tag": "%s",\n' "$TAG" printf ' "registry": "%s",\n' "$IMAGE_PREFIX" printf ' "images": {\n' first=1 for s in backend consumer worker forms updater web admin tracking realtime; do digest=$(docker buildx imagetools inspect "$IMAGE_PREFIX/$s:$TAG" \ --format '{{json .Manifest.Digest}}' | tr -d '"') [ "$first" = 1 ] || printf ',\n' first=0 printf ' "%s": "%s"' "$s" "$digest" done printf '\n }\n}\n' } > /tmp/images.json cat /tmp/images.json - name: Generate release notes env: GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_REPOSITORY: ${{ github.repository }} TAG: ${{ github.ref_name }} run: | .github/scripts/release-notes.sh "$TAG" > /tmp/changelog.md cat /tmp/changelog.md - name: Assemble release body run: | { cat /tmp/changelog.md cat <<'EOF' ## Install ``` curl -fsSL https://warmbly.com/install.sh | sh -s -- --version ${{ github.ref_name }} ``` Add `--wizard` to be asked where each store lives, what is kept and for how long, and how it is backed up. ## The warmbly CLI ``` curl -fsSL https://warmbly.com/cli.sh | sh # macOS, Linux irm https://warmbly.com/cli.ps1 | iex # Windows brew install warmbly/tap/warmbly # Homebrew scoop install warmbly # Scoop ``` Or take an archive below and unpack it yourself; `checksums.txt` verifies every one of them. Already installed? `warmbly upgrade`. `images.json` below lists the manifest digest of every image in this release. The installer checks what it pulled against it, and you can too: ``` docker image inspect ${{ env.IMAGE_PREFIX }}/backend:${{ github.ref_name }} --format '{{index .RepoDigests 0}}' ``` ## Docker Images All images are available at `ghcr.io/${{ github.repository_owner }}/warmbly`: | Service | Image | |---------|-------| | Backend | `${{ env.IMAGE_PREFIX }}/backend:${{ github.ref_name }}` | | Consumer | `${{ env.IMAGE_PREFIX }}/consumer:${{ github.ref_name }}` | | Worker | `${{ env.IMAGE_PREFIX }}/worker:${{ github.ref_name }}` | | Tracking | `${{ env.IMAGE_PREFIX }}/tracking:${{ github.ref_name }}` | | Realtime | `${{ env.IMAGE_PREFIX }}/realtime:${{ github.ref_name }}` | | Dashboard (web) | `${{ env.IMAGE_PREFIX }}/web:${{ github.ref_name }}` | | Admin | `${{ env.IMAGE_PREFIX }}/admin:${{ github.ref_name }}` | | Forms | `${{ env.IMAGE_PREFIX }}/forms:${{ github.ref_name }}` | | Updater | `${{ env.IMAGE_PREFIX }}/updater:${{ github.ref_name }}` | | CLI | `${{ env.IMAGE_PREFIX }}/cli:${{ github.ref_name }}` | ## Deployment This release will be automatically deployed to production. EOF } > /tmp/release-body.md # The formula and manifest are generated with the archives, so their # checksums can never drift from what they describe. Pushing them is # skipped, loudly, when the tap token is not configured: a release must # not fail because a downstream package repo is not set up yet. - name: Publish the Homebrew formula and Scoop manifest env: TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} TAG: ${{ github.ref_name }} run: | set -euo pipefail if [ -z "${TAP_TOKEN:-}" ]; then echo "HOMEBREW_TAP_TOKEN is not set; skipping the tap push." echo "The formula and manifest are still attached to the release." exit 0 fi # A prerelease must never become the default `brew install`. case "$TAG" in *-*) echo "$TAG is a prerelease; not updating the taps."; exit 0 ;; esac git config --global user.name "warmbly-release" git config --global user.email "release@warmbly.com" git clone --depth 1 \ "https://x-access-token:${TAP_TOKEN}@github.com/warmbly/homebrew-tap.git" /tmp/tap mkdir -p /tmp/tap/Formula /tmp/tap/bucket cp /tmp/cli/warmbly.rb /tmp/tap/Formula/warmbly.rb cp /tmp/cli/warmbly.json /tmp/tap/bucket/warmbly.json cd /tmp/tap git add Formula/warmbly.rb bucket/warmbly.json if git diff --cached --quiet; then echo "the tap already describes $TAG" else git commit -m "warmbly $TAG" git push echo "pushed warmbly $TAG to the tap" fi - name: Create Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} body_path: /tmp/release-body.md files: | /tmp/images.json /tmp/cli/* draft: false prerelease: ${{ contains(github.ref_name, '-') }}