ci(tag-repos): retry on 5xx and fail the run on any tag failure #296
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
| # ═══════════════════════════════════════════════════════════ | |
| # LocalPibox Devstack — CI/CD Build & Publish | |
| # ═══════════════════════════════════════════════════════════ | |
| # Builds on GitHub (fast connection) → publishes to GHCR | |
| # Extensions update at runtime via pi update --extensions | |
| # | |
| # Triggers: | |
| # - Push to dev (code changes, NOT VERSION bumps) | |
| # - Push to main (Dockerfile, support/, lpb.stack.env, lpb.conf.env) | |
| # - Weekly cron (Monday 3am UTC) — keep image fresh | |
| # - Manual dispatch | |
| # | |
| # Version model (Option C): | |
| # - Single source: devstack/VERSION | |
| # - CI bumps version after tests pass, sets bumped value as CI output | |
| # - The 5 stack repos (all except devstack) tagged on each CI run | |
| # - Dev pipeline (push to dev): 0.0.x-lpb-dev, images :dev-*, :{v}-*, :{sha}-* | |
| # - Main pipeline (push to main): 0.0.x-lpb, images :main-*, :latest-*, :{v}-*, :{sha}-* | |
| # | |
| # Actions: all latest major versions (Node.js 24 native) | |
| # actions/checkout@v6 · docker/build-push-action@v7 | |
| # docker/setup-buildx-action@v4 · docker/setup-qemu-action@v4 | |
| # docker/login-action@v4 · docker/metadata-action@v6 | |
| # actions/upload-artifact@v7 | |
| name: Build & Publish Devstack | |
| on: | |
| push: | |
| branches: [dev, main] | |
| paths: | |
| - 'Dockerfile' | |
| - 'support/**' | |
| - 'scripts/**' | |
| - '.github/workflows/*.yml' | |
| # NOTE: VERSION + lpb.stack.env changes intentionally excluded | |
| # to avoid re-triggering on auto-bump commits | |
| pull_request: | |
| branches: [main] | |
| paths: | |
| - 'Dockerfile' | |
| - 'support/**' | |
| - 'scripts/**' | |
| - '.github/workflows/*.yml' | |
| schedule: | |
| - cron: '0 3 * * 1' | |
| workflow_dispatch: | |
| inputs: | |
| publish_latest: | |
| description: 'Publish as :latest tag' | |
| type: boolean | |
| default: true | |
| no_cache: | |
| description: 'Full rebuild with no cache (pick up fork-branch changes)' | |
| type: boolean | |
| default: false | |
| permissions: | |
| contents: write | |
| packages: write | |
| env: | |
| IMAGE_NAME: ghcr.io/lpb-stack/devstack | |
| jobs: | |
| # ────────────────────────────────────────────────────── | |
| # Phase 1: Test | |
| # ────────────────────────────────────────────────────── | |
| test-lpb: | |
| name: Run lpb.py unit tests | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| - name: Run test suite | |
| run: | | |
| python3 scripts/test_lpb.py | |
| python3 scripts/test_localpibox.py | |
| # ────────────────────────────────────────────────────── | |
| # Phase 2: Bump version (before build, so build uses correct number) | |
| # ────────────────────────────────────────────────────── | |
| bump-version: | |
| name: Bump version | |
| runs-on: ubuntu-latest | |
| needs: [test-lpb] | |
| if: ${{ github.event_name != 'pull_request' }} | |
| outputs: | |
| version: ${{ steps.bump.outputs.version }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Bump patch version | |
| id: bump | |
| run: | | |
| set -e | |
| VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0-lpb") | |
| MAJOR=$(echo "$VERSION" | sed 's/^\([0-9]*\)\..*/\1/') | |
| MINOR=$(echo "$VERSION" | sed 's/^[0-9]*\.\([0-9]*\)\..*/\1/') | |
| PATCH=$(echo "$VERSION" | sed 's/^[0-9]*\.[0-9]*\.\([0-9]*\).*/\1/') | |
| NEW_PATCH=$((PATCH + 1)) | |
| # Add -dev suffix for dev branch, no suffix for main | |
| if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then | |
| SUFFIX="" | |
| else | |
| SUFFIX="-dev" | |
| fi | |
| NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}-lpb${SUFFIX}" | |
| echo "Bumping $VERSION → $NEW_VERSION" | |
| echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" | |
| # Persist bumped version (VERSION excluded from CI paths, won't re-trigger) | |
| echo "$NEW_VERSION" > VERSION | |
| git config user.name "ci-localpibox" | |
| git config user.email "ci@lpb-stack.dev" | |
| git add VERSION | |
| git commit -m "chore: bump VERSION $NEW_VERSION" || echo "Nothing to commit" | |
| git push origin "${GITHUB_REF_NAME}" --force-with-lease || echo "Push failed (already up-to-date)" | |
| # ────────────────────────────────────────────────────── | |
| # Phase 3: Build & publish images | |
| # ────────────────────────────────────────────────────── | |
| build-cli: | |
| name: Build & publish cli image | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| needs: [bump-version] | |
| if: ${{ github.event_name != 'pull_request' }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: true | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v4 | |
| - name: Set up buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Login to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Load stack config | |
| id: config | |
| run: | | |
| set -a | |
| source lpb.stack.env | |
| # Pipeline profile: main builds stable branches (lpb/main), dev builds lpb-dev/dev | |
| case "${GITHUB_REF_NAME}" in | |
| main) [ -f lpb.stack.main.env ] && source lpb.stack.main.env ;; | |
| dev) [ -f lpb.stack.dev.env ] && source lpb.stack.dev.env ;; | |
| esac | |
| set +a | |
| echo "PI_FORK=$LPB_PI_FORK" >> "$GITHUB_OUTPUT" | |
| # Use branch name for cloning (not version tag, which doesn't exist yet) | |
| echo "PI_REF=$LPB_PI_REF" >> "$GITHUB_OUTPUT" | |
| # Use bumped version for image tagging | |
| echo "PI_TAG=${{ needs.bump-version.outputs.version }}" >> "$GITHUB_OUTPUT" | |
| echo "CONFIG_FORK=$LPB_CONFIG_FORK" >> "$GITHUB_OUTPUT" | |
| echo "CONFIG_REF=$LPB_CONFIG_REF" >> "$GITHUB_OUTPUT" | |
| echo "NODE_VERSION=$LPB_NODE_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "VSCODIUM_VERSION=$LPB_VSCODIUM_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "LPB_VERSION=${{ needs.bump-version.outputs.version }}" >> "$GITHUB_OUTPUT" | |
| MAX_TOKENS=$(grep -E '^LPB_MAX_TOKENS_CONTEXT_RATIO=' lpb.conf.env | cut -d= -f2- | tr -d '"' 2>/dev/null || true) | |
| echo "MAX_TOKENS=${MAX_TOKENS:-0.06}" >> "$GITHUB_OUTPUT" | |
| sha=$(git ls-remote "$LPB_PI_FORK" "refs/heads/$LPB_PI_REF" | awk '{print $1}') | |
| echo "sha=${sha:-unknown}" >> "$GITHUB_OUTPUT" | |
| echo "stack_version=${{ needs.bump-version.outputs.version }}" >> "$GITHUB_OUTPUT" | |
| - name: Build & push cli | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| target: cli | |
| push: ${{ github.event_name != 'pull_request' }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| build-args: | | |
| PI_FORK=${{ steps.config.outputs.PI_FORK }} | |
| PI_REF=${{ steps.config.outputs.PI_REF }} | |
| CONFIG_FORK=${{ steps.config.outputs.CONFIG_FORK }} | |
| CONFIG_REF=${{ steps.config.outputs.CONFIG_REF }} | |
| NODE_VERSION=${{ steps.config.outputs.NODE_VERSION }} | |
| VSCODIUM_VERSION=${{ steps.config.outputs.VSCODIUM_VERSION }} | |
| PI_HEAD_SHA=${{ steps.config.outputs.sha }} | |
| LPB_VERSION=${{ steps.config.outputs.LPB_VERSION }} | |
| LPB_MAX_TOKENS_CONTEXT_RATIO=${{ steps.config.outputs.MAX_TOKENS }} | |
| secrets: | | |
| GIT_AUTH_TOKEN=${{ secrets.LPB_STACK_PAT }} | |
| no-cache: ${{ github.event.inputs.no_cache == 'true' }} | |
| tags: | | |
| ${{ env.IMAGE_NAME }}:${{ needs.bump-version.outputs.version }}-cli | |
| ${{ github.ref_name == 'main' && format('{0}:main-cli', env.IMAGE_NAME) || format('{0}:dev-cli', env.IMAGE_NAME) }} | |
| ${{ github.ref_name == 'main' && format('{0}:latest-cli', env.IMAGE_NAME) || '' }} | |
| ${{ env.IMAGE_NAME }}:${{ github.sha }}-cli | |
| ${{ github.event_name == 'schedule' && format('{0}:weekly-cli', env.IMAGE_NAME) || '' }} | |
| ${{ github.event_name == 'workflow_dispatch' && inputs.publish_latest == 'true' && format('{0}:latest-cli', env.IMAGE_NAME) || '' }} | |
| provenance: false | |
| platforms: linux/amd64 | |
| build-web: | |
| name: Build & publish web image | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 45 | |
| needs: [build-cli, bump-version] | |
| if: ${{ github.event_name != 'pull_request' }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v6 | |
| with: | |
| persist-credentials: true | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v4 | |
| - name: Set up buildx | |
| uses: docker/setup-buildx-action@v4 | |
| - name: Login to GHCR | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Load stack config | |
| id: config | |
| run: | | |
| set -a | |
| source lpb.stack.env | |
| # Pipeline profile: main builds stable branches (lpb/main), dev builds lpb-dev/dev | |
| case "${GITHUB_REF_NAME}" in | |
| main) [ -f lpb.stack.main.env ] && source lpb.stack.main.env ;; | |
| dev) [ -f lpb.stack.dev.env ] && source lpb.stack.dev.env ;; | |
| esac | |
| set +a | |
| echo "PI_FORK=$LPB_PI_FORK" >> "$GITHUB_OUTPUT" | |
| echo "PI_REF=$LPB_PI_REF" >> "$GITHUB_OUTPUT" | |
| echo "CONFIG_FORK=$LPB_CONFIG_FORK" >> "$GITHUB_OUTPUT" | |
| echo "CONFIG_REF=$LPB_CONFIG_REF" >> "$GITHUB_OUTPUT" | |
| echo "NODE_VERSION=$LPB_NODE_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "VSCODIUM_VERSION=$LPB_VSCODIUM_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "LPB_VERSION=${{ needs.bump-version.outputs.version }}" >> "$GITHUB_OUTPUT" | |
| MAX_TOKENS=$(grep -E '^LPB_MAX_TOKENS_CONTEXT_RATIO=' lpb.conf.env | cut -d= -f2- | tr -d '"' 2>/dev/null || true) | |
| echo "MAX_TOKENS=${MAX_TOKENS:-0.06}" >> "$GITHUB_OUTPUT" | |
| sha=$(git ls-remote "$LPB_PI_FORK" "refs/heads/$LPB_PI_REF" | awk '{print $1}') | |
| echo "sha=${sha:-unknown}" >> "$GITHUB_OUTPUT" | |
| echo "stack_version=${{ needs.bump-version.outputs.version }}" >> "$GITHUB_OUTPUT" | |
| - name: Build & push web | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: . | |
| file: ./Dockerfile | |
| target: web | |
| push: ${{ github.event_name != 'pull_request' }} | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| build-args: | | |
| PI_FORK=${{ steps.config.outputs.PI_FORK }} | |
| PI_REF=${{ steps.config.outputs.PI_REF }} | |
| CONFIG_FORK=${{ steps.config.outputs.CONFIG_FORK }} | |
| CONFIG_REF=${{ steps.config.outputs.CONFIG_REF }} | |
| NODE_VERSION=${{ steps.config.outputs.NODE_VERSION }} | |
| VSCODIUM_VERSION=${{ steps.config.outputs.VSCODIUM_VERSION }} | |
| PI_HEAD_SHA=${{ steps.config.outputs.sha }} | |
| LPB_VERSION=${{ steps.config.outputs.LPB_VERSION }} | |
| LPB_MAX_TOKENS_CONTEXT_RATIO=${{ steps.config.outputs.MAX_TOKENS }} | |
| secrets: | | |
| GIT_AUTH_TOKEN=${{ secrets.LPB_STACK_PAT }} | |
| no-cache: ${{ github.event.inputs.no_cache == 'true' }} | |
| tags: | | |
| ${{ env.IMAGE_NAME }}:${{ needs.bump-version.outputs.version }}-web | |
| ${{ github.ref_name == 'main' && format('{0}:main-web', env.IMAGE_NAME) || format('{0}:dev-web', env.IMAGE_NAME) }} | |
| ${{ github.ref_name == 'main' && format('{0}:latest-web', env.IMAGE_NAME) || '' }} | |
| ${{ env.IMAGE_NAME }}:${{ github.sha }}-web | |
| ${{ github.event_name == 'schedule' && format('{0}:weekly-web', env.IMAGE_NAME) || '' }} | |
| ${{ github.event_name == 'workflow_dispatch' && inputs.publish_latest == 'true' && format('{0}:latest-web', env.IMAGE_NAME) || '' }} | |
| provenance: false | |
| platforms: linux/amd64 | |
| # ────────────────────────────────────────────────────── | |
| # Phase 4: Tag repos on success (only after build passes) | |
| # ────────────────────────────────────────────────────── | |
| tag-repos: | |
| name: Tag all repos | |
| runs-on: ubuntu-latest | |
| needs: [build-cli, build-web, bump-version] | |
| if: ${{ github.event_name != 'pull_request' && needs.build-cli.result == 'success' && needs.build-web.result == 'success' }} | |
| steps: | |
| - name: Create tags on all repos | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| VERSION: ${{ needs.bump-version.outputs.version }} | |
| GH_TOKEN: ${{ secrets.LPB_STACK_PAT }} | |
| run: | | |
| set -e | |
| # repo -> branch mapping per pipeline | |
| # dev pipeline: dev branches; main pipeline: stable branches | |
| if [ "${GITHUB_REF_NAME}" = "main" ]; then | |
| declare -A REPO_BRANCHS=( | |
| ["lpb-stack/pi"]="lpb" | |
| ["lpb-stack/pi-subagents"]="lpb" | |
| ["lpb-stack/lemonade-pi-plugin"]="lpb" | |
| ["lpb-stack/config"]="main" | |
| ["lpb-stack/lpb-memory"]="main" | |
| ) | |
| else | |
| declare -A REPO_BRANCHS=( | |
| ["lpb-stack/pi"]="lpb-dev" | |
| ["lpb-stack/pi-subagents"]="lpb-dev" | |
| ["lpb-stack/lemonade-pi-plugin"]="lpb-dev" | |
| ["lpb-stack/config"]="dev" | |
| ["lpb-stack/lpb-memory"]="dev" | |
| ) | |
| fi | |
| FAILURES=0 | |
| for repo in "${!REPO_BRANCHS[@]}"; do | |
| branch="${REPO_BRANCHS[$repo]}" | |
| echo "Tagging $repo@$VERSION (from $branch)" | |
| sha=$(git ls-remote "https://github.com/$repo.git" "refs/heads/$branch" | awk '{print $1}') | |
| if [ -z "$sha" ]; then | |
| # A missing branch means the repo map or branch name is wrong — | |
| # a partial tag set would desync the stack, so fail loudly. | |
| echo " ❌ $repo:$branch not found — aborting (fail fast)" | |
| exit 1 | |
| fi | |
| # POST to /git/refs with {ref, sha} body (NOT /git/refs/tags/{tagname}) | |
| # 201 = created, 422 = already exists (idempotent re-run) = success. | |
| # Anything else (503/429/...) is retried with backoff — transient | |
| # GitHub API outages must not produce a partially-tagged stack. | |
| status="" | |
| for attempt in 1 2 3 4; do | |
| status=$(curl -s -o /dev/null -w '%{http_code}' \ | |
| -X POST "https://api.github.com/repos/$repo/git/refs" \ | |
| -H "Authorization: token $GH_TOKEN" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| -d "{\"ref\": \"refs/tags/$VERSION\", \"sha\": \"$sha\"}") | |
| if [ "$status" = "201" ] || [ "$status" = "422" ]; then | |
| break | |
| fi | |
| if [ "$attempt" -lt 4 ]; then | |
| sleep $((attempt * 15)) | |
| echo " ⚠️ $repo HTTP $status — retry $((attempt + 1))/4" | |
| fi | |
| done | |
| if [ "$status" = "201" ] || [ "$status" = "422" ]; then | |
| echo " ✅ $repo@$VERSION" | |
| else | |
| echo " ❌ $repo tag creation failed (HTTP $status after 4 attempts)" | |
| FAILURES=$((FAILURES + 1)) | |
| fi | |
| done | |
| if [ "$FAILURES" -gt 0 ]; then | |
| echo "::error::Tag creation failed for $FAILURES repo(s) at $VERSION — the stack is now misaligned; re-run this job to complete tagging" | |
| exit 1 | |
| fi | |
| status: | |
| name: Build status | |
| needs: [build-cli, build-web] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| steps: | |
| - run: | | |
| if [ "${{ needs.build-cli.result }}" != "success" ] || [ "${{ needs.build-web.result }}" != "success" ]; then | |
| echo "Build failed!" | |
| exit 1 | |
| fi | |
| echo "Build complete — both cli and web images pushed" |