chore(release): prepare v1.31.4

This commit is contained in:
Marc-André Bombeck committed 2026-07-21 01:34:29 +02:00
1 parent 3ce6726646
commit b38f41b99d
12 files changed
+99 -68

No files matched your search

+21
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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/*`
+1 -4
View File
@@ -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", () => {
+2 -3
View File
@@ -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);
+32 -12
View File
@@ -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"',
+16 -16
View File
@@ -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: {
+1 -3
View File
@@ -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", () => {
+1 -3
View File
@@ -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;
}
+9 -8
View File
@@ -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(
+13 -16
View File
@@ -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" }]);
});
});