name: Release on: push: branches: - main - "**" # build :dev- image for any branch push (e.g. feature/x, zs/edit, fix/y) permissions: contents: write packages: write jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-python@v6 with: python-version: "3.13" - name: Install uv uses: astral-sh/setup-uv@v7 - name: Install dependencies run: uv pip install --system ".[dev]" - name: Run tests run: pytest tests/ -v --tb=short env: TESTING: "1" build-and-push: needs: test runs-on: ubuntu-latest outputs: image_tag: ${{ steps.meta.outputs.versioned_tag }} version: ${{ steps.meta.outputs.version }} channel: ${{ steps.meta.outputs.channel }} steps: - uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true - name: Claim version tag (with retry to avoid race conditions) id: meta run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" YEAR_MONTH=$(date +%Y.%m) if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then CHANNEL="stable" else CHANNEL="dev" fi SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) # Claim a unique version by pushing a git tag BEFORE building. # Retry up to 5 times if another CI run took our N. TAG_CLAIMED=false for ATTEMPT in 1 2 3 4 5; do git fetch --tags --force # Use max(N) not count — safe even if tags are deleted MAX_N=$(git tag -l "*-${YEAR_MONTH}.*" | sed 's/.*\.//' | sort -n | tail -1) N=$(( ${MAX_N:-0} + 1 )) VERSION="${YEAR_MONTH}.${N}" TAG="${CHANNEL}-${VERSION}" git tag -a "$TAG" -m "Release $TAG" if git push origin "$TAG" 2>/dev/null; then echo "Claimed tag $TAG (attempt $ATTEMPT)" TAG_CLAIMED=true break else echo "Tag $TAG already exists, retrying... (attempt $ATTEMPT)" git tag -d "$TAG" sleep 2 fi done if [ "$TAG_CLAIMED" != "true" ]; then echo "::error::Failed to claim a unique version tag after 5 attempts" exit 1 fi echo "channel=${CHANNEL}" >> "$GITHUB_OUTPUT" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "versioned_tag=${TAG}" >> "$GITHUB_OUTPUT" echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" # Per-branch slug for dev builds (enables branch-aware dev VMs) if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then BRANCH_NAME="${GITHUB_REF#refs/heads/}" BRANCH_SLUG=$(echo "$BRANCH_NAME" | sed 's|^feature/||' | sed 's|[^a-zA-Z0-9-]|-|g' | tr '[:upper:]' '[:lower:]' | cut -c1-50) echo "branch_slug=${BRANCH_SLUG}" >> "$GITHUB_OUTPUT" echo "Branch slug: ${BRANCH_SLUG}" fi echo "Channel: ${CHANNEL}" echo "Version: ${VERSION}" echo "Versioned tag: ${TAG}" - name: Log in to GHCR uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v7 with: push: true build-args: | AGNES_VERSION=${{ steps.meta.outputs.version }} RELEASE_CHANNEL=${{ steps.meta.outputs.channel }} AGNES_COMMIT_SHA=${{ github.sha }} tags: | ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.channel }} ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.versioned_tag }} ghcr.io/${{ github.repository }}:sha-${{ steps.meta.outputs.short_sha }} ${{ steps.meta.outputs.channel == 'dev' && format('ghcr.io/{0}:dev-{1}', github.repository, steps.meta.outputs.branch_slug) || '' }} smoke-test: needs: build-and-push if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Start Agnes from built image run: | # Create empty .env (docker-compose.yml requires env_file: .env, gitignored) touch .env # Use prod compose (GHCR images) + CI overlay (test secrets) export AGNES_TAG="${{ needs.build-and-push.outputs.image_tag }}" docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.ci.yml up -d app # Wait for healthy (max 60s) timeout 60 bash -c 'until curl -sf http://localhost:8000/api/health | python3 -c "import sys,json; d=json.load(sys.stdin); sys.exit(0 if d[\"status\"]!=\"unhealthy\" else 1)"; do sleep 3; done' - name: Run smoke tests run: bash scripts/smoke-test.sh http://localhost:8000 - name: Collect logs on failure if: failure() run: docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.ci.yml logs > smoke-test-logs.txt - name: Upload logs if: failure() uses: actions/upload-artifact@v4 with: name: smoke-test-logs path: smoke-test-logs.txt - name: Teardown if: always() run: docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.ci.yml down -v