Release #149
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 | |
| 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, the way any | |
| # user does. GHCR creates packages private by default, so a release | |
| # whose images are private ships a CLI whose scaffolds cannot boot. | |
| # Fix: github.com/orgs/Agent-Field/packages → the package → Package | |
| # settings → Change visibility → Public, then re-run this job. | |
| env: | |
| AF_STACK_VERSION: ${{ needs.prepare.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| private="" | |
| for svc in runtime dashboard customer-app supportdesk-agent; do | |
| token="$(curl -fsS "https://ghcr.io/token?scope=repository:agent-field/af-stack-$svc:pull" | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')" | |
| code="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' "https://ghcr.io/v2/agent-field/af-stack-$svc/manifests/$AF_STACK_VERSION")" | |
| echo "ghcr.io/agent-field/af-stack-$svc:$AF_STACK_VERSION anonymous pull: HTTP $code" | |
| [ "$code" = "200" ] || private="$private af-stack-$svc" | |
| done | |
| if [ -n "$private" ]; then | |
| echo "::error::these GHCR packages are not publicly pullable:$private — make each one Public under the org's package settings and re-run this job; until then apps from \`af-stack init\` cannot boot their bundled backend" | |
| exit 1 | |
| fi | |
| - 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 |