fix: address CI and PR review feedback

Co-authored-by: claw <stanislavkalishin+claw@gmail.com>
This commit is contained in:
2026-03-30 15:58:58 +02:00
parent 4d8d2fb6cf
commit ed2fbac284
10 changed files with 85 additions and 42 deletions

View File

@@ -29,13 +29,11 @@ jobs:
outputs: outputs:
eligible: ${{ steps.detect.outputs.eligible }} eligible: ${{ steps.detect.outputs.eligible }}
ref: ${{ steps.detect.outputs.ref }} ref: ${{ steps.detect.outputs.ref }}
sha: ${{ steps.detect.outputs.sha }}
steps: steps:
- id: detect - id: detect
run: | run: |
eligible=false eligible=false
ref="" ref=""
sha=""
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
eligible=true eligible=true
@@ -47,7 +45,6 @@ jobs:
echo "eligible=$eligible" >> "$GITHUB_OUTPUT" echo "eligible=$eligible" >> "$GITHUB_OUTPUT"
echo "ref=$ref" >> "$GITHUB_OUTPUT" echo "ref=$ref" >> "$GITHUB_OUTPUT"
echo "sha=${ref}" >> "$GITHUB_OUTPUT"
build-bot: build-bot:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -147,11 +144,14 @@ jobs:
test -n "${{ secrets.VPS_SSH_KEY }}" test -n "${{ secrets.VPS_SSH_KEY }}"
- name: Prepare SSH - name: Prepare SSH
env:
VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
run: | run: |
install -m 700 -d ~/.ssh install -m 700 -d ~/.ssh
printf '%s\n' '${{ secrets.VPS_SSH_KEY }}' > ~/.ssh/id_ed25519 printf '%s\n' '${{ secrets.VPS_SSH_KEY }}' > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" >> ~/.ssh/known_hosts test -n "$VPS_KNOWN_HOSTS"
printf '%s\n' "$VPS_KNOWN_HOSTS" > ~/.ssh/known_hosts
- name: Upload deploy bundle - name: Upload deploy bundle
run: | run: |
@@ -172,14 +172,6 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Smoke check
env:
BOT_API_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }}
MINI_APP_URL: ${{ vars.VPS_MINIAPP_URL || 'https://household.whekin.dev' }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_EXPECTED_WEBHOOK_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }}/webhook/telegram
run: bun run ops:deploy:smoke
- name: Set Telegram webhook - name: Set Telegram webhook
env: env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
@@ -192,3 +184,11 @@ jobs:
fi fi
bun run ops:telegram:webhook set bun run ops:telegram:webhook set
- name: Smoke check
env:
BOT_API_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }}
MINI_APP_URL: ${{ vars.VPS_MINIAPP_URL || 'https://household.whekin.dev' }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_EXPECTED_WEBHOOK_URL: ${{ vars.VPS_BOT_URL || 'https://household-bot.whekin.dev' }}/webhook/telegram
run: bun run ops:deploy:smoke

View File

@@ -141,7 +141,7 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
: null : null
const scheduledDispatchScheduler = const scheduledDispatchScheduler =
runtime.scheduledDispatch && runtime.scheduledDispatch &&
(runtime.scheduledDispatch.provider === 'self-hosted' || runtime.schedulerSharedSecret) (runtime.scheduledDispatch.provider === 'aws-eventbridge' || runtime.schedulerSharedSecret)
? runtime.scheduledDispatch.provider === 'gcp-cloud-tasks' ? runtime.scheduledDispatch.provider === 'gcp-cloud-tasks'
? createGcpScheduledDispatchScheduler({ ? createGcpScheduledDispatchScheduler({
projectId: runtime.scheduledDispatch.projectId, projectId: runtime.scheduledDispatch.projectId,

View File

@@ -248,8 +248,14 @@ export function getBotRuntimeConfig(env: NodeJS.ProcessEnv = process.env): BotRu
runtime.schedulerSharedSecret = schedulerSharedSecret runtime.schedulerSharedSecret = schedulerSharedSecret
} }
if (scheduledDispatch !== undefined) { if (scheduledDispatch !== undefined) {
if (scheduledDispatch.provider === 'self-hosted' && schedulerSharedSecret === undefined) { if (
throw new Error('Self-hosted scheduled dispatch requires SCHEDULER_SHARED_SECRET') (scheduledDispatch.provider === 'self-hosted' ||
scheduledDispatch.provider === 'gcp-cloud-tasks') &&
schedulerSharedSecret === undefined
) {
throw new Error(
`${scheduledDispatch.provider} scheduled dispatch requires SCHEDULER_SHARED_SECRET`
)
} }
runtime.scheduledDispatch = scheduledDispatch runtime.scheduledDispatch = scheduledDispatch

View File

@@ -20,15 +20,15 @@ function parsePositiveInteger(name: string, fallback: number): number {
return parsed return parsed
} }
async function runOnce() { async function runOnce(input: {
const baseUrl = requireEnv('BOT_INTERNAL_BASE_URL').replace(/\/$/, '') baseUrl: string
const schedulerSecret = requireEnv('SCHEDULER_SHARED_SECRET') schedulerSecret: string
const dueScanLimit = parsePositiveInteger('SCHEDULER_DUE_SCAN_LIMIT', 25) dueScanLimit: number
}) {
const response = await fetch(`${baseUrl}/jobs/dispatch-due?limit=${dueScanLimit}`, { const response = await fetch(`${input.baseUrl}/jobs/dispatch-due?limit=${input.dueScanLimit}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'x-household-scheduler-secret': schedulerSecret 'x-household-scheduler-secret': input.schedulerSecret
} }
}) })
@@ -51,6 +51,11 @@ async function runOnce() {
async function main() { async function main() {
const intervalMs = parsePositiveInteger('SCHEDULER_POLL_INTERVAL_MS', 60_000) const intervalMs = parsePositiveInteger('SCHEDULER_POLL_INTERVAL_MS', 60_000)
const runConfig = {
baseUrl: requireEnv('BOT_INTERNAL_BASE_URL').replace(/\/$/, ''),
schedulerSecret: requireEnv('SCHEDULER_SHARED_SECRET'),
dueScanLimit: parsePositiveInteger('SCHEDULER_DUE_SCAN_LIMIT', 25)
}
let stopping = false let stopping = false
const stop = () => { const stop = () => {
@@ -62,7 +67,7 @@ async function main() {
while (!stopping) { while (!stopping) {
try { try {
await runOnce() await runOnce(runConfig)
} catch (error) { } catch (error) {
console.error( console.error(
JSON.stringify({ JSON.stringify({

View File

@@ -5,7 +5,11 @@ services:
env_file: env_file:
- ${ENV_DIR:-/opt/household-bot/env}/bot.env - ${ENV_DIR:-/opt/household-bot/env}/bot.env
healthcheck: healthcheck:
test: ['CMD', 'bun', '-e', "fetch('http://127.0.0.1:' + (process.env.PORT ?? '8080') + '/healthz').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))"] test:
- CMD
- bun
- -e
- "fetch('http://127.0.0.1:' + (process.env.PORT ?? '8080') + '/healthz').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -23,7 +27,9 @@ services:
scheduler: scheduler:
image: ${BOT_IMAGE:?set BOT_IMAGE} image: ${BOT_IMAGE:?set BOT_IMAGE}
restart: unless-stopped restart: unless-stopped
command: ['bun', 'apps/bot/dist/scheduler-runner.js'] command:
- bun
- apps/bot/dist/scheduler-runner.js
env_file: env_file:
- ${ENV_DIR:-/opt/household-bot/env}/bot.env - ${ENV_DIR:-/opt/household-bot/env}/bot.env
environment: environment:
@@ -36,10 +42,13 @@ services:
migrate: migrate:
image: ${BOT_IMAGE:?set BOT_IMAGE} image: ${BOT_IMAGE:?set BOT_IMAGE}
profiles: ['ops'] profiles:
- ops
env_file: env_file:
- ${ENV_DIR:-/opt/household-bot/env}/bot.env - ${ENV_DIR:-/opt/household-bot/env}/bot.env
command: ['bun', 'packages/db/dist/migrate.js'] command:
- bun
- packages/db/dist/migrate.js
restart: 'no' restart: 'no'
caddy: caddy:

View File

@@ -1 +1 @@
BOT_API_URL=https://household-bot.whekin.dev VITE_BOT_API_URL=https://household-bot.whekin.dev

View File

@@ -125,7 +125,6 @@ These can be adjusted later without changing the deployment shape.
6. Validate builds/tests where practical 6. Validate builds/tests where practical
7. Push branch and open PR 7. Push branch and open PR
## Runtime Env Files on VPS ## Runtime Env Files on VPS
Expected files under `/opt/household-bot/env`: Expected files under `/opt/household-bot/env`:
@@ -134,6 +133,7 @@ Expected files under `/opt/household-bot/env`:
- `caddy.env` - `caddy.env`
Templates live in `deploy/vps/*.env.example`. Templates live in `deploy/vps/*.env.example`.
- `miniapp.env` should set `VITE_BOT_API_URL` for the frontend build/runtime config.
## GitHub Actions Inputs / Secrets ## GitHub Actions Inputs / Secrets
@@ -145,8 +145,9 @@ Recommended repository variables:
- `VPS_BOT_URL` (default `https://household-bot.whekin.dev`) - `VPS_BOT_URL` (default `https://household-bot.whekin.dev`)
- `VPS_MINIAPP_URL` (default `https://household.whekin.dev`) - `VPS_MINIAPP_URL` (default `https://household.whekin.dev`)
Required repository secret: Required repository secrets:
- `VPS_SSH_KEY` - `VPS_SSH_KEY`
- `VPS_KNOWN_HOSTS`
Optional for webhook sync and smoke verification: Optional for webhook sync and smoke verification:
- `TELEGRAM_BOT_TOKEN` - `TELEGRAM_BOT_TOKEN`

View File

@@ -38,8 +38,8 @@ class NotificationRepositoryStub implements AdHocNotificationRepository {
sentAt: null, sentAt: null,
cancelledAt: null, cancelledAt: null,
cancelledByMemberId: null, cancelledByMemberId: null,
createdAt: Temporal.Instant.from('2026-03-23T09:00:00Z'), createdAt: Temporal.Instant.from('2099-03-23T09:00:00Z'),
updatedAt: Temporal.Instant.from('2026-03-23T09:00:00Z') updatedAt: Temporal.Instant.from('2099-03-23T09:00:00Z')
} }
this.notifications.set(id, record) this.notifications.set(id, record)
return record return record
@@ -191,7 +191,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'Напомни Георгию завтра', originalRequestText: 'Напомни Георгию завтра',
notificationText: 'пошпынять Георгия о том, позвонил ли он', notificationText: 'пошпынять Георгия о том, позвонил ли он',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic' deliveryMode: 'topic'
}) })
@@ -222,7 +222,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind everyone tomorrow', originalRequestText: 'remind everyone tomorrow',
notificationText: 'pay rent', notificationText: 'pay rent',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'dm_all' deliveryMode: 'dm_all'
}) })
@@ -246,7 +246,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind tomorrow', originalRequestText: 'remind tomorrow',
notificationText: 'check rent', notificationText: 'check rent',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic', deliveryMode: 'topic',
friendlyTagAssignee: true friendlyTagAssignee: true
@@ -273,7 +273,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind tomorrow', originalRequestText: 'remind tomorrow',
notificationText: 'call landlord', notificationText: 'call landlord',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic', deliveryMode: 'topic',
friendlyTagAssignee: false friendlyTagAssignee: false
@@ -282,7 +282,7 @@ describe('createAdHocNotificationService', () => {
const result = await service.cancelNotification({ const result = await service.cancelNotification({
notificationId: created.id, notificationId: created.id,
viewerMemberId: 'admin', viewerMemberId: 'admin',
asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') asOf: Temporal.Instant.from('2099-03-23T09:00:00Z')
}) })
expect(result.status).toBe('cancelled') expect(result.status).toBe('cancelled')
@@ -306,7 +306,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind tomorrow', originalRequestText: 'remind tomorrow',
notificationText: 'call landlord', notificationText: 'call landlord',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic', deliveryMode: 'topic',
friendlyTagAssignee: false friendlyTagAssignee: false
@@ -315,7 +315,7 @@ describe('createAdHocNotificationService', () => {
const items = await service.listUpcomingNotifications({ const items = await service.listUpcomingNotifications({
householdId: 'household-1', householdId: 'household-1',
viewerMemberId: 'viewer', viewerMemberId: 'viewer',
asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') asOf: Temporal.Instant.from('2099-03-23T09:00:00Z')
}) })
expect(items).toHaveLength(1) expect(items).toHaveLength(1)
@@ -342,7 +342,7 @@ describe('createAdHocNotificationService', () => {
originalRequestText: 'remind tomorrow', originalRequestText: 'remind tomorrow',
notificationText: 'call landlord', notificationText: 'call landlord',
timezone: 'Asia/Tbilisi', timezone: 'Asia/Tbilisi',
scheduledFor: Temporal.Instant.from('2026-03-25T08:00:00Z'), scheduledFor: Temporal.Instant.from('2099-03-25T08:00:00Z'),
timePrecision: 'date_only_defaulted', timePrecision: 'date_only_defaulted',
deliveryMode: 'topic', deliveryMode: 'topic',
friendlyTagAssignee: false friendlyTagAssignee: false
@@ -355,7 +355,7 @@ describe('createAdHocNotificationService', () => {
timePrecision: 'exact', timePrecision: 'exact',
deliveryMode: 'dm_selected', deliveryMode: 'dm_selected',
dmRecipientMemberIds: ['alice', 'bob'], dmRecipientMemberIds: ['alice', 'bob'],
asOf: Temporal.Instant.from('2026-03-23T09:00:00Z') asOf: Temporal.Instant.from('2099-03-23T09:00:00Z')
}) })
expect(result.status).toBe('updated') expect(result.status).toBe('updated')

View File

@@ -8,6 +8,17 @@ import type {
} from '@household/ports' } from '@household/ports'
const BUILT_IN_DISPATCH_KINDS = ['utilities', 'rent_warning', 'rent_due'] as const const BUILT_IN_DISPATCH_KINDS = ['utilities', 'rent_warning', 'rent_due'] as const
const DEFAULT_DUE_DISPATCH_SCAN_LIMIT = 25
const MAX_DUE_DISPATCH_SCAN_LIMIT = 100
function normalizeDueDispatchLimit(limit: number | undefined): number {
const value = limit ?? DEFAULT_DUE_DISPATCH_SCAN_LIMIT
if (!Number.isInteger(value) || value <= 0) {
return DEFAULT_DUE_DISPATCH_SCAN_LIMIT
}
return Math.min(value, MAX_DUE_DISPATCH_SCAN_LIMIT)
}
function builtInDispatchDay( function builtInDispatchDay(
kind: (typeof BUILT_IN_DISPATCH_KINDS)[number], kind: (typeof BUILT_IN_DISPATCH_KINDS)[number],
@@ -312,7 +323,7 @@ export function createScheduledDispatchService(input: {
return input.repository.listDueScheduledDispatches({ return input.repository.listDueScheduledDispatches({
dueBefore: inputValue?.asOf ?? nowInstant(), dueBefore: inputValue?.asOf ?? nowInstant(),
provider: input.scheduler.provider, provider: input.scheduler.provider,
limit: inputValue?.limit ?? 25 limit: normalizeDueDispatchLimit(inputValue?.limit)
}) })
}, },

View File

@@ -22,6 +22,15 @@ require_var() {
fi fi
} }
require_positive_int() {
local name="$1"
local value="${!name:-}"
if [[ ! "$value" =~ ^[0-9]+$ || "$value" -le 0 ]]; then
echo "Invalid positive integer for $name: $value" >&2
exit 1
fi
}
require_var BOT_IMAGE require_var BOT_IMAGE
require_var MINIAPP_IMAGE require_var MINIAPP_IMAGE
require_file "$COMPOSE_FILE" require_file "$COMPOSE_FILE"
@@ -38,6 +47,8 @@ export MINIAPP_IMAGE
export ENV_DIR export ENV_DIR
export SCHEDULER_POLL_INTERVAL_MS="${SCHEDULER_POLL_INTERVAL_MS:-60000}" export SCHEDULER_POLL_INTERVAL_MS="${SCHEDULER_POLL_INTERVAL_MS:-60000}"
export SCHEDULER_DUE_SCAN_LIMIT="${SCHEDULER_DUE_SCAN_LIMIT:-25}" export SCHEDULER_DUE_SCAN_LIMIT="${SCHEDULER_DUE_SCAN_LIMIT:-25}"
require_positive_int SCHEDULER_POLL_INTERVAL_MS
require_positive_int SCHEDULER_DUE_SCAN_LIMIT
mkdir -p "$DEPLOY_ROOT" mkdir -p "$DEPLOY_ROOT"