Release #164
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: Release | |
| # Auto-release on merge to main: when the CI workflow succeeds on a push to | |
| # main, compute the next semantic version from the merged commits, build and | |
| # publish the container images, smoke-boot the runtime image against a real | |
| # Postgres so a broken build can't ship, then cut the GitHub Release with | |
| # cross-compiled CLI binaries and move the `latest` image tag. | |
| # | |
| # The image set is runtime, dashboard, customer-app and the bundled | |
| # supportdesk-agent — the last one is what `af-stack init` wires into a | |
| # scaffolded app's own docker-compose.yml. | |
| # The smoke job also scaffolds an app with the freshly built CLI and runs | |
| # `npm start` in it, proving the published images boot a bundled backend the | |
| # app can actually talk to before anything is tagged. | |
| # | |
| # Manual runs are supported via workflow_dispatch (e.g. to re-cut after a | |
| # transient failure). Use dry_run to compute the version without publishing. | |
| on: | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: [completed] | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: "Compute the version but do not publish" | |
| type: boolean | |
| default: false | |
| concurrency: | |
| group: release | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| jobs: | |
| prepare: | |
| name: Compute version | |
| # Only release after CI passed on a push to main (or a manual dispatch). | |
| if: >- | |
| github.event_name == 'workflow_dispatch' || | |
| (github.event.workflow_run.conclusion == 'success' && | |
| github.event.workflow_run.head_branch == 'main' && | |
| github.event.workflow_run.event == 'push') | |
| runs-on: ubuntu-latest | |
| outputs: | |
| should_release: ${{ steps.compute.outputs.should_release }} | |
| version: ${{ steps.compute.outputs.version }} | |
| tag: ${{ steps.compute.outputs.tag }} | |
| prerelease: ${{ steps.compute.outputs.prerelease }} | |
| sha: ${{ steps.sha.outputs.sha }} | |
| steps: | |
| - id: sha | |
| run: echo "sha=${{ github.event.workflow_run.head_sha || github.sha }}" >> "$GITHUB_OUTPUT" | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ steps.sha.outputs.sha }} | |
| fetch-depth: 0 | |
| - id: compute | |
| name: Next version from conventional commits | |
| run: | | |
| set -euo pipefail | |
| # Base the next version on the latest STABLE tag (vX.Y.Z). Pre-release | |
| # tags like v0.1.0-rc.1 are deliberately ignored so the first real | |
| # auto-release starts a clean stable series. | |
| last_stable="$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true)" | |
| if [ -n "$last_stable" ]; then | |
| base="${last_stable#v}" | |
| range="${last_stable}..HEAD" | |
| else | |
| base="0.0.0" | |
| range="HEAD" | |
| fi | |
| IFS=. read -r MA MI PA <<< "$base" | |
| subjects="$(git log --format='%s' $range)" | |
| bodies="$(git log --format='%B' $range)" | |
| bump=none | |
| # `type!:` or a `BREAKING CHANGE` footer => major. | |
| if printf '%s\n' "$subjects" | grep -qE '^[a-zA-Z]+(\([^)]*\))?!:' \ | |
| || printf '%s\n' "$bodies" | grep -qE 'BREAKING[ -]CHANGE'; then | |
| bump=major | |
| elif printf '%s\n' "$subjects" | grep -qE '^feat(\([^)]*\))?:'; then | |
| bump=minor | |
| elif printf '%s\n' "$subjects" | grep -qE '^fix(\([^)]*\))?:'; then | |
| bump=patch | |
| fi | |
| echo "base=$base range=$range bump=$bump" | |
| case "$bump" in | |
| major) MA=$((MA+1)); MI=0; PA=0 ;; | |
| minor) MI=$((MI+1)); PA=0 ;; | |
| patch) PA=$((PA+1)) ;; | |
| none) | |
| echo "No feat/fix/breaking commits since ${last_stable:-<start>}; nothing to release." | |
| echo "should_release=false" >> "$GITHUB_OUTPUT" | |
| exit 0 ;; | |
| esac | |
| version="${MA}.${MI}.${PA}" | |
| echo "Releasing v${version}" | |
| { | |
| echo "should_release=true" | |
| echo "version=${version}" | |
| echo "tag=v${version}" | |
| echo "prerelease=false" | |
| } >> "$GITHUB_OUTPUT" | |
| images: | |
| name: Build & push images | |
| needs: prepare | |
| if: needs.prepare.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: write | |
| strategy: | |
| matrix: | |
| include: | |
| - name: runtime | |
| dockerfile: services/runtime/Dockerfile | |
| context: . | |
| - name: dashboard | |
| dockerfile: apps/dashboard/Dockerfile | |
| context: . | |
| - name: customer-app | |
| dockerfile: apps/customer-app/Dockerfile | |
| context: . | |
| # The agent image's Dockerfile does `COPY requirements.txt ./`, so it | |
| # needs its own directory as the build context, not the repo root. | |
| - name: supportdesk-agent | |
| dockerfile: apps/backend/agents/supportdesk/Dockerfile | |
| context: apps/backend/agents/supportdesk | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.prepare.outputs.sha }} | |
| - uses: docker/setup-buildx-action@v3 | |
| - uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push ${{ matrix.name }} | |
| uses: docker/build-push-action@v6 | |
| with: | |
| context: ${{ matrix.context }} | |
| file: ${{ matrix.dockerfile }} | |
| push: true | |
| tags: ghcr.io/agent-field/af-stack-${{ matrix.name }}:${{ needs.prepare.outputs.version }} | |
| build-args: | | |
| VERSION=${{ needs.prepare.outputs.version }} | |
| cache-from: type=gha,scope=${{ matrix.name }} | |
| cache-to: type=gha,mode=max,scope=${{ matrix.name }} | |
| provenance: false | |
| # GHCR packages are private on first push. af-stack init pulls with | |
| # no login, so we try to flip visibility here (packages:write). The | |
| # REST API 404s for some org-owned packages — continue, and let the | |
| # anonymous-pull assert in smoke fail closed with the UI path. | |
| - name: Make ghcr.io/agent-field/af-stack-${{ matrix.name }} public | |
| continue-on-error: true | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: scripts/publish-ghcr-packages.sh "af-stack-${{ matrix.name }}" | |
| smoke: | |
| name: Smoke — boot runtime image | |
| needs: [prepare, images] | |
| if: needs.prepare.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| packages: read | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.prepare.outputs.sha }} | |
| - uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Boot the freshly built runtime image and assert /ready | |
| env: | |
| AF_STACK_VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| docker compose -f docker-compose.release-smoke.yml up -d | |
| echo "Waiting for runtime /ready (migrations must apply on the built image)..." | |
| ok="" | |
| for _ in $(seq 1 60); do | |
| code="$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/ready || true)" | |
| if [ "$code" = "200" ]; then echo "runtime /ready = 200"; ok=1; break; fi | |
| sleep 3 | |
| done | |
| if [ "$ok" != "1" ]; then | |
| echo "::error::runtime image did not become ready — refusing to publish the release" | |
| docker compose -f docker-compose.release-smoke.yml logs runtime | tail -80 | |
| exit 1 | |
| fi | |
| curl -fsS http://localhost:8080/health >/dev/null && echo "health OK" | |
| # Free host port 8080 before the scaffold smoke. `af-stack dev` would | |
| # auto-allocate around a busy port, but the scaffolded app is supposed to | |
| # come up on the default one — so prove that path, don't fall back to it. | |
| - name: Tear down the runtime smoke | |
| run: docker compose -f docker-compose.release-smoke.yml down -v || true | |
| # Second smoke: the scaffolded-app path, end to end, against the images | |
| # this release just pushed. `af-stack init` writes a docker-compose.yml | |
| # pinned to ${{ needs.prepare.outputs.version }}, `npm start` runs | |
| # `af-stack dev` via its prestart hook and then calls supportdesk.echo. | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: go.mod | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| - name: Put the CLI build dir on PATH | |
| # `npm start`'s prestart hook shells out to `af-stack` by name. | |
| run: echo /tmp >> "$GITHUB_PATH" | |
| - name: Build the CLI pinned to the release version | |
| env: | |
| AF_STACK_VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| go build -ldflags "-X main.version=${AF_STACK_VERSION}" \ | |
| -o /tmp/af-stack ./services/cli/cmd/af-stack | |
| /tmp/af-stack version || true | |
| - name: Assert the release images are publicly pullable | |
| # `af-stack init` apps pull these with NO registry login. Private | |
| # GHCR packages must fail this job — do not skip or warn. The | |
| # script lists every private package (it must not die on the first | |
| # 401) and prints the org package-settings URLs. | |
| env: | |
| AF_STACK_VERSION: ${{ needs.prepare.outputs.version }} | |
| run: scripts/assert-ghcr-public.sh | |
| - name: Log out of GHCR so the scaffold pulls anonymously, like a user | |
| run: docker logout ghcr.io || true | |
| - name: Scaffold a standalone app | |
| run: | | |
| set -euo pipefail | |
| rm -rf /tmp/smoke-app | |
| /tmp/af-stack init smoke-app --dir /tmp | |
| echo "--- generated docker-compose.yml ---" | |
| cat /tmp/smoke-app/docker-compose.yml | |
| - name: npm start — scaffolded app boots its bundled backend | |
| # The first `docker compose up -d` pulls ~6 images. | |
| timeout-minutes: 15 | |
| run: | | |
| set -euo pipefail | |
| export PATH=/tmp:$PATH | |
| cd /tmp/smoke-app | |
| # No dependencies — this just validates the generated package.json. | |
| npm install | |
| status=0 | |
| npm start 2>&1 | tee /tmp/smoke-start.log || status=$? | |
| if [ "$status" -ne 0 ] || ! grep -q '^Echo agent replied:' /tmp/smoke-start.log; then | |
| echo "::error::the scaffolded app could not talk to its bundled backend — refusing to publish the release" | |
| for svc in runtime agentfield supportdesk-agent; do | |
| echo "--- docker compose logs $svc ---" | |
| docker compose logs --tail 80 "$svc" || true | |
| done | |
| exit 1 | |
| fi | |
| echo "scaffolded app talked to its bundled backend OK" | |
| - name: Tear down the scaffolded app | |
| if: always() | |
| run: docker compose -f /tmp/smoke-app/docker-compose.yml --project-directory /tmp/smoke-app down -v || true | |
| - name: Tear down | |
| if: always() | |
| run: docker compose -f docker-compose.release-smoke.yml down -v || true | |
| publish: | |
| name: Tag, release & move latest | |
| needs: [prepare, images, smoke] | |
| if: >- | |
| needs.prepare.outputs.should_release == 'true' && | |
| (github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true') | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: write | |
| packages: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| ref: ${{ needs.prepare.outputs.sha }} | |
| fetch-depth: 0 | |
| - name: Create and push the release tag | |
| run: | | |
| set -euo pipefail | |
| git config user.name "github-actions[bot]" | |
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | |
| git tag -a "${{ needs.prepare.outputs.tag }}" -m "Release ${{ needs.prepare.outputs.tag }}" | |
| git push origin "${{ needs.prepare.outputs.tag }}" | |
| - uses: actions/setup-go@v5 | |
| with: | |
| go-version-file: go.mod | |
| - name: GoReleaser — GitHub Release + CLI binaries | |
| uses: goreleaser/goreleaser-action@v7 | |
| with: | |
| version: "~> v2" | |
| args: release --clean | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Move :latest to this release (stable only) | |
| if: needs.prepare.outputs.prerelease != 'true' | |
| run: | | |
| set -euo pipefail | |
| for svc in runtime dashboard customer-app supportdesk-agent; do | |
| docker buildx imagetools create \ | |
| --tag "ghcr.io/agent-field/af-stack-$svc:latest" \ | |
| "ghcr.io/agent-field/af-stack-$svc:${{ needs.prepare.outputs.version }}" | |
| done |