mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
chore(release): prepare v1.31.4
This commit is contained in:
1 parent
3ce6726646
commit
b38f41b99d
12 files changed
+99
-68
No files matched your search
@@ -2,6 +2,27 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.31.4] — 2026-07-21
|
||||
|
||||
- **Web and background processes now start and recover predictably.** Invalid
|
||||
encryption-key configuration fails before the service accepts traffic,
|
||||
worker-only processes report their real health, connection limits are
|
||||
divided safely across replicas, and provider backfills keep their bounded
|
||||
queues during reconnects.
|
||||
- **Published images are reproducible and verified before promotion.** The
|
||||
release pipeline builds one multi-architecture image, validates its digest,
|
||||
startup, migrations, health, and reported version, and only then promotes
|
||||
that exact artifact to production.
|
||||
- **CI now uses isolated, deterministic test environments.** Integration tests
|
||||
receive their own database and cryptographic settings before application
|
||||
code loads, worktrees install hooks consistently, and browser tests use the
|
||||
intended application process on every platform.
|
||||
- **Concurrent iOS registrations no longer fail intermittently.** Device and
|
||||
APNs notification-channel reconciliation now share one database lock and
|
||||
transaction, so simultaneous first registration remains idempotent.
|
||||
|
||||
No migrations. No breaking changes.
|
||||
|
||||
## [1.31.3] — 2026-07-20
|
||||
|
||||
- **Document summaries now stay in the language you chose.** The same account
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.31.3
|
||||
version: 1.31.4
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.31.3",
|
||||
"version": "1.31.4",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.31.3";
|
||||
/* @sw-version-fallback */ "v1.31.4";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -38,10 +38,7 @@ describe("Playwright CI determinism", () => {
|
||||
|
||||
expect(config.retries).toBe(2);
|
||||
expect(config.failOnFlakyTests).toBe(true);
|
||||
expect(config.reporter).toEqual([
|
||||
["github"],
|
||||
["html", { open: "never" }],
|
||||
]);
|
||||
expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]);
|
||||
});
|
||||
|
||||
it("retains the Playwright report after every workflow outcome", () => {
|
||||
|
||||
@@ -33,8 +33,7 @@ function runPrepareFixture(
|
||||
lefthookStatus = 0,
|
||||
): PrepareResult {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath)
|
||||
throw new Error("Vitest did not expose the current test path");
|
||||
if (!testPath) throw new Error("Vitest did not expose the current test path");
|
||||
const root = join(dirname(testPath), "../..");
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-"));
|
||||
temporaryDirectories.push(directory);
|
||||
@@ -70,7 +69,7 @@ function runPrepareFixture(
|
||||
lefthook,
|
||||
process.platform === "win32"
|
||||
? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n'
|
||||
: "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$FIXTURE_LEFTHOOK_LOG\"\nexit \"$FIXTURE_LEFTHOOK_STATUS\"\n",
|
||||
: '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$FIXTURE_LEFTHOOK_LOG"\nexit "$FIXTURE_LEFTHOOK_STATUS"\n',
|
||||
);
|
||||
if (process.platform !== "win32") chmodSync(lefthook, 0o755);
|
||||
|
||||
|
||||
@@ -48,7 +48,11 @@ function loadWorkflow(name: string): Workflow {
|
||||
return parse(readRepoFile(join(".github/workflows", name))) as Workflow;
|
||||
}
|
||||
|
||||
function stepScript(workflow: Workflow, jobName: string, stepName: string): string {
|
||||
function stepScript(
|
||||
workflow: Workflow,
|
||||
jobName: string,
|
||||
stepName: string,
|
||||
): string {
|
||||
const step = workflow.jobs[jobName]?.steps?.find(
|
||||
(candidate) => candidate.name === stepName,
|
||||
);
|
||||
@@ -93,7 +97,12 @@ if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
return spawnSync(
|
||||
"bash",
|
||||
["-euo", "pipefail", "-c", stepScript(workflow, "release-policy", "Validate release policy")],
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "release-policy", "Validate release policy"),
|
||||
],
|
||||
{
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
@@ -114,9 +123,10 @@ if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi
|
||||
);
|
||||
}
|
||||
|
||||
function runExactPull(
|
||||
resolvedDigest: string,
|
||||
): { result: SpawnSyncReturns<string>; commands: string } {
|
||||
function runExactPull(resolvedDigest: string): {
|
||||
result: SpawnSyncReturns<string>;
|
||||
commands: string;
|
||||
} {
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const dockerPath = join(directory, "docker");
|
||||
@@ -138,7 +148,12 @@ exit 1
|
||||
const workflow = loadWorkflow("post-publish-verify.yml");
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
["-euo", "pipefail", "-c", stepScript(workflow, "verify", "Pull exact image")],
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "verify", "Pull exact image"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
@@ -179,7 +194,12 @@ printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_S
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
return spawnSync(
|
||||
"bash",
|
||||
["-euo", "pipefail", "-c", stepScript(workflow, "promote", "Trigger Coolify deploy")],
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "promote", "Trigger Coolify deploy"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
@@ -219,9 +239,9 @@ describe("release container inputs", () => {
|
||||
};
|
||||
expect(packageJson.packageManager).toBe("pnpm@10.31.0");
|
||||
expect(dockerfile).not.toContain("pnpm@latest");
|
||||
expect(dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g)).toHaveLength(
|
||||
3,
|
||||
);
|
||||
expect(
|
||||
dockerfile.match(/corepack prepare pnpm@10\.31\.0 --activate/g),
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("relies on and verifies standalone dependency resolution", () => {
|
||||
@@ -352,10 +372,10 @@ describe("exact post-publish verification", () => {
|
||||
}
|
||||
|
||||
expect(stepScript(workflow, "verify", "Run migrations")).toContain(
|
||||
'${IMAGE_REF}@${IMAGE_DIGEST}',
|
||||
"${IMAGE_REF}@${IMAGE_DIGEST}",
|
||||
);
|
||||
expect(stepScript(workflow, "verify", "Start exact image")).toContain(
|
||||
'${IMAGE_REF}@${IMAGE_DIGEST}',
|
||||
"${IMAGE_REF}@${IMAGE_DIGEST}",
|
||||
);
|
||||
expect(stepScript(workflow, "verify", "Verify exact version")).toContain(
|
||||
'jq -e --arg expected "$IMAGE_TAG"',
|
||||
|
||||
@@ -274,6 +274,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Keep channel reconciliation under the same identity locks as the device
|
||||
// row. Moving it after commit lets two first-time registrations race the
|
||||
// compound unique key and intermittently turns one valid request into 500.
|
||||
if (apnsToken) {
|
||||
await tx.notificationChannel.upsert({
|
||||
where: { userId_type: { userId: user.id, type: "APNS" } },
|
||||
create: {
|
||||
userId: user.id,
|
||||
type: "APNS",
|
||||
enabled: true,
|
||||
config: encrypt("{}"),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
return { id: device.id };
|
||||
});
|
||||
|
||||
@@ -292,22 +308,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { id } = registration;
|
||||
|
||||
// Reconcile APNs channel state only after the canonical device transaction
|
||||
// commits. Config is an encrypted empty record by design — the per-device
|
||||
// token and environment live on the Device row.
|
||||
if (apnsToken) {
|
||||
await prisma.notificationChannel.upsert({
|
||||
where: { userId_type: { userId: user.id, type: "APNS" } },
|
||||
create: {
|
||||
userId: user.id,
|
||||
type: "APNS",
|
||||
enabled: true,
|
||||
config: encrypt("{}"),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
await auditLog("devices.register", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
|
||||
@@ -42,9 +42,7 @@ describe("per-process database connection budget", () => {
|
||||
expect(getConnectionBudget()).toBe(20);
|
||||
expect(getPrismaPoolMax()).toBe(18);
|
||||
expect(getPgBossPoolMax()).toBe(2);
|
||||
expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(
|
||||
getConnectionBudget(),
|
||||
);
|
||||
expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(getConnectionBudget());
|
||||
});
|
||||
|
||||
it("uses the existing pool_timeout seconds for both pool constructors", () => {
|
||||
|
||||
@@ -3,9 +3,7 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
interface MockBoss {
|
||||
start: Mock<() => Promise<unknown>>;
|
||||
stop: Mock<(options?: { graceful?: boolean }) => Promise<void>>;
|
||||
on: Mock<
|
||||
(event: string, handler: (error: Error) => void) => MockBoss
|
||||
>;
|
||||
on: Mock<(event: string, handler: (error: Error) => void) => MockBoss>;
|
||||
emitError(error: Error): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
|
||||
import {
|
||||
getPgBossPoolMax,
|
||||
getPoolConnectionTimeoutMs,
|
||||
} from "@/lib/db";
|
||||
import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db";
|
||||
|
||||
const BOSS_KEY = "__healthlog_pgboss__" as const;
|
||||
const BOSS_START_KEY = "__healthlog_pgboss_start__" as const;
|
||||
@@ -36,7 +33,9 @@ function wait(ms: number): Promise<void> {
|
||||
return promise;
|
||||
}
|
||||
|
||||
function resolveRetryPolicy(options: ProducerStartOptions): ProducerRetryPolicy {
|
||||
function resolveRetryPolicy(
|
||||
options: ProducerStartOptions,
|
||||
): ProducerRetryPolicy {
|
||||
const maxAttempts = Math.max(
|
||||
1,
|
||||
Math.trunc(options.maxAttempts ?? PRODUCER_START_MAX_ATTEMPTS),
|
||||
@@ -52,7 +51,10 @@ function resolveRetryPolicy(options: ProducerStartOptions): ProducerRetryPolicy
|
||||
return { maxAttempts, baseDelayMs, maxDelayMs };
|
||||
}
|
||||
|
||||
function retryDelayMs(failedAttempt: number, policy: ProducerRetryPolicy): number {
|
||||
function retryDelayMs(
|
||||
failedAttempt: number,
|
||||
policy: ProducerRetryPolicy,
|
||||
): number {
|
||||
return Math.min(
|
||||
policy.maxDelayMs,
|
||||
policy.baseDelayMs * 2 ** Math.max(0, failedAttempt - 1),
|
||||
@@ -164,8 +166,7 @@ export async function startGlobalBossProducer(
|
||||
if (existing) return existing;
|
||||
|
||||
const starting = globalState[BOSS_START_KEY] as
|
||||
| Promise<PgBoss | null>
|
||||
| undefined;
|
||||
Promise<PgBoss | null> | undefined;
|
||||
if (starting) return starting;
|
||||
|
||||
const startPromise = startProducerAttempts(
|
||||
|
||||
@@ -8,23 +8,20 @@ const TEST_HMAC_KEY = "integration-test-hmac-key-32-bytes-minimum";
|
||||
const TEST_SESSION_SECRET = "integration-test-session-secret-32-bytes";
|
||||
|
||||
describe("integration worker environment", () => {
|
||||
it(
|
||||
"replaces ambient database and encryption configuration with test values",
|
||||
async () => {
|
||||
expect(process.env.DATABASE_URL).toContain("healthlog_test");
|
||||
expect(process.env.DATABASE_URL).not.toContain("ambient_database");
|
||||
expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY);
|
||||
expect(process.env.ENCRYPTION_KEYS).toBeUndefined();
|
||||
expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined();
|
||||
expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY);
|
||||
expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET);
|
||||
it("replaces ambient database and encryption configuration with test values", async () => {
|
||||
expect(process.env.DATABASE_URL).toContain("healthlog_test");
|
||||
expect(process.env.DATABASE_URL).not.toContain("ambient_database");
|
||||
expect(process.env.ENCRYPTION_KEY).toBe(TEST_ENCRYPTION_KEY);
|
||||
expect(process.env.ENCRYPTION_KEYS).toBeUndefined();
|
||||
expect(process.env.ENCRYPTION_ACTIVE_KEY_ID).toBeUndefined();
|
||||
expect(process.env.API_TOKEN_HMAC_KEY).toBe(TEST_HMAC_KEY);
|
||||
expect(process.env.SESSION_SECRET).toBe(TEST_SESSION_SECRET);
|
||||
|
||||
const rows = await getPrismaClient().$queryRaw<
|
||||
Array<{ database_name: string }>
|
||||
>`
|
||||
const rows = await getPrismaClient().$queryRaw<
|
||||
Array<{ database_name: string }>
|
||||
>`
|
||||
SELECT current_database() AS database_name
|
||||
`;
|
||||
expect(rows).toEqual([{ database_name: "healthlog_test" }]);
|
||||
},
|
||||
);
|
||||
expect(rows).toEqual([{ database_name: "healthlog_test" }]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user