From 434cff0a153e57f5bc89f9cee47e0276470820e7 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 21:50:46 +0900 Subject: [PATCH 1/3] fix(deploy): distrust secret hash state without matching remote updateTime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local hash state could suppress a needed secret update after the remote value changed out of band (a deploy from another checkout, or a console-side edit): the stored hash still matched the desired value, so the deploy skipped the update while the platform held something else. Secrets state now stores the updateTime returned by this deploy's own create and update responses alongside each hash (schema version 2), and a planned update is skipped only when both the hash and the remote updateTime match. Missing evidence — a v1 state file, an empty retried create response, or a platform that omits updateTime — always falls back to updating, never to skipping. --- .../commands/deploy/secret-manager.test.ts | 231 +++++++++++++++--- .../src/cli/commands/deploy/secret-manager.ts | 46 +++- .../deploy/secrets-state-concurrency.test.ts | 4 +- .../cli/commands/deploy/secrets-state.test.ts | 48 ++-- .../src/cli/commands/deploy/secrets-state.ts | 21 +- packages/sdk/src/cli/shared/client.ts | 6 +- 6 files changed, 287 insertions(+), 69 deletions(-) diff --git a/packages/sdk/src/cli/commands/deploy/secret-manager.test.ts b/packages/sdk/src/cli/commands/deploy/secret-manager.test.ts index 498dd16422..ed98309058 100644 --- a/packages/sdk/src/cli/commands/deploy/secret-manager.test.ts +++ b/packages/sdk/src/cli/commands/deploy/secret-manager.test.ts @@ -65,7 +65,14 @@ function createMockApplyClient() { } as unknown as OperatorClient; } -function createMockPlanClient(existingSecrets: string[] = [], vaultName = "my-vault") { +type ExistingSecretFixture = + | string + | { name: string; updateTime?: { seconds: bigint; nanos: number } }; + +function createMockPlanClient( + existingSecrets: ExistingSecretFixture[] = [], + vaultName = "my-vault", +) { return { listSecretManagerVaults: vi.fn().mockResolvedValue({ vaults: [{ name: vaultName }], @@ -75,7 +82,9 @@ function createMockPlanClient(existingSecrets: string[] = [], vaultName = "my-va metadata: { labels: { "sdk-name": "test-app", "sdk-version": sdkVersion } }, }), listSecretManagerSecrets: vi.fn().mockResolvedValue({ - secrets: existingSecrets.map((name) => ({ name })), + secrets: existingSecrets.map((secret) => + typeof secret === "string" ? { name: secret } : secret, + ), nextPageToken: "", }), } as unknown as OperatorClient; @@ -160,7 +169,7 @@ describe("applySecretManager phase separation", () => { mockLoadSecretsState.mockReturnValue({ vaults: { "my-vault": { - "orphan-secret": hashValue("orphan-value"), + "orphan-secret": { hash: hashValue("orphan-value") }, }, }, }); @@ -253,28 +262,17 @@ describe("planSecretManager hash-based diff", () => { mockLoadSecretsState.mockReturnValue({ vaults: {} }); }); - test("skips update when hash matches stored state", async () => { + test("includes update when forceApplyAll is enabled even if evidence matches", async () => { const secretValue = "my-secret-value"; mockLoadSecretsState.mockReturnValue({ - vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } }, + vaults: { + "my-vault": { "existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" } }, + }, }); - const client = createMockPlanClient(["existing-secret"]); - const ctx = createPlanContext(client, [ - { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, ]); - - const result = await planSecretManager(ctx); - expect(result.secretChangeSet.updates).toHaveLength(0); - }); - - test("includes update when forceApplyAll is enabled even if hash matches", async () => { - const secretValue = "my-secret-value"; - mockLoadSecretsState.mockReturnValue({ - vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } }, - }); - - const client = createMockPlanClient(["existing-secret"]); const ctx = { ...createPlanContext(client, [ { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, @@ -288,10 +286,14 @@ describe("planSecretManager hash-based diff", () => { test("includes update when hash does not match", async () => { mockLoadSecretsState.mockReturnValue({ - vaults: { "my-vault": { "existing-secret": hashValue("old-value") } }, + vaults: { + "my-vault": { "existing-secret": { hash: hashValue("old-value"), updateTime: "100.5" } }, + }, }); - const client = createMockPlanClient(["existing-secret"]); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); const ctx = createPlanContext(client, [ { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: "new-value" }] }, ]); @@ -317,10 +319,18 @@ describe("planSecretManager hash-based diff", () => { const secretValue = "my-secret-value"; mockLoadSecretsState.mockImplementation((scope?: { workspaceId: string }) => scope?.workspaceId === "ws-a" - ? { vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } } } + ? { + vaults: { + "my-vault": { + "existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" }, + }, + }, + } : { vaults: {} }, ); - const client = createMockPlanClient(["existing-secret"]); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); const ctxA = createPlanContext( client, [{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }], @@ -348,10 +358,18 @@ describe("planSecretManager hash-based diff", () => { const secretValue = "my-secret-value"; mockLoadSecretsState.mockImplementation((scope?: { applicationId: string }) => scope?.applicationId === "app-a" - ? { vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } } } + ? { + vaults: { + "my-vault": { + "existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" }, + }, + }, + } : { vaults: {} }, ); - const client = createMockPlanClient(["existing-secret"]); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); const ctxA = createPlanContext( client, [{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }], @@ -376,6 +394,145 @@ describe("planSecretManager hash-based diff", () => { }); }); +describe("planSecretManager update-time evidence", () => { + const secretValue = "my-secret-value"; + const storedEntry = { hash: hashValue(secretValue), updateTime: "100.5" }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("skips update when hash and remote updateTime both match stored evidence", async () => { + mockLoadSecretsState.mockReturnValue({ + vaults: { "my-vault": { "existing-secret": storedEntry } }, + }); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); + const ctx = createPlanContext(client, [ + { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + ]); + + const result = await planSecretManager(ctx); + expect(result.secretChangeSet.updates).toHaveLength(0); + }); + + test("updates when the remote updateTime differs from stored evidence", async () => { + mockLoadSecretsState.mockReturnValue({ + vaults: { "my-vault": { "existing-secret": storedEntry } }, + }); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 200n, nanos: 0 } }, + ]); + const ctx = createPlanContext(client, [ + { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + ]); + + const result = await planSecretManager(ctx); + expect(result.secretChangeSet.updates).toHaveLength(1); + }); + + test("updates when the stored entry has no updateTime evidence", async () => { + mockLoadSecretsState.mockReturnValue({ + vaults: { "my-vault": { "existing-secret": { hash: hashValue(secretValue) } } }, + }); + const client = createMockPlanClient([ + { name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } }, + ]); + const ctx = createPlanContext(client, [ + { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + ]); + + const result = await planSecretManager(ctx); + expect(result.secretChangeSet.updates).toHaveLength(1); + }); + + test("updates when the remote secret carries no updateTime", async () => { + mockLoadSecretsState.mockReturnValue({ + vaults: { "my-vault": { "existing-secret": storedEntry } }, + }); + const client = createMockPlanClient(["existing-secret"]); + const ctx = createPlanContext(client, [ + { vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }, + ]); + + const result = await planSecretManager(ctx); + expect(result.secretChangeSet.updates).toHaveLength(1); + }); +}); + +describe("applySecretManager update-time evidence persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadSecretsState.mockReturnValue({ vaults: {} }); + }); + + function evidencePlanResult() { + return { + stateScope, + vaultChangeSet: { creates: [], updates: [], deletes: [], replaces: [] }, + secretChangeSet: { + creates: [ + { + name: "my-vault/secret-a", + secretName: "secret-a", + workspaceId: "ws-1", + vaultName: "my-vault", + value: "value-a", + }, + ], + updates: [ + { + name: "my-vault/secret-b", + secretName: "secret-b", + workspaceId: "ws-1", + vaultName: "my-vault", + value: "value-b", + }, + ], + deletes: [], + replaces: [], + }, + } as unknown as Awaited>; + } + + test("stores updateTime evidence from create and update responses", async () => { + const client = { + ...createMockApplyClient(), + createSecretManagerSecret: vi.fn().mockResolvedValue({ + secret: { name: "secret-a", updateTime: { seconds: 10n, nanos: 1 } }, + }), + updateSecretManagerSecret: vi.fn().mockResolvedValue({ + secret: { name: "secret-b", updateTime: { seconds: 20n, nanos: 2 } }, + }), + } as unknown as OperatorClient; + const application = { secrets: [] } as unknown as Application; + + await applySecretManager(client, evidencePlanResult(), "create-update", application); + + const savedState = mockSaveSecretsState.mock.calls[0]![1]; + expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ + hash: hashValue("value-a"), + updateTime: "10.1", + }); + expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({ + hash: hashValue("value-b"), + updateTime: "20.2", + }); + }); + + test("stores hash without evidence when a create response is empty", async () => { + const client = createMockApplyClient(); + const application = { secrets: [] } as unknown as Application; + + await applySecretManager(client, evidencePlanResult(), "create-update", application); + + const savedState = mockSaveSecretsState.mock.calls[0]![1]; + expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") }); + expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({ hash: hashValue("value-b") }); + }); +}); + describe("planSecretManager vault metadata and deletion", () => { beforeEach(() => { vi.clearAllMocks(); @@ -668,8 +825,8 @@ describe("applySecretManager state persistence", () => { expect(mockSaveSecretsState).toHaveBeenCalledTimes(1); const savedState = mockSaveSecretsState.mock.calls[0]![1]; - expect(savedState.vaults["my-vault"]["secret-a"]).toBe(hashValue("value-a")); - expect(savedState.vaults["my-vault"]["secret-b"]).toBe(hashValue("value-b")); + expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") }); + expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({ hash: hashValue("value-b") }); }); test("does not save state when application is not provided", async () => { @@ -706,8 +863,8 @@ describe("applySecretManager state persistence", () => { mockLoadSecretsState.mockReturnValue({ vaults: { "my-vault": { - "secret-a": hashValue("value-a"), - orphan: hashValue("orphan-value"), + "secret-a": { hash: hashValue("value-a") }, + orphan: { hash: hashValue("orphan-value") }, }, }, }); @@ -735,7 +892,7 @@ describe("applySecretManager state persistence", () => { expect(mockSaveSecretsState).toHaveBeenCalledTimes(1); const savedState = mockSaveSecretsState.mock.calls[0]![1]; - expect(savedState.vaults["my-vault"]["secret-a"]).toBe(hashValue("value-a")); + expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") }); expect(savedState.vaults["my-vault"]["orphan"]).toBeUndefined(); }); @@ -743,7 +900,7 @@ describe("applySecretManager state persistence", () => { mockLoadSecretsState.mockReturnValue({ vaults: { "my-vault": { - "only-secret": hashValue("value"), + "only-secret": { hash: hashValue("value") }, }, }, }); @@ -849,7 +1006,7 @@ describe("applySecretManager ignoreNullishValues state persistence", () => { mockLoadSecretsState.mockReturnValue({ vaults: { "my-vault": { - "nullish-secret": hashValue("previous-value"), + "nullish-secret": { hash: hashValue("previous-value") }, }, }, }); @@ -892,7 +1049,11 @@ describe("applySecretManager ignoreNullishValues state persistence", () => { expect(mockSaveSecretsState).toHaveBeenCalledTimes(1); const savedState = mockSaveSecretsState.mock.calls[0]![1]; - expect(savedState.vaults["my-vault"]["nullish-secret"]).toBe(hashValue("previous-value")); - expect(savedState.vaults["my-vault"]["valued-secret"]).toBe(hashValue("real-value")); + expect(savedState.vaults["my-vault"]["nullish-secret"]).toEqual({ + hash: hashValue("previous-value"), + }); + expect(savedState.vaults["my-vault"]["valued-secret"]).toEqual({ + hash: hashValue("real-value"), + }); }); }); diff --git a/packages/sdk/src/cli/commands/deploy/secret-manager.ts b/packages/sdk/src/cli/commands/deploy/secret-manager.ts index 9ffe9603e0..e49d2d9ba1 100644 --- a/packages/sdk/src/cli/commands/deploy/secret-manager.ts +++ b/packages/sdk/src/cli/commands/deploy/secret-manager.ts @@ -11,6 +11,7 @@ import { hashValue, loadSecretsState, saveSecretsState, + serializeUpdateTime, withSecretsStateLock, } from "./secrets-state"; import type { ApplyPhase, PlanContext } from "#/cli/commands/deploy/types"; @@ -187,7 +188,7 @@ export async function planSecretManager(context: PlanContext) { } // Fetch existing secrets in this vault - let existingSecrets: string[] = []; + const existingSecretTimes = new Map(); if (existing) { const secrets = await fetchAllTolerant(async (pageToken, maxPageSize) => { const { secrets, nextPageToken } = await client.listSecretManagerSecrets({ @@ -198,10 +199,12 @@ export async function planSecretManager(context: PlanContext) { }); return [secrets, nextPageToken]; }); - existingSecrets = secrets.map((s) => s.name); + for (const secret of secrets) { + existingSecretTimes.set(secret.name, serializeUpdateTime(secret.updateTime)); + } } - const existingSet = new Set(existingSecrets); + const existingSet = new Set(existingSecretTimes.keys()); // Diff secrets for (const secret of vault.secrets) { @@ -213,9 +216,16 @@ export async function planSecretManager(context: PlanContext) { } if (existingSet.has(secret.name)) { - const currentHash = hashValue(secret.value); - const storedHash = state.vaults[vaultName]?.[secret.name]; - if (forceApplyAll || currentHash !== storedHash) { + const stored = state.vaults[vaultName]?.[secret.name]; + const remoteUpdateTime = existingSecretTimes.get(secret.name); + // Skip only when the stored hash matches and the remote updateTime + // proves no other writer changed the secret since that hash was saved. + const unchanged = + stored !== undefined && + stored.hash === hashValue(secret.value) && + stored.updateTime !== undefined && + stored.updateTime === remoteUpdateTime; + if (forceApplyAll || !unchanged) { secretChangeSet.updates.push({ name: `${vaultName}/${secret.name}`, secretName: secret.name, @@ -345,18 +355,24 @@ export async function applySecretManager( const secretHashUpdates = [...secretChangeSet.creates, ...secretChangeSet.updates]; if (secretHashUpdates.length > 0) { await withSecretsStateLock(stateScope, async () => { + // Evidence must come from this deploy's own mutation responses; pairing + // the hash with a re-listed timestamp could adopt another writer's. + const appliedUpdateTimes = new Map(); + // Create new secrets await Promise.all( - secretChangeSet.creates.map((create) => - client.createSecretManagerSecret(secretCreateRequest(create)), - ), + secretChangeSet.creates.map(async (create) => { + const response = await client.createSecretManagerSecret(secretCreateRequest(create)); + appliedUpdateTimes.set(create.name, serializeUpdateTime(response.secret?.updateTime)); + }), ); // Update existing secrets await Promise.all( - secretChangeSet.updates.map((update) => - client.updateSecretManagerSecret(secretUpdateRequest(update)), - ), + secretChangeSet.updates.map(async (update) => { + const response = await client.updateSecretManagerSecret(secretUpdateRequest(update)); + appliedUpdateTimes.set(update.name, serializeUpdateTime(response.secret?.updateTime)); + }), ); if (application) { @@ -365,9 +381,13 @@ export async function applySecretManager( if (!Object.hasOwn(state.vaults, secret.vaultName)) { state.vaults[secret.vaultName] = {}; } + const updateTime = appliedUpdateTimes.get(secret.name); assertDefined(state.vaults[secret.vaultName], "vault state entry missing")[ secret.secretName - ] = hashValue(secret.value); + ] = { + hash: hashValue(secret.value), + ...(updateTime === undefined ? {} : { updateTime }), + }; } saveSecretsState(stateScope, state); } diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state-concurrency.test.ts b/packages/sdk/src/cli/commands/deploy/secrets-state-concurrency.test.ts index ddb8156d97..2b8ce1ad43 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state-concurrency.test.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state-concurrency.test.ts @@ -127,10 +127,10 @@ describe("concurrent deploys to the same target", () => { await Promise.all([applyA, applyB]); const state = loadSecretsState(stateScope); - expect(state.vaults["my-vault"]?.["shared-secret"]).toBe( + expect(state.vaults["my-vault"]?.["shared-secret"]?.hash).toBe( hashValue(remote.get("shared-secret")!), ); - expect(state.vaults["my-vault"]?.["trailing-secret"]).toBe(hashValue("trailing-value")); + expect(state.vaults["my-vault"]?.["trailing-secret"]?.hash).toBe(hashValue("trailing-value")); }); test("auth connection hash state matches the last remote write", async () => { diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts b/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts index b1bce56260..4067cde4c1 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state.test.ts @@ -50,8 +50,8 @@ describe("secrets-state", () => { const state = { vaults: { "my-vault": { - "secret-a": "abc123", - "secret-b": "def456", + "secret-a": { hash: "abc123", updateTime: "100.5" }, + "secret-b": { hash: "def456" }, }, }, }; @@ -62,7 +62,7 @@ describe("secrets-state", () => { test("state from another workspace is a cache miss", () => { saveSecretsState(scopeA, { - vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + vaults: { "shared-vault": { "shared-secret": { hash: "matching-hash" } } }, connections: { "shared-connection": "matching-hash" }, }); @@ -76,7 +76,7 @@ describe("secrets-state", () => { test("state from another application is a cache miss", () => { saveSecretsState(scopeA, { - vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + vaults: { "shared-vault": { "shared-secret": { hash: "matching-hash" } } }, connections: { "shared-connection": "matching-hash" }, }); @@ -90,7 +90,7 @@ describe("secrets-state", () => { test("a renamed application keeps state when its stable id matches", () => { saveSecretsState(scopeA, { - vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + vaults: { "shared-vault": { "shared-secret": { hash: "matching-hash" } } }, }); const loaded = loadSecretsState({ @@ -98,7 +98,7 @@ describe("secrets-state", () => { applicationName: "renamed-application", }); - expect(loaded.vaults["shared-vault"]?.["shared-secret"]).toBe("matching-hash"); + expect(loaded.vaults["shared-vault"]?.["shared-secret"]?.hash).toBe("matching-hash"); }); test("state without a stable application id is always a cache miss", () => { @@ -108,7 +108,7 @@ describe("secrets-state", () => { }; saveSecretsState(scopeWithoutId, { - vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + vaults: { "shared-vault": { "shared-secret": { hash: "matching-hash" } } }, }); expect(loadSecretsState(scopeWithoutId)).toEqual({ vaults: {} }); @@ -116,11 +116,11 @@ describe("secrets-state", () => { }); test("saving one scope preserves another scope", () => { - saveSecretsState(scopeA, { vaults: { "vault-a": { secret: "hash-a" } } }); - saveSecretsState(scopeB, { vaults: { "vault-b": { secret: "hash-b" } } }); + saveSecretsState(scopeA, { vaults: { "vault-a": { secret: { hash: "hash-a" } } } }); + saveSecretsState(scopeB, { vaults: { "vault-b": { secret: { hash: "hash-b" } } } }); - expect(loadSecretsState(scopeA).vaults["vault-a"]?.secret).toBe("hash-a"); - expect(loadSecretsState(scopeB).vaults["vault-b"]?.secret).toBe("hash-b"); + expect(loadSecretsState(scopeA).vaults["vault-a"]?.secret?.hash).toBe("hash-a"); + expect(loadSecretsState(scopeB).vaults["vault-b"]?.secret?.hash).toBe("hash-b"); }); test("stores different scopes in different files", () => { @@ -128,12 +128,12 @@ describe("secrets-state", () => { }); test("a malformed scope file does not invalidate another scope", () => { - saveSecretsState(scopeB, { vaults: { "vault-b": { secret: "hash-b" } } }); + saveSecretsState(scopeB, { vaults: { "vault-b": { secret: { hash: "hash-b" } } } }); const statePath = getSecretsStatePath(scopeA); mkdirSync(path.dirname(statePath), { recursive: true }); writeFileSync(statePath, "{broken json,,", "utf-8"); - expect(loadSecretsState(scopeB).vaults["vault-b"]?.secret).toBe("hash-b"); + expect(loadSecretsState(scopeB).vaults["vault-b"]?.secret?.hash).toBe("hash-b"); }); test("legacy unscoped state is a cache miss", () => { @@ -151,13 +151,33 @@ describe("secrets-state", () => { expect(loadSecretsState(scopeA)).toEqual({ vaults: {} }); }); + test("version 1 hash-only state is a cache miss", () => { + const statePath = getSecretsStatePath(scopeA); + mkdirSync(path.dirname(statePath), { recursive: true }); + writeFileSync( + statePath, + JSON.stringify({ + version: 1, + workspaceId: scopeA.workspaceId, + applicationKey: `id:${scopeA.applicationId}`, + state: { + vaults: { "shared-vault": { "shared-secret": "matching-hash" } }, + connections: { "shared-connection": "matching-hash" }, + }, + }), + "utf-8", + ); + + expect(loadSecretsState(scopeA)).toEqual({ vaults: {} }); + }); + test("state with an unknown version is a cache miss", () => { const statePath = getSecretsStatePath(scopeA); mkdirSync(path.dirname(statePath), { recursive: true }); writeFileSync( statePath, JSON.stringify({ - version: 2, + version: 3, workspaces: { "workspace-a": { applications: { diff --git a/packages/sdk/src/cli/commands/deploy/secrets-state.ts b/packages/sdk/src/cli/commands/deploy/secrets-state.ts index 3f7826017f..f599b94473 100644 --- a/packages/sdk/src/cli/commands/deploy/secrets-state.ts +++ b/packages/sdk/src/cli/commands/deploy/secrets-state.ts @@ -12,14 +12,20 @@ import pLimit, { type LimitFunction } from "p-limit"; import * as path from "pathe"; import { z } from "zod"; import { getDistDir } from "#/cli/shared/dist-dir"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; + +const SecretsStateEntrySchema = z.object({ + hash: z.string(), + updateTime: z.string().optional(), +}); const SecretsStateSchema = z.object({ - vaults: z.record(z.string(), z.record(z.string(), z.string())), + vaults: z.record(z.string(), z.record(z.string(), SecretsStateEntrySchema)), connections: z.record(z.string(), z.string()).optional(), }); const PersistedSecretsStateSchema = z.object({ - version: z.literal(1), + version: z.literal(2), workspaceId: z.string(), applicationKey: z.string(), state: SecretsStateSchema, @@ -97,7 +103,7 @@ export function saveSecretsState(scope: SecretsStateScope, state: SecretsState): tempPath, JSON.stringify( { - version: 1, + version: 2, workspaceId: scope.workspaceId, applicationKey: applicationStateKey(scope), state, @@ -119,6 +125,15 @@ export function hashValue(value: string): string { return createHash("sha256").update(value).digest("hex"); } +/** + * Serialize a platform timestamp into the string stored as update evidence. + * @param updateTime - Timestamp from a Secret Manager list or mutation response + * @returns Serialized timestamp, or undefined when the platform sent none + */ +export function serializeUpdateTime(updateTime: Timestamp | undefined): string | undefined { + return updateTime === undefined ? undefined : `${updateTime.seconds}.${updateTime.nanos}`; +} + const LOCK_POLL_INTERVAL_MS = 100; const LOCK_ACQUIRE_TIMEOUT_MS = 5 * 60 * 1000; // A holder refreshes the lock directory mtime while working, so a lock whose diff --git a/packages/sdk/src/cli/shared/client.ts b/packages/sdk/src/cli/shared/client.ts index d0e827a83b..951f92247c 100644 --- a/packages/sdk/src/cli/shared/client.ts +++ b/packages/sdk/src/cli/shared/client.ts @@ -294,8 +294,10 @@ function connectCodeName(error: unknown): string { * * Membership is deliberately an allowlist, not `startsWith("Create")`: swallowing * synthesizes an empty response (see `synthesizeEmptyUnaryResponse`), which is only - * safe when every caller ignores the response body. These are the deploy/apply - * resource creations that fire under heavy parallelism and discard their response. + * safe when every caller tolerates an empty response body. These are the deploy/apply + * resource creations that fire under heavy parallelism and discard their response — + * except `CreateSecretManagerSecret`, whose caller reads `secret.updateTime` but + * degrades safely when it is absent (the next deploy re-updates the secret). * * Intentionally excluded because their callers read the response body — swallowing * would hand back an empty message and corrupt downstream state: From d8700fddaa2ca6511af4a57207a68586c3b52269 Mon Sep 17 00:00:00 2001 From: dqn Date: Fri, 17 Jul 2026 21:53:34 +0900 Subject: [PATCH 2/3] chore: add changeset for secret update-time evidence --- .changeset/secret-state-update-time-evidence.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/secret-state-update-time-evidence.md diff --git a/.changeset/secret-state-update-time-evidence.md b/.changeset/secret-state-update-time-evidence.md new file mode 100644 index 0000000000..4c5c7fa70c --- /dev/null +++ b/.changeset/secret-state-update-time-evidence.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/sdk": patch +--- + +Fix deploy silently skipping a secret update after the remote value changed outside the current checkout (a deploy from another machine, or a console-side edit). The local hash state now also records the platform's update timestamp for each secret and distrusts the stored hash when the remote timestamp no longer matches. After upgrading, the first deploy re-pushes managed secrets once to seed the new state format. From 26fa05aab75dcf5ba17f79293245a32103195104 Mon Sep 17 00:00:00 2001 From: dqn Date: Mon, 20 Jul 2026 09:48:49 +0900 Subject: [PATCH 3/3] docs: reword secret evidence changeset in user-facing terms --- .changeset/secret-state-update-time-evidence.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/secret-state-update-time-evidence.md b/.changeset/secret-state-update-time-evidence.md index 4c5c7fa70c..522d4250b3 100644 --- a/.changeset/secret-state-update-time-evidence.md +++ b/.changeset/secret-state-update-time-evidence.md @@ -2,4 +2,4 @@ "@tailor-platform/sdk": patch --- -Fix deploy silently skipping a secret update after the remote value changed outside the current checkout (a deploy from another machine, or a console-side edit). The local hash state now also records the platform's update timestamp for each secret and distrusts the stored hash when the remote timestamp no longer matches. After upgrading, the first deploy re-pushes managed secrets once to seed the new state format. +Fix deploy silently skipping a secret update after the remote value changed outside the current project directory (a deploy from another machine, or a console-side edit). Deploy now verifies each secret's last platform update time before skipping and re-updates the secret when it no longer matches. After upgrading, the first deploy re-pushes managed secrets once.