Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b91717fc19 | |||
| a065adcd82 | |||
| 6edf2fa341 | |||
| 9e3d548536 |
@@ -129,13 +129,26 @@ Apply these rules strictly:
|
||||
|
||||
## 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
|
||||
./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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -25,50 +25,12 @@ env:
|
||||
|
||||
jobs:
|
||||
# =============================================================================
|
||||
# Run Tests First
|
||||
# =============================================================================
|
||||
backend-test:
|
||||
name: Backend Tests
|
||||
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 Docker Images
|
||||
# Tests are NOT run here — branch protection on main requires all PR checks
|
||||
# (backend-test + frontend-build from test.yml) to pass before merge.
|
||||
# Tags are created from main, so code is already tested.
|
||||
# =============================================================================
|
||||
build-and-push:
|
||||
needs: [backend-test, frontend-build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -52,7 +52,6 @@ services:
|
||||
- /tmp:noexec,nosuid,size=64m
|
||||
- /var/cache/nginx:noexec,nosuid,size=64m
|
||||
- /var/run:noexec,nosuid,size=64m
|
||||
- /etc/nginx/conf.d:noexec,nosuid,size=1m
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
||||
import { getMedTotal } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
|
||||
export interface UseRefillReturn {
|
||||
// Refill state
|
||||
@@ -146,8 +146,8 @@ export function useRefill(): UseRefillReturn {
|
||||
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
||||
|
||||
// The "base" from DB structure (without any stockAdjustment)
|
||||
const baseTotal =
|
||||
selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
|
||||
// Use getPackageSize() which handles both blister and bottle types correctly
|
||||
const baseTotal = getPackageSize(selectedMed);
|
||||
|
||||
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||
const newStockAdjustment = desiredTotal - baseTotal;
|
||||
|
||||
@@ -287,6 +287,114 @@ describe("useRefill", () => {
|
||||
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", () => {
|
||||
const { result } = renderHook(() => useRefill());
|
||||
|
||||
|
||||
@@ -483,6 +483,187 @@ describe("calculateCoverage", () => {
|
||||
// medsLeft = 23 - 1 = 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", () => {
|
||||
|
||||
@@ -202,7 +202,7 @@ export function calculateCoverage(
|
||||
if (
|
||||
!Number.isNaN(blisterStartDateOnly) &&
|
||||
doseTimestamp >= blisterStartDateOnly &&
|
||||
doseTimestamp >= stockCorrectionDateOnly
|
||||
doseTimestamp > stockCorrectionDateOnly
|
||||
) {
|
||||
consumed += blisters[blisterIdx].usage;
|
||||
}
|
||||
|
||||
+86
-55
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# MedAssist Release Script
|
||||
# MedAssist Release Script (non-interactive)
|
||||
# =============================================================================
|
||||
# Usage:
|
||||
# ./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 1.2.3 # explicit version
|
||||
#
|
||||
# This script creates a PR for the version bump (required due to branch protection),
|
||||
# waits for CI, merges it, and then creates a signed tag for the release.
|
||||
# Fully non-interactive: no y/N prompts. Designed to be called by AI agents
|
||||
# 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
|
||||
@@ -21,45 +22,98 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# GitHub repo
|
||||
# Configuration
|
||||
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
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
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
|
||||
echo -e "${RED}Error: GitHub CLI (gh) is required but not installed.${NC}"
|
||||
echo "Install it with: brew install gh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check gh authentication
|
||||
if ! gh auth status &> /dev/null; then
|
||||
echo -e "${RED}Error: Not authenticated with GitHub CLI.${NC}"
|
||||
echo "Run: gh auth login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for uncommitted changes
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo -e "${RED}Error: You have uncommitted changes. Commit or stash them first.${NC}"
|
||||
git status --short
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Make sure we're on main and up to date
|
||||
# ─── Determine version ───────────────────────────────────────────────────────
|
||||
|
||||
echo -e "${BLUE}Updating main branch...${NC}"
|
||||
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/')
|
||||
echo -e "${BLUE}Current version: ${YELLOW}v${CURRENT_VERSION}${NC}"
|
||||
|
||||
# Calculate new version
|
||||
if [[ -z "$1" ]]; then
|
||||
echo -e "${RED}Usage: $0 <patch|minor|major|x.y.z>${NC}"
|
||||
exit 1
|
||||
@@ -79,7 +133,6 @@ case "$1" in
|
||||
NEW_VERSION="$((major + 1)).0.0"
|
||||
;;
|
||||
*)
|
||||
# Assume explicit version (validate format)
|
||||
if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo -e "${RED}Invalid version format. Use: x.y.z${NC}"
|
||||
exit 1
|
||||
@@ -88,45 +141,31 @@ case "$1" in
|
||||
;;
|
||||
esac
|
||||
|
||||
echo -e "${GREEN}New version: ${YELLOW}v${NEW_VERSION}${NC}"
|
||||
echo ""
|
||||
echo -e "${GREEN}Releasing: ${YELLOW}v${CURRENT_VERSION} → v${NEW_VERSION}${NC}"
|
||||
|
||||
# Confirm
|
||||
read -p "Release v${NEW_VERSION}? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Aborted."
|
||||
exit 1
|
||||
fi
|
||||
# ─── Create release branch and PR ────────────────────────────────────────────
|
||||
|
||||
# Branch name for the release
|
||||
RELEASE_BRANCH="chore/release-${NEW_VERSION}"
|
||||
|
||||
# Check if branch already exists
|
||||
if git show-ref --verify --quiet "refs/heads/${RELEASE_BRANCH}"; then
|
||||
echo -e "${YELLOW}Branch ${RELEASE_BRANCH} already exists locally. Deleting...${NC}"
|
||||
git branch -D "${RELEASE_BRANCH}"
|
||||
fi
|
||||
|
||||
# Create release branch
|
||||
echo -e "${BLUE}Creating release branch...${NC}"
|
||||
git checkout -b "${RELEASE_BRANCH}"
|
||||
|
||||
# Update version in package.json files
|
||||
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}\"/" frontend/package.json 2>/dev/null || true
|
||||
|
||||
# Commit version bump
|
||||
echo -e "${BLUE}Committing version bump...${NC}"
|
||||
git add backend/package.json frontend/package.json 2>/dev/null || git add backend/package.json
|
||||
git commit -m "chore: release v${NEW_VERSION}"
|
||||
|
||||
# Push branch to GitHub
|
||||
echo -e "${BLUE}Pushing release branch to GitHub...${NC}"
|
||||
git push -u origin "${RELEASE_BRANCH}" 2>/dev/null || git push -u github "${RELEASE_BRANCH}"
|
||||
echo -e "${BLUE}Pushing release branch...${NC}"
|
||||
git push -u "${GIT_REMOTE}" "${RELEASE_BRANCH}"
|
||||
|
||||
# Create PR
|
||||
echo -e "${BLUE}Creating Pull Request...${NC}"
|
||||
PR_URL=$(gh pr create \
|
||||
--repo "${GITHUB_REPO}" \
|
||||
@@ -136,57 +175,49 @@ PR_URL=$(gh pr create \
|
||||
|
||||
Automated version bump for release v${NEW_VERSION}.
|
||||
|
||||
This PR was created by the release script." \
|
||||
2>&1)
|
||||
This PR was created by the release script.")
|
||||
|
||||
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
|
||||
|
||||
# Extract PR number
|
||||
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||
|
||||
# Wait for CI checks
|
||||
echo -e "${BLUE}Waiting for CI checks to complete...${NC}"
|
||||
if ! gh pr checks "${PR_NUMBER}" --repo "${GITHUB_REPO}" --watch; then
|
||||
# ─── Wait for CI and merge ────────────────────────────────────────────────────
|
||||
|
||||
if ! wait_for_ci "${PR_NUMBER}"; then
|
||||
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
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}CI checks passed!${NC}"
|
||||
|
||||
# Merge PR
|
||||
echo -e "${BLUE}Merging PR...${NC}"
|
||||
echo -e "${BLUE}Merging PR #${PR_NUMBER}...${NC}"
|
||||
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
|
||||
|
||||
# Switch back to main and pull
|
||||
echo -e "${BLUE}Updating main branch with merged changes...${NC}"
|
||||
echo -e "${BLUE}Updating main branch...${NC}"
|
||||
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
|
||||
echo -e "${YELLOW}Tag v${NEW_VERSION} already exists locally. Deleting...${NC}"
|
||||
git tag -d "v${NEW_VERSION}"
|
||||
fi
|
||||
|
||||
# Check if remote tag exists
|
||||
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
|
||||
if git ls-remote --tags "${GIT_REMOTE}" "v${NEW_VERSION}" 2>/dev/null | grep -q "v${NEW_VERSION}"; then
|
||||
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 github ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||
git push "${GIT_REMOTE}" ":refs/tags/v${NEW_VERSION}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create signed tag
|
||||
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
|
||||
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
||||
|
||||
# Push tag
|
||||
echo -e "${BLUE}Pushing tag to GitHub...${NC}"
|
||||
git push origin "v${NEW_VERSION}" 2>/dev/null || git push github "v${NEW_VERSION}"
|
||||
echo -e "${BLUE}Pushing tag...${NC}"
|
||||
git push "${GIT_REMOTE}" "v${NEW_VERSION}"
|
||||
|
||||
# ─── Done ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
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 ""
|
||||
echo -e "${BLUE}GitHub Actions will now build and publish Docker images.${NC}"
|
||||
|
||||
Reference in New Issue
Block a user