Compare commits

..

4 Commits

Author SHA1 Message Date
Daniel Volz b91717fc19 fix: stock correction not working for bottle type and manual calculation mode (#133)
- Fix bottle type: submitStockCorrection used blister formula for baseTotal
  but getMedTotal uses only looseTablets for bottles. Now uses getPackageSize()
  which handles both types correctly.
- Fix manual mode: same-day taken doses were counted as consumed after a stock
  correction (>= comparison with date-only timestamps). Changed to > so doses
  on the correction day are excluded.
- Add agent instruction: only release-manager may create PRs/push/merge.
2026-02-08 15:12:17 +01:00
Daniel Volz a065adcd82 ci: remove redundant test jobs from docker-build workflow (#132)
Tests are already guaranteed by branch protection (test.yml must pass
before PR can be merged to main). Running them again in docker-build.yml
was redundant and slowed down image builds.

This reduces test runs from 3x to 2x per code change:
- test.yml on PR (required by branch protection)
- update-test-badges.yml on main push (needed for badge counts)

Docker image builds now start immediately after merge.
2026-02-08 15:05:33 +01:00
Daniel Volz 6edf2fa341 docs: add rule to keep README.md up to date after code changes (#131) 2026-02-08 14:45:30 +01:00
Daniel Volz 9e3d548536 chore: make release script non-interactive with CI retry logic (#130)
- Remove y/N confirmation prompt for automation
- Add wait_for_ci() with retry logic (polls until checks appear)
- Auto-detect git remote (origin or github)
- Remove unused /etc/nginx/conf.d tmpfs from compose
- Update release-manager agent docs to match
2026-02-08 14:13:11 +01:00
9 changed files with 401 additions and 106 deletions
+16 -3
View File
@@ -129,13 +129,26 @@ Apply these rules strictly:
## Task 3: Execute Release ## Task 3: Execute Release
Use the release script whenever possible: Use the release script — it is **fully non-interactive** (no y/N prompts) and handles the entire flow automatically:
```bash ```bash
./scripts/release.sh <patch|minor|major> ./scripts/release.sh <patch|minor|major|x.y.z>
``` ```
This script handles: branch creation → version bump → PR → CI wait → merge → signed tag → push. The script performs these steps in order:
1. Checks out and updates `main`
2. Creates release branch `chore/release-X.Y.Z`
3. Bumps version in `backend/package.json` and `frontend/package.json`
4. Commits, pushes, and creates a PR
5. Waits for CI checks (with retry logic — polls every 15s, waits up to 10 minutes)
6. Merges the PR (squash + delete branch)
7. Creates a signed tag `vX.Y.Z` and pushes it
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
**On failure:** If CI fails, the script exits with an error. The release branch and PR remain open for inspection. Fix the issue, push to the branch, and the PR will re-run CI. Then merge manually or re-run the script.
### Version Files (MANDATORY) ### Version Files (MANDATORY)
+2 -1
View File
@@ -4,12 +4,13 @@
- **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English. - **English is the primary language**: All code, comments, documentation, commit messages, PR descriptions, and GitHub releases MUST be written in English. The user may communicate in German, but all project artifacts must be in English.
- **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action. - **NEVER release without explicit permission**: Do NOT create tags, releases, or version bumps unless the user explicitly asks for it. Always wait for explicit confirmation before any release action.
- **NEVER create PRs without explicit permission**: Do NOT create Pull Requests, push branches, or merge code unless the user explicitly asks for it. Always present changes and wait for the user to confirm before any git operations that affect the remote repository. - **NEVER create PRs, push, or merge**: Only the **release-manager agent** (`@release-manager`) is allowed to create Pull Requests, push branches to the remote, or merge code. Regular agents and Copilot MUST NOT perform any git operations that affect the remote repository (no `git push`, no `gh pr create`, no `gh pr merge`). Present your local changes and tell the user to invoke `@release-manager` when ready to ship.
- **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository. - **No temporary files**: Delete temporary scripts/files immediately after use. Do not commit temporary debug scripts, test files, or one-off utilities to the repository.
- **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done. - **Clean workspace**: Always clean up after yourself. If you create a file for a specific task, delete it once done.
- **Remove old code when re-implementing**: When fixing a bug or re-implementing a feature that didn't work, ALWAYS remove the old/broken code completely. Never leave dead code, unused functions, or obsolete implementations in the codebase. - **Remove old code when re-implementing**: When fixing a bug or re-implementing a feature that didn't work, ALWAYS remove the old/broken code completely. Never leave dead code, unused functions, or obsolete implementations in the codebase.
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. When modifying existing features, update or add tests accordingly. If old tests become obsolete due to code changes, remove or update them. - **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests. When modifying existing features, update or add tests accordingly. If old tests become obsolete due to code changes, remove or update them.
- **Fix bugs, don't test around them**: If you discover incorrect behavior in the code while writing tests, ALWAYS fix the buggy code first, then write tests that verify the correct behavior. NEVER write tests that mimic or assert broken behavior. The user's time is finite and irreplaceable — every bug left unfixed wastes it. - **Fix bugs, don't test around them**: If you discover incorrect behavior in the code while writing tests, ALWAYS fix the buggy code first, then write tests that verify the correct behavior. NEVER write tests that mimic or assert broken behavior. The user's time is finite and irreplaceable — every bug left unfixed wastes it.
- **Keep README.md up to date**: After implementing code changes, check whether the `README.md` needs to be updated (e.g., new features, changed ENV variables, new commands, changed architecture, new endpoints, updated screenshots). If changes are relevant to the README, **ask the user for confirmation** before updating it. Do NOT silently update the README — always present the proposed README changes and wait for approval. Examples of README-relevant changes: new ENV variables, new API endpoints, new UI features, changed setup/install steps, new dependencies, changed Docker configuration.
## Architecture Overview ## Architecture Overview
+4 -42
View File
@@ -25,50 +25,12 @@ env:
jobs: jobs:
# ============================================================================= # =============================================================================
# Run Tests First # Build and Push Docker Images
# ============================================================================= # Tests are NOT run here — branch protection on main requires all PR checks
backend-test: # (backend-test + frontend-build from test.yml) to pass before merge.
name: Backend Tests # Tags are created from main, so code is already tested.
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- run: npm ci
- run: npx tsc --noEmit
- run: npm run test:run
frontend-build:
name: Frontend Build
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run build
# =============================================================================
# Build and Push Docker Images (only after tests pass)
# ============================================================================= # =============================================================================
build-and-push: build-and-push:
needs: [backend-test, frontend-build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
-1
View File
@@ -52,7 +52,6 @@ services:
- /tmp:noexec,nosuid,size=64m - /tmp:noexec,nosuid,size=64m
- /var/cache/nginx:noexec,nosuid,size=64m - /var/cache/nginx:noexec,nosuid,size=64m
- /var/run:noexec,nosuid,size=64m - /var/run:noexec,nosuid,size=64m
- /etc/nginx/conf.d:noexec,nosuid,size=1m
cap_drop: cap_drop:
- ALL - ALL
+3 -3
View File
@@ -1,6 +1,6 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import type { Coverage, FormState, Medication, RefillEntry } from "../types"; import type { Coverage, FormState, Medication, RefillEntry } from "../types";
import { getMedTotal } from "../types"; import { getMedTotal, getPackageSize } from "../types";
export interface UseRefillReturn { export interface UseRefillReturn {
// Refill state // Refill state
@@ -146,8 +146,8 @@ export function useRefill(): UseRefillReturn {
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills; const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
// The "base" from DB structure (without any stockAdjustment) // The "base" from DB structure (without any stockAdjustment)
const baseTotal = // Use getPackageSize() which handles both blister and bottle types correctly
selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets; const baseTotal = getPackageSize(selectedMed);
// stockAdjustment = what we need to make getMedTotal() return desiredTotal // stockAdjustment = what we need to make getMedTotal() return desiredTotal
const newStockAdjustment = desiredTotal - baseTotal; const newStockAdjustment = desiredTotal - baseTotal;
+108
View File
@@ -287,6 +287,114 @@ describe("useRefill", () => {
expect(mockLoadMeds).toHaveBeenCalled(); expect(mockLoadMeds).toHaveBeenCalled();
}); });
it("stock correction uses correct base for bottle type medications", async () => {
// BUG FIX: submitStockCorrection used blister formula (packCount * blistersPerPack * pillsPerBlister + looseTablets)
// for ALL medications, but getMedTotal() uses only looseTablets + stockAdjustment for bottles.
// This mismatch caused the correction to compute the wrong stockAdjustment.
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const bottleMed: Medication = {
id: 4,
name: "Pills in a Box",
packageType: "bottle",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
stockAdjustment: -2,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
// getMedTotal for bottle = looseTablets + stockAdjustment = 150 + (-2) = 148
// getPackageSize for bottle = looseTablets = 150
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
// Pre-fill: user sees 148 pills (148 / 1 = 148 full, 0 partial)
act(() => {
result.current.openEditStockModal(bottleMed, {
all: [{ name: "Pills in a Box", medsLeft: 148, daysLeft: 148 }] as Coverage[],
});
});
// User adds +1 → 149 full blisters (pillsPerBlister=1)
act(() => {
result.current.setEditStockFullBlisters(149);
result.current.setEditStockPartialBlisterPills(0);
});
await act(async () => {
await result.current.submitStockCorrection(4, bottleMed, mockLoadMeds);
});
// desiredTotal = 149 * 1 + 0 = 149
// baseTotal (fixed) = getPackageSize(bottle) = looseTablets = 150
// newStockAdjustment = 149 - 150 = -1
// → getMedTotal = 150 + (-1) = 149 ✓
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call: [string, RequestInit]) => call[0] === "/api/medications/4/stock-adjustment"
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
expect(body.stockAdjustment).toBe(-1); // NOT -2 (the old bug)
});
it("stock correction uses correct base for blister type medications", async () => {
// Ensure blister type still works correctly after the bottle fix
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const blisterMed: Medication = {
id: 2,
name: "Blister Med",
packageType: "blister",
packCount: 1,
blistersPerPack: 5,
pillsPerBlister: 5,
looseTablets: 0,
stockAdjustment: 1,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-30T21:07:00" }],
updatedAt: null,
};
// getMedTotal for blister = 1*5*5 + 0 + 1 = 26
// getPackageSize for blister = 1*5*5 + 0 = 25
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
// User sees 26 pills → 5 full blisters (5pills each) + 1 partial
act(() => {
result.current.openEditStockModal(blisterMed, {
all: [{ name: "Blister Med", medsLeft: 26, daysLeft: 26 }] as Coverage[],
});
});
// User changes to 27 (+1): 5 full + 2 partial
act(() => {
result.current.setEditStockFullBlisters(5);
result.current.setEditStockPartialBlisterPills(2);
});
await act(async () => {
await result.current.submitStockCorrection(2, blisterMed, mockLoadMeds);
});
// desiredTotal = 5 * 5 + 2 = 27
// baseTotal = getPackageSize(blister) = 1*5*5 + 0 = 25
// newStockAdjustment = 27 - 25 = 2
// → getMedTotal = 25 + 2 = 27 ✓
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call: [string, RequestInit]) => call[0] === "/api/medications/2/stock-adjustment"
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
expect(body.stockAdjustment).toBe(2);
});
it("allows setting state directly", () => { it("allows setting state directly", () => {
const { result } = renderHook(() => useRefill()); const { result } = renderHook(() => useRefill());
+181
View File
@@ -483,6 +483,187 @@ describe("calculateCoverage", () => {
// medsLeft = 23 - 1 = 22 // medsLeft = 23 - 1 = 22
expect(result.all[0].medsLeft).toBe(22); expect(result.all[0].medsLeft).toBe(22);
}); });
it("manual mode: stock correction excludes same-day taken doses", () => {
// BUG FIX: In manual mode, doses taken on the same day as a stock correction
// were counted as consumed (>= comparison with date-only timestamps).
// The user already accounted for today's consumption when setting the stock count.
//
// Scenario: User has 110 pills, took 1 dose today, corrects to 111.
// Bug: medsLeft = 111 - 1 = 110 (today's dose counted)
// Fix: medsLeft = 111 - 0 = 111 (today's dose excluded)
const correctionTime = new Date("2024-03-15T12:00:00Z");
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
const meds: Medication[] = [
{
id: 1,
name: "DailyMed",
packCount: 1,
blistersPerPack: 14,
pillsPerBlister: 14,
looseTablets: 0,
stockAdjustment: -85, // 196 - 85 = 111 pills
lastStockCorrectionAt: correctionTime.toISOString(),
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-01-01T08:00:00",
},
],
updatedAt: correctionTime.toISOString(),
},
];
// User took a dose today (before the correction)
const takenDoses = new Set([`1-0-${todayMidnight}`]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
expect(result.all).toHaveLength(1);
// getMedTotal = 196 - 85 = 111
// Today's taken dose should NOT be counted (same day as correction)
expect(result.all[0].medsLeft).toBe(111);
});
it("manual mode: stock correction counts next-day taken doses", () => {
// After a stock correction, doses taken the next day SHOULD be counted.
const correctionTime = new Date("2024-03-14T12:00:00Z");
const march15Midnight = new Date("2024-03-15T00:00:00").getTime();
const meds: Medication[] = [
{
id: 1,
name: "DailyMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
stockAdjustment: -7, // 30 - 7 = 23 pills
lastStockCorrectionAt: correctionTime.toISOString(),
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-01T08:00:00",
},
],
updatedAt: correctionTime.toISOString(),
},
];
// User takes dose on March 15 (day after correction on March 14)
const takenDoses = new Set([`1-0-${march15Midnight}`]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
expect(result.all).toHaveLength(1);
// getMedTotal = 30 - 7 = 23
// March 15 dose should be counted (day after correction)
expect(result.all[0].medsLeft).toBe(22);
});
it("manual mode: no stock correction counts all taken doses", () => {
// Without any stock correction, all taken doses should be counted
const march14Midnight = new Date("2024-03-14T00:00:00").getTime();
const march15Midnight = new Date("2024-03-15T00:00:00").getTime();
const meds: Medication[] = [
{
id: 1,
name: "DailyMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
takenBy: [],
blisters: [
{
usage: 1,
every: 1,
start: "2024-03-01T08:00:00",
},
],
updatedAt: null,
},
];
// User took doses on March 14 and 15
const takenDoses = new Set([`1-0-${march14Midnight}`, `1-0-${march15Midnight}`]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
expect(result.all).toHaveLength(1);
// Both doses should be counted: medsLeft = 30 - 2 = 28
expect(result.all[0].medsLeft).toBe(28);
});
it("manual mode: stock correction with multiple medications", () => {
// Regression test: 3 medications (daily, daily, weekly).
// Stock correction on all 3. The daily ones have same-day taken doses.
const correctionTime = new Date("2024-03-15T12:00:00Z");
const todayMidnight = new Date("2024-03-15T00:00:00").getTime();
const meds: Medication[] = [
{
id: 1,
name: "DailyMed1",
packCount: 1,
blistersPerPack: 14,
pillsPerBlister: 14,
looseTablets: 0,
stockAdjustment: -85, // 196 - 85 = 111
lastStockCorrectionAt: correctionTime.toISOString(),
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T08:00:00" }],
updatedAt: correctionTime.toISOString(),
},
{
id: 2,
name: "DailyMed2",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 30,
looseTablets: 0,
stockAdjustment: -10, // 30 - 10 = 20
lastStockCorrectionAt: correctionTime.toISOString(),
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2024-01-01T09:00:00" }],
updatedAt: correctionTime.toISOString(),
},
{
id: 3,
name: "WeeklyMed",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
stockAdjustment: -2, // 10 - 2 = 8
lastStockCorrectionAt: correctionTime.toISOString(),
takenBy: [],
blisters: [{ usage: 1, every: 7, start: "2024-01-05T10:00:00" }],
updatedAt: correctionTime.toISOString(),
},
];
// Daily meds have same-day taken doses, weekly med does not
const takenDoses = new Set([`1-0-${todayMidnight}`, `2-0-${todayMidnight}`]);
const result = calculateCoverage(meds, [], "en", 7, "manual", takenDoses);
expect(result.all).toHaveLength(3);
const daily1 = result.all.find((c) => c.name === "DailyMed1")!;
const daily2 = result.all.find((c) => c.name === "DailyMed2")!;
const weekly = result.all.find((c) => c.name === "WeeklyMed")!;
// All three should reflect full stock (same-day doses excluded)
expect(daily1.medsLeft).toBe(111);
expect(daily2.medsLeft).toBe(20);
expect(weekly.medsLeft).toBe(8);
});
}); });
describe("getStockStatus", () => { describe("getStockStatus", () => {
+1 -1
View File
@@ -202,7 +202,7 @@ export function calculateCoverage(
if ( if (
!Number.isNaN(blisterStartDateOnly) && !Number.isNaN(blisterStartDateOnly) &&
doseTimestamp >= blisterStartDateOnly && doseTimestamp >= blisterStartDateOnly &&
doseTimestamp >= stockCorrectionDateOnly doseTimestamp > stockCorrectionDateOnly
) { ) {
consumed += blisters[blisterIdx].usage; consumed += blisters[blisterIdx].usage;
} }
+86 -55
View File
@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
# ============================================================================= # =============================================================================
# MedAssist Release Script # MedAssist Release Script (non-interactive)
# ============================================================================= # =============================================================================
# Usage: # Usage:
# ./scripts/release.sh patch # 1.0.0 -> 1.0.1 (bugfixes) # ./scripts/release.sh patch # 1.0.0 -> 1.0.1 (bugfixes)
@@ -8,8 +8,9 @@
# ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes) # ./scripts/release.sh major # 1.0.0 -> 2.0.0 (breaking changes)
# ./scripts/release.sh 1.2.3 # explicit version # ./scripts/release.sh 1.2.3 # explicit version
# #
# This script creates a PR for the version bump (required due to branch protection), # Fully non-interactive: no y/N prompts. Designed to be called by AI agents
# waits for CI, merges it, and then creates a signed tag for the release. # or CI systems. Creates a PR for the version bump (required due to branch
# protection), waits for CI with retry logic, merges, and creates a signed tag.
# ============================================================================= # =============================================================================
set -e set -e
@@ -21,45 +22,98 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# GitHub repo # Configuration
GITHUB_REPO="DanielVolz/medassist-ng" GITHUB_REPO="DanielVolz/medassist-ng"
CI_POLL_INTERVAL=15 # seconds between CI status polls
CI_INITIAL_DELAY=20 # seconds to wait before first CI check
CI_MAX_WAIT=600 # maximum seconds to wait for CI (10 minutes)
# Get script directory and project root # Get script directory and project root
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT" cd "$PROJECT_ROOT"
# Check for gh CLI # Detect git remote name (prefer 'origin', fall back to 'github')
detect_remote() {
if git remote | grep -q '^origin$'; then
echo "origin"
elif git remote | grep -q '^github$'; then
echo "github"
else
echo -e "${RED}Error: No 'origin' or 'github' remote found.${NC}" >&2
exit 1
fi
}
GIT_REMOTE=$(detect_remote)
# Wait for CI checks on a PR with retry logic
# GitHub Actions can take 10-30 seconds before checks are reported.
# This function polls until checks appear and then watches them.
wait_for_ci() {
local pr_number="$1"
local elapsed=0
echo -e "${BLUE}Waiting ${CI_INITIAL_DELAY}s for CI checks to be registered...${NC}"
sleep "${CI_INITIAL_DELAY}"
elapsed=$CI_INITIAL_DELAY
while [[ $elapsed -lt $CI_MAX_WAIT ]]; do
# Check if any checks have been reported
local check_output
check_output=$(gh pr checks "${pr_number}" --repo "${GITHUB_REPO}" 2>&1) || true
if echo "$check_output" | grep -q "no checks reported"; then
echo -e "${YELLOW}No checks reported yet (${elapsed}s elapsed). Retrying in ${CI_POLL_INTERVAL}s...${NC}"
sleep "${CI_POLL_INTERVAL}"
elapsed=$((elapsed + CI_POLL_INTERVAL))
continue
fi
# Checks are registered — use --watch to wait for completion
echo -e "${BLUE}CI checks registered. Watching for completion...${NC}"
if gh pr checks "${pr_number}" --repo "${GITHUB_REPO}" --watch; then
echo -e "${GREEN}CI checks passed!${NC}"
return 0
else
echo -e "${RED}CI checks failed!${NC}"
return 1
fi
done
echo -e "${RED}Timed out waiting for CI checks after ${CI_MAX_WAIT}s.${NC}"
return 1
}
# ─── Preflight checks ────────────────────────────────────────────────────────
if ! command -v gh &> /dev/null; then if ! command -v gh &> /dev/null; then
echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}" echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}"
echo "Install it with: brew install gh" echo "Install it with: brew install gh"
exit 1 exit 1
fi fi
# Check gh authentication
if ! gh auth status &> /dev/null; then if ! gh auth status &> /dev/null; then
echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}" echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}"
echo "Run: gh auth login" echo "Run: gh auth login"
exit 1 exit 1
fi fi
# Check for uncommitted changes
if [[ -n $(git status --porcelain) ]]; then if [[ -n $(git status --porcelain) ]]; then
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}" echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
git status --short git status --short
exit 1 exit 1
fi fi
# Make sure we're on main and up to date # ─── Determine version ───────────────────────────────────────────────────────
echo -e "${BLUE}Updating main branch...${NC}" echo -e "${BLUE}Updating main branch...${NC}"
git checkout main git checkout main
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true git pull "${GIT_REMOTE}" main
# Get current version from backend/package.json
CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/') CURRENT_VERSION=$(grep '"version"' backend/package.json | sed 's/.*"version": "\(.*\)".*/\1/')
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}" echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
# Calculate new version
if [[ -z "$1" ]]; then if [[ -z "$1" ]]; then
echo -e "${RED}Usage: $0 <patch|minor|major|x.y.z>${NC}" echo -e "${RED}Usage: $0 <patch|minor|major|x.y.z>${NC}"
exit 1 exit 1
@@ -79,7 +133,6 @@ case "$1" in
NEW_VERSION="$((major + 1)).0.0" NEW_VERSION="$((major + 1)).0.0"
;; ;;
*) *)
# Assume explicit version (validate format)
if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}Invalid version format. Use: x.y.z${NC}" echo -e "${RED}Invalid version format. Use: x.y.z${NC}"
exit 1 exit 1
@@ -88,45 +141,31 @@ case "$1" in
;; ;;
esac esac
echo -e "${GREEN}New version: ${YELLOW}v${NEW_VERSION}${NC}" echo -e "${GREEN}Releasing: ${YELLOW}v${CURRENT_VERSION} → v${NEW_VERSION}${NC}"
echo ""
# Confirm # ─── Create release branch and PR ────────────────────────────────────────────
read -p "Release v${NEW_VERSION}? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 1
fi
# Branch name for the release
RELEASE_BRANCH="chore/release-${NEW_VERSION}" RELEASE_BRANCH="chore/release-${NEW_VERSION}"
# Check if branch already exists
if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then
echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}" echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}"
git branch -D "${RELEASE_BRANCH}" git branch -D "${RELEASE_BRANCH}"
fi fi
# Create release branch
echo -e "${BLUE}Creating release branch...${NC}" echo -e "${BLUE}Creating release branch...${NC}"
git checkout -b "${RELEASE_BRANCH}" git checkout -b "${RELEASE_BRANCH}"
# Update version in package.json files
echo -e "${BLUE}Updating package.json files...${NC}" echo -e "${BLUE}Updating package.json files...${NC}"
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" backend/package.json
sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" frontend/package.json 2>/dev/null || true sed -i '' "s/\"version\": \"${CURRENT_VERSION}\"/\"version\": \"${NEW_VERSION}\"/" frontend/package.json 2>/dev/null || true
# Commit version bump
echo -e "${BLUE}Committing version bump...${NC}" echo -e "${BLUE}Committing version bump...${NC}"
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
git commit -m "chore: release v${NEW_VERSION}" git commit -m "chore: release v${NEW_VERSION}"
# Push branch to GitHub echo -e "${BLUE}Pushing release branch...${NC}"
echo -e "${BLUE}Pushing release branch to GitHub...${NC}" git push -u "${GIT_REMOTE}" "${RELEASE_BRANCH}"
git push -u origin "${RELEASE_BRANCH}" 2>/dev/null || git push -u github "${RELEASE_BRANCH}"
# Create PR
echo -e "${BLUE}Creating Pull Request...${NC}" echo -e "${BLUE}Creating Pull Request...${NC}"
PR_URL=$(gh pr create \ PR_URL=$(gh pr create \
--repo "${GITHUB_REPO}" \ --repo "${GITHUB_REPO}" \
@@ -136,57 +175,49 @@ PR_URL=$(gh pr create \
Automated version bump for release v${NEW_VERSION}. Automated version bump for release v${NEW_VERSION}.
This PR was created by the release script." \ This PR was created by the release script.")
2>&1)
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}" echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
# Extract PR number
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
# Wait for CI checks # ─── Wait for CI and merge ────────────────────────────────────────────────────
echo -e "${BLUE}Waiting for CI checks to complete...${NC}"
if ! gh pr checks "${PR_NUMBER}" --repo "${GITHUB_REPO}" --watch; then if ! wait_for_ci "${PR_NUMBER}"; then
echo -e "${RED}CI checks failed! Please fix the issues and try again.${NC}" echo -e "${RED}CI checks failed! Please fix the issues and try again.${NC}"
echo -e "${RED}Release branch '${RELEASE_BRANCH}' and PR #${PR_NUMBER} are still open.${NC}"
exit 1 exit 1
fi fi
echo -e "${GREEN}CI checks passed!${NC}" echo -e "${BLUE}Merging PR #${PR_NUMBER}...${NC}"
# Merge PR
echo -e "${BLUE}Merging PR...${NC}"
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
# Switch back to main and pull echo -e "${BLUE}Updating main branch...${NC}"
echo -e "${BLUE}Updating main branch with merged changes...${NC}"
git checkout main git checkout main
git pull origin main 2>/dev/null || git pull github main 2>/dev/null || true git pull "${GIT_REMOTE}" main
# ─── Create and push signed tag ──────────────────────────────────────────────
# Check if tag exists and delete it
if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then if git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; then
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}" echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}"
git tag -d "v${NEW_VERSION}" git tag -d "v${NEW_VERSION}"
fi fi
# Check if remote tag exists if git ls-remote --tags "${GIT_REMOTE}" "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
if git ls-remote --tags origin "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}" || \
git ls-remote --tags github "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
echo -e "${YELLOW}Tag v${NEW_VERSION} exists on remote. Deleting...${NC}" echo -e "${YELLOW}Tag v${NEW_VERSION} exists on remote. Deleting...${NC}"
git push origin ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true git push "${GIT_REMOTE}" ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
git push github ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
fi fi
# Create signed tag
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}" echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
# Push tag echo -e "${BLUE}Pushing tag...${NC}"
echo -e "${BLUE}Pushing tag to GitHub...${NC}" git push "${GIT_REMOTE}" "v${NEW_VERSION}"
git push origin "v${NEW_VERSION}" 2>/dev/null || git push github "v${NEW_VERSION}"
# ─── Done ─────────────────────────────────────────────────────────────────────
echo "" echo ""
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}✓ Released v${NEW_VERSION}${NC}" echo -e "${GREEN} ✓ Released v${NEW_VERSION}${NC}"
echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}" echo -e "${GREEN}════════════════════════════════════════════════════════════${NC}"
echo "" echo ""
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}" echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"