release: bump version to 1.9.11 #154
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: Deploy | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_dispatch: | |
| env: | |
| REGISTRY: ghcr.io | |
| IMAGE_NAME: hughkantsime/odin | |
| jobs: | |
| # ── Validate build before deploying ── | |
| validate: | |
| name: Validate Build | |
| runs-on: mac-mini-runner | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Add Homebrew to PATH | |
| run: echo "$HOME/homebrew/bin:/opt/homebrew/bin" | tr ':' '\n' >> $GITHUB_PATH | |
| - name: Set up Node 20 | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '20' | |
| - name: Validate design tokens are committed | |
| run: | | |
| node design/generate.mjs --local-only | |
| if ! git diff --exit-code frontend/src/design-tokens.css; then | |
| echo "::error::design-tokens.css is out of sync with tokens.json" | |
| echo "Run 'make tokens' and commit the result before releasing" | |
| exit 1 | |
| fi | |
| - name: Validate frontend build | |
| run: | | |
| cd frontend | |
| npm ci --no-audit --no-fund | |
| npx vite build | |
| - name: Validate backend deps | |
| run: pip3.11 install --break-system-packages -r backend/requirements.txt | |
| # ── Auto-tag from VERSION file ── | |
| tag: | |
| name: Auto-Tag Release | |
| needs: validate | |
| runs-on: mac-mini-runner | |
| outputs: | |
| version: ${{ steps.version.outputs.version }} | |
| tag_created: ${{ steps.check.outputs.tag_created }} | |
| permissions: | |
| contents: write | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Read VERSION | |
| id: version | |
| run: echo "version=v$(cat VERSION)" >> $GITHUB_OUTPUT | |
| - name: Check if tag exists | |
| id: check | |
| run: | | |
| TAG="${{ steps.version.outputs.version }}" | |
| if git rev-parse "$TAG" >/dev/null 2>&1; then | |
| TAG_SHA=$(git rev-parse "$TAG") | |
| HEAD_SHA=$(git rev-parse HEAD) | |
| if [ "$TAG_SHA" = "$HEAD_SHA" ]; then | |
| echo "Tag $TAG exists and points to HEAD — proceeding (make release pushed it)" | |
| echo "tag_created=true" >> $GITHUB_OUTPUT | |
| else | |
| echo "Tag $TAG exists but points to $TAG_SHA (HEAD is $HEAD_SHA) — skipping" | |
| echo "tag_created=false" >> $GITHUB_OUTPUT | |
| fi | |
| else | |
| echo "tag_created=true" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Create tag | |
| if: steps.check.outputs.tag_created == 'true' | |
| run: | | |
| TAG="${{ steps.version.outputs.version }}" | |
| if git rev-parse "$TAG" >/dev/null 2>&1; then | |
| echo "Tag $TAG already exists, skipping creation" | |
| else | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| git tag "$TAG" | |
| git push origin "$TAG" | |
| fi | |
| # ── Build and push Docker image ── | |
| build: | |
| name: Build & Push Docker Image | |
| needs: tag | |
| if: needs.tag.outputs.tag_created == 'true' | |
| runs-on: mac-mini-runner | |
| timeout-minutes: 30 | |
| permissions: | |
| contents: read | |
| packages: write | |
| steps: | |
| - name: Add Homebrew to PATH | |
| run: echo "$HOME/homebrew/bin:/opt/homebrew/bin" | tr ':' '\n' >> $GITHUB_PATH | |
| - uses: actions/checkout@v4 | |
| with: | |
| lfs: true | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v3 | |
| - 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@v5 | |
| with: | |
| context: . | |
| push: true | |
| tags: | | |
| ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.tag.outputs.version }} | |
| ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest | |
| platforms: linux/amd64,linux/arm64 | |
| provenance: false | |
| # ── Phase 3: Create GitHub Release ── | |
| release: | |
| name: Create GitHub Release | |
| needs: [tag, build] | |
| if: needs.tag.outputs.tag_created == 'true' | |
| runs-on: mac-mini-runner | |
| continue-on-error: true | |
| permissions: | |
| contents: write | |
| outputs: | |
| release_ok: ${{ steps.create.outcome == 'success' }} | |
| steps: | |
| - name: Add Homebrew to PATH | |
| run: echo "$HOME/homebrew/bin:/opt/homebrew/bin" | tr ':' '\n' >> $GITHUB_PATH | |
| - uses: actions/checkout@v4 | |
| - name: Extract changelog body | |
| id: changelog | |
| run: | | |
| VER="${{ needs.tag.outputs.version }}" | |
| VER="${VER#v}" | |
| python3 -c " | |
| import sys, re | |
| content = open('CHANGELOG.md').read() | |
| ver = '$VER' | |
| m = re.search(r'## \[' + re.escape(ver) + r'\][^\n]*\n(.*?)(?=\n## \[|\Z)', content, re.DOTALL) | |
| if not m: | |
| print(f'ERROR: no changelog section for {ver}', file=sys.stderr); sys.exit(1) | |
| body = m.group(1).strip() | |
| if not body: | |
| print(f'ERROR: empty changelog section for {ver}', file=sys.stderr); sys.exit(1) | |
| print(body) | |
| " > /tmp/changelog_body.txt | |
| if [ ! -s /tmp/changelog_body.txt ]; then | |
| echo "ERROR: changelog body is empty for v$VER" | |
| exit 1 | |
| fi | |
| echo "Extracted $(wc -l < /tmp/changelog_body.txt) lines for v$VER" | |
| - name: Create GitHub Release | |
| id: create | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| VER="${{ needs.tag.outputs.version }}" | |
| gh release create "$VER" \ | |
| --title "O.D.I.N. $VER" \ | |
| --notes-file /tmp/changelog_body.txt \ | |
| install/install.sh | |
| # ── Phase 4: Sync odin-site ── | |
| sync-site: | |
| name: Sync odin-site | |
| needs: [release, tag] | |
| if: needs.tag.outputs.tag_created == 'true' | |
| runs-on: mac-mini-runner | |
| continue-on-error: true | |
| outputs: | |
| sync_ok: ${{ steps.poll.outcome == 'success' }} | |
| steps: | |
| - name: Add Homebrew to PATH | |
| run: echo "$HOME/homebrew/bin:/opt/homebrew/bin" | tr ':' '\n' >> $GITHUB_PATH | |
| - name: Dispatch odin-release event | |
| id: dispatch | |
| env: | |
| # Cross-repo dispatch requires a PAT with repo scope — GITHUB_TOKEN | |
| # cannot trigger workflows in other repositories. | |
| GH_TOKEN: ${{ secrets.ODIN_SITE_PAT }} | |
| run: | | |
| VER="${{ needs.tag.outputs.version }}" | |
| VER="${VER#v}" | |
| TAG="${{ needs.tag.outputs.version }}" | |
| SHA="${{ github.sha }}" | |
| DISPATCH_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| echo "dispatch_time=$DISPATCH_TIME" >> $GITHUB_OUTPUT | |
| gh api repos/HughKantsime/odin-site/dispatches \ | |
| --method POST \ | |
| --input - <<EOF | |
| {"event_type":"odin-release","client_payload":{"version":"$VER","tag":"$TAG","source_repo":"HughKantsime/runsodin","source_sha":"$SHA"}} | |
| EOF | |
| - name: Poll for odin-site workflow completion | |
| id: poll | |
| env: | |
| GH_TOKEN: ${{ secrets.ODIN_SITE_PAT }} | |
| run: | | |
| DISPATCH_TIME="${{ steps.dispatch.outputs.dispatch_time }}" | |
| for i in $(seq 1 20); do | |
| sleep 15 | |
| RESULT=$(gh run list --repo HughKantsime/odin-site --event repository_dispatch --limit 5 --json status,conclusion,createdAt,databaseId 2>/dev/null) | |
| CONCLUSION=$(echo "$RESULT" | python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| dispatch_time = '$DISPATCH_TIME' | |
| for run in data: | |
| if run.get('createdAt', '') >= dispatch_time: | |
| print(run.get('conclusion') or 'pending') | |
| break | |
| else: | |
| print('not_found') | |
| ") | |
| echo "Poll $i/20: $CONCLUSION" | |
| if [ "$CONCLUSION" = "success" ]; then exit 0; fi | |
| if [ "$CONCLUSION" = "failure" ] || [ "$CONCLUSION" = "cancelled" ]; then | |
| echo "::error::odin-site sync workflow failed with: $CONCLUSION" | |
| exit 1 | |
| fi | |
| done | |
| echo "::error::odin-site sync workflow timed out after 5 minutes" | |
| exit 1 | |
| # ── Phase 5: Verify prod deployment ── | |
| # | |
| # IMPORTANT: this job does NOT use continue-on-error. Prior to 2026-04-16 | |
| # it had `continue-on-error: true`, which meant a failure here (prod | |
| # hasn't picked up the new image) reported as the whole Deploy workflow | |
| # succeeding — GitHub showed a green ✓ badge while prod was actually | |
| # crash-looping. That's how v1.9.0's prod outage went undetected for | |
| # 19 hours. The notify job below still runs via `if: always()` so we | |
| # still get the summary+notification on partial failure; the change is | |
| # just that the overall workflow status now reflects reality. | |
| verify-prod: | |
| name: Verify Prod | |
| needs: [build, tag] | |
| if: needs.tag.outputs.tag_created == 'true' | |
| runs-on: mac-mini-runner | |
| outputs: | |
| prod_ok: ${{ steps.verify.outcome == 'success' }} | |
| steps: | |
| - name: Add Homebrew to PATH | |
| run: echo "$HOME/homebrew/bin:/opt/homebrew/bin" | tr ':' '\n' >> $GITHUB_PATH | |
| - uses: actions/checkout@v4 | |
| - name: Verify prod version and readiness | |
| id: verify | |
| run: | | |
| VER="${{ needs.tag.outputs.version }}" | |
| ops/prod_verify_public.sh "$VER" | |
| # ── Phase 6: Notification ── | |
| notify: | |
| name: Deploy Notification | |
| needs: [tag, build, release, sync-site, verify-prod] | |
| if: always() && needs.tag.outputs.tag_created == 'true' | |
| runs-on: mac-mini-runner | |
| steps: | |
| - name: Add Homebrew to PATH | |
| run: echo "$HOME/homebrew/bin:/opt/homebrew/bin" | tr ':' '\n' >> $GITHUB_PATH | |
| - name: Build status and notify | |
| run: | | |
| VER="${{ needs.tag.outputs.version }}" | |
| # Collect phase results | |
| BUILD="${{ needs.build.result }}" | |
| RELEASE="${{ needs.release.result }}" | |
| SYNC="${{ needs.sync-site.result }}" | |
| PROD="${{ needs.verify-prod.result }}" | |
| icon() { [ "$1" = "success" ] && echo "✓" || echo "✗"; } | |
| STATUS="GHCR $(icon $BUILD) | Release $(icon $RELEASE) | Site $(icon $SYNC) | Prod $(icon $PROD)" | |
| echo "## Deploy Summary" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Version:** $VER" >> $GITHUB_STEP_SUMMARY | |
| echo "- **GHCR:** $BUILD" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Release:** $RELEASE" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Site sync:** $SYNC" >> $GITHUB_STEP_SUMMARY | |
| echo "- **Prod verify:** $PROD" >> $GITHUB_STEP_SUMMARY | |
| # Determine priority | |
| if [ "$BUILD" = "success" ] && [ "$RELEASE" = "success" ] && [ "$SYNC" = "success" ] && [ "$PROD" = "success" ]; then | |
| PRIORITY="default" | |
| TITLE="O.D.I.N. $VER deployed" | |
| elif [ "$BUILD" != "success" ]; then | |
| PRIORITY="high" | |
| TITLE="O.D.I.N. $VER — build FAILED" | |
| else | |
| PRIORITY="high" | |
| TITLE="O.D.I.N. $VER — partial failure" | |
| fi | |
| curl -sf \ | |
| -H "Title: $TITLE" \ | |
| -H "Priority: $PRIORITY" \ | |
| -H "Tags: rocket" \ | |
| -d "$STATUS" \ | |
| "${{ secrets.NTFY_URL }}" || echo "::warning::ntfy notification failed" |