From 47636c2533812433201b690bbd6bda1c7a60c011 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 31 Jul 2026 16:03:36 +0200 Subject: [PATCH 1/7] direct: detect stale dashboard publishes via publish/update timestamps DoRead computed a dashboard's published state as "does GetPublished succeed", but once a dashboard is published GetPublished keeps returning the previous revision even after the draft is updated, so that check cannot tell whether the published content is current. Compare the published revision_create_time against the draft update_time instead: published is true only when GetPublished succeeds AND revision_create_time >= update_time. When the draft was updated without a republish, published becomes false, which differs from the desired true and the planner schedules a republish. Verified on AWS, GCP, Azure and dogfood: a publish always leaves revision_create_time >= update_time, and a draft update pushes update_time ahead until the next publish (so there is no perpetual-republish risk). The draft etag, by contrast, does not reliably change on a display-name update on cloud, so it cannot be used for this. The testserver now emits strictly-increasing RFC3339Nano timestamps (nextTimestamp) so update/publish ordering is deterministic. published is also excluded from config-remote-sync as an internal state field. Co-authored-by: Isaac --- .../detect-change/out.plan.direct.json | 8 +++- .../publish-failure-stale-content/output.txt | 6 ++- bundle/configsync/defaults.go | 3 ++ bundle/direct/dresources/dashboard.go | 44 ++++++++++++++++++- libs/testserver/dashboards.go | 7 ++- libs/testserver/fake_workspace.go | 8 ++++ 6 files changed, 68 insertions(+), 8 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/detect-change/out.plan.direct.json b/acceptance/bundle/resources/dashboards/detect-change/out.plan.direct.json index b00e44d4fe8..c177eb0b450 100644 --- a/acceptance/bundle/resources/dashboards/detect-change/out.plan.direct.json +++ b/acceptance/bundle/resources/dashboards/detect-change/out.plan.direct.json @@ -25,7 +25,7 @@ "lifecycle_state": "ACTIVE", "parent_path": "/Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/resources", "path": "/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/resources/test-dashboard-[UNIQUE_NAME].lvdash.json", - "published": true, + "published": false, "serialized_dashboard": "{}\n", "update_time": "[TIMESTAMP]", "warehouse_id": "[TEST_DEFAULT_WAREHOUSE_ID]" @@ -56,6 +56,12 @@ "reason": "spec:output_only", "remote": "/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/resources/test-dashboard-[UNIQUE_NAME].lvdash.json" }, + "published": { + "action": "update", + "old": true, + "new": true, + "remote": false + }, "serialized_dashboard": { "action": "skip", "reason": "etag_based", diff --git a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt index 8b0f79427d2..a88b5057bc6 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt +++ b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/output.txt @@ -84,10 +84,14 @@ The remote modifications will be lost. json.plan.resources.dashboards.dashboard1.new_state.value.published = true; json.plan.resources.dashboards.dashboard1.remote_state.etag = "[ETAG_2]"; -json.plan.resources.dashboards.dashboard1.remote_state.published = true; +json.plan.resources.dashboards.dashboard1.remote_state.published = false; json.plan.resources.dashboards.dashboard1.changes.etag.action = "update"; json.plan.resources.dashboards.dashboard1.changes.etag.old = "[ETAG_1]"; json.plan.resources.dashboards.dashboard1.changes.etag.remote = "[ETAG_2]"; +json.plan.resources.dashboards.dashboard1.changes.published.action = "update"; +json.plan.resources.dashboards.dashboard1.changes.published.old = true; +json.plan.resources.dashboards.dashboard1.changes.published.new = true; +json.plan.resources.dashboards.dashboard1.changes.published.remote = false; json.plan.resources.dashboards.dashboard1.changes.serialized_dashboard.reason = "etag_based"; >>> [CLI] bundle deploy diff --git a/bundle/configsync/defaults.go b/bundle/configsync/defaults.go index 4dedadedfc9..4dff5c143db 100644 --- a/bundle/configsync/defaults.go +++ b/bundle/configsync/defaults.go @@ -120,6 +120,9 @@ var serverSideDefaults = map[string]any{ // modified-remotely detection), so configsync cannot rely on the plan's Skip // action and must exclude the field explicitly. "resources.dashboards.*.etag": alwaysSkip, + // published is an internal state field (not part of the user config): DoRead + // derives it from the publish lifecycle, so it must never be synced into config. + "resources.dashboards.*.published": alwaysSkip, } // shouldSkipField checks if a field should be skipped in change detection. diff --git a/bundle/direct/dresources/dashboard.go b/bundle/direct/dresources/dashboard.go index bb3b3921a43..aaf1feea616 100644 --- a/bundle/direct/dresources/dashboard.go +++ b/bundle/direct/dresources/dashboard.go @@ -7,6 +7,7 @@ import ( "path" "slices" "strings" + "time" "github.com/databricks/cli/bundle/config/resources" "github.com/databricks/cli/bundle/deployplan" @@ -27,6 +28,16 @@ type ResourceDashboard struct { type DashboardState struct { resources.DashboardConfig + // Published reports whether the current draft content is published. DoRead sets it + // to true only when GetPublished succeeds AND the published revision is at least as + // new as the draft (revision_create_time >= update_time). A draft update without a + // republish leaves the previously-published content in place, but the API keeps + // returning it from GetPublished, so a naive "does GetPublished succeed" cannot + // detect stale content; the timestamp comparison can. + // + // The desired value (PrepareState) is always true, so when the published content is + // stale, remote Published=false differs from desired and the planner runs DoUpdate, + // which republishes. Published bool `json:"published"` } @@ -141,8 +152,14 @@ func (r *ResourceDashboard) DoRead(ctx context.Context, id string) (*DashboardSt return nil, apierr.ErrNotFound } - // Determine if the dashboard is published - published := publishedErr == nil + // A dashboard is published (in the sense that matters for planning: the current + // draft content is what's published) only when GetPublished succeeds AND the + // published revision is at least as new as the draft. A draft update without a + // republish leaves stale content published, but GetPublished keeps returning it, + // so publishedErr == nil alone cannot detect staleness — the timestamp comparison + // can. If either timestamp is unparseable, fall back to publish-existence so we + // don't spuriously republish. See DashboardState.Published. + published := publishedErr == nil && publishedIsCurrent(dashboard, publishedDashboard) forceSendFields := utils.FilterFields[DashboardState](dashboard.ForceSendFields) // EmbedCredentials must always be included in ForceSendFields to ensure it's serialized @@ -181,6 +198,29 @@ func (r *ResourceDashboard) DoRead(ctx context.Context, id string) (*DashboardSt }, nil } +// publishedIsCurrent reports whether the published revision reflects the current +// draft, i.e. the dashboard was published after its last draft update. The published +// API exposes no etag, only revision_create_time, so we compare it against the draft's +// update_time. Both are RFC3339 timestamps set by the backend on a shared clock +// (verified on AWS/GCP/Azure/dogfood: a publish always leaves revision_create_time >= +// update_time, and a draft update pushes update_time ahead until the next publish). +// If either timestamp is missing or unparseable, return true so we do not force a +// spurious republish. +func publishedIsCurrent(dashboard *dashboards.Dashboard, published *dashboards.PublishedDashboard) bool { + if published == nil { + return false + } + updateTime, err := time.Parse(time.RFC3339, dashboard.UpdateTime) + if err != nil { + return true + } + revisionTime, err := time.Parse(time.RFC3339, published.RevisionCreateTime) + if err != nil { + return true + } + return !revisionTime.Before(updateTime) +} + func prepareDashboardRequest(config *DashboardState) (dashboards.Dashboard, error) { dashboard := dashboards.Dashboard{ DisplayName: config.DisplayName, diff --git a/libs/testserver/dashboards.go b/libs/testserver/dashboards.go index aa7f102849d..84b8527212a 100644 --- a/libs/testserver/dashboards.go +++ b/libs/testserver/dashboards.go @@ -8,7 +8,6 @@ import ( "path" "strconv" "strings" - "time" "github.com/databricks/databricks-sdk-go/service/dashboards" "github.com/databricks/databricks-sdk-go/service/workspace" @@ -142,7 +141,7 @@ func (s *FakeWorkspace) DashboardCreate(req Request) Response { dashboard.Path = dashboard.ParentPath + "/" + dashboard.DisplayName + ".lvdash.json" } - dashboard.CreateTime = strings.TrimSuffix(time.Now().UTC().Format(time.RFC3339), "Z") + dashboard.CreateTime = nextTimestamp() dashboard.UpdateTime = dashboard.CreateTime inputSerializedDashboard := dashboard.SerializedDashboard @@ -230,7 +229,7 @@ func (s *FakeWorkspace) DashboardUpdate(req Request) Response { updated.SerializedDashboard = transformSerializedDashboard(updateReq.SerializedDashboard, datasetCatalog, datasetSchema) } updated.WarehouseId = updateReq.WarehouseId - updated.UpdateTime = time.Now().UTC().Format(time.RFC3339) + updated.UpdateTime = nextTimestamp() // Write stages a stale value: like a real backend, the first read after an update // returns the pre-update value. Tests that read right after an update wait for the @@ -264,7 +263,7 @@ func (s *FakeWorkspace) DashboardPublish(req Request) Response { WarehouseId: dashboard.WarehouseId, DisplayName: dashboard.DisplayName, EmbedCredentials: publishReq.EmbedCredentials, - RevisionCreateTime: time.Now().UTC().Format(time.RFC3339), + RevisionCreateTime: nextTimestamp(), ForceSendFields: []string{"EmbedCredentials"}, } diff --git a/libs/testserver/fake_workspace.go b/libs/testserver/fake_workspace.go index 8d6e8ee0dd3..404742132af 100644 --- a/libs/testserver/fake_workspace.go +++ b/libs/testserver/fake_workspace.go @@ -128,6 +128,14 @@ func nowMilli() int64 { return lastNowMilli } +// nextTimestamp returns a strictly-increasing RFC3339 timestamp with nanosecond +// precision. The sub-second component keeps distinct events ordered even within the +// same wall-clock second, which the dashboard publish lifecycle relies on to compare +// a draft's update_time against a published revision's revision_create_time. +func nextTimestamp() string { + return time.Unix(0, nowNano()).UTC().Format(time.RFC3339Nano) +} + func nextUUID() string { var b [16]byte binary.BigEndian.PutUint64(b[0:8], uint64(nextID())) From 4334fe925693c534a064ec63d2cb818711825998 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Fri, 31 Jul 2026 16:41:34 +0200 Subject: [PATCH 2/7] acceptance: regression test for stale-publish detection after a draft update Deploys a dashboard, makes an out-of-band draft update without republishing, and asserts `bundle plan` reports the dashboard's published state needs an update (published: {old: true, remote: false}). Before the timestamp-based fix, DoRead reported published=true (GetPublished still returns the previous revision), so the stale publish was missed and the plan showed no change. Direct engine only: published is a direct-engine state field. Co-authored-by: Isaac --- .../dashboard.lvdash.json | 34 +++++++++++++++ .../databricks.yml.tmpl | 12 ++++++ .../out.test.toml | 5 +++ .../republish-after-draft-update/output.txt | 41 +++++++++++++++++++ .../republish-after-draft-update/script | 28 +++++++++++++ .../republish-after-draft-update/test.toml | 27 ++++++++++++ 6 files changed, 147 insertions(+) create mode 100644 acceptance/bundle/resources/dashboards/republish-after-draft-update/dashboard.lvdash.json create mode 100644 acceptance/bundle/resources/dashboards/republish-after-draft-update/databricks.yml.tmpl create mode 100644 acceptance/bundle/resources/dashboards/republish-after-draft-update/out.test.toml create mode 100644 acceptance/bundle/resources/dashboards/republish-after-draft-update/output.txt create mode 100644 acceptance/bundle/resources/dashboards/republish-after-draft-update/script create mode 100644 acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/dashboard.lvdash.json b/acceptance/bundle/resources/dashboards/republish-after-draft-update/dashboard.lvdash.json new file mode 100644 index 00000000000..397a9a1259c --- /dev/null +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/dashboard.lvdash.json @@ -0,0 +1,34 @@ +{ + "pages": [ + { + "displayName": "New Page", + "layout": [ + { + "position": { + "height": 2, + "width": 6, + "x": 0, + "y": 0 + }, + "widget": { + "name": "82eb9107", + "textbox_spec": "# I'm a title" + } + }, + { + "position": { + "height": 2, + "width": 6, + "x": 0, + "y": 2 + }, + "widget": { + "name": "ffa6de4f", + "textbox_spec": "Text" + } + } + ], + "name": "fdd21a3c" + } + ] +} diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/databricks.yml.tmpl b/acceptance/bundle/resources/dashboards/republish-after-draft-update/databricks.yml.tmpl new file mode 100644 index 00000000000..0fae00d3fcc --- /dev/null +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/databricks.yml.tmpl @@ -0,0 +1,12 @@ +bundle: + name: republish-after-draft-update + +workspace: + root_path: ~/.bundle/$UNIQUE_NAME + +resources: + dashboards: + dashboard1: + display_name: test-dashboard-$UNIQUE_NAME + file_path: ./dashboard.lvdash.json + warehouse_id: $TEST_DEFAULT_WAREHOUSE_ID diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/out.test.toml b/acceptance/bundle/resources/dashboards/republish-after-draft-update/out.test.toml new file mode 100644 index 00000000000..fd5c0ccb0ad --- /dev/null +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/out.test.toml @@ -0,0 +1,5 @@ +Local = true +Cloud = false +RequiresWarehouse = true +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +EnvMatrix.Ignore = ["databricks.yml"] diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/output.txt b/acceptance/bundle/resources/dashboards/republish-after-draft-update/output.txt new file mode 100644 index 00000000000..3593cbf24ba --- /dev/null +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/output.txt @@ -0,0 +1,41 @@ + +>>> [CLI] bundle deploy +Uploading bundle files to /Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME]/files... +Deploying resources... +Updating deployment state... +Deployment complete! + +=== Plan right after deploy -- published is current, no change: +>>> [CLI] bundle plan -o json +null + +=== Plan after out-of-band draft update -- a republish is owed: +>>> [CLI] bundle plan -o json +Warning: dashboard "dashboard1" has been modified remotely + at resources.dashboards.dashboard1 + in databricks.yml:10:7 + +This dashboard has been modified remotely since the last bundle deployment. +These modifications are untracked and will be overwritten on deploy. + +Make sure that the local dashboard definition matches what you intend to deploy +before proceeding with the deployment. + +To overwrite the remote changes with your local version, use --force. +The remote modifications will be lost. + +{ + "action": "update", + "old": true, + "new": true, + "remote": false +} + +>>> [CLI] bundle destroy --auto-approve +The following resources will be deleted: + delete resources.dashboards.dashboard1 + +All files and directories at the following location will be deleted: /Workspace/Users/[USERNAME]/.bundle/[UNIQUE_NAME] + +Deleting files... +Destroy complete! diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/script b/acceptance/bundle/resources/dashboards/republish-after-draft-update/script new file mode 100644 index 00000000000..7e131a5de78 --- /dev/null +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/script @@ -0,0 +1,28 @@ +envsubst < databricks.yml.tmpl > databricks.yml + +cleanup() { + trace $CLI bundle destroy --auto-approve +} +trap cleanup EXIT + +# Deploy: the dashboard is created and published, so the published revision matches the +# draft (revision_create_time >= update_time). +trace $CLI bundle deploy +DASHBOARD_ID=$($CLI bundle summary --output json | jq -r '.resources.dashboards.dashboard1.id') +echo "$DASHBOARD_ID:DASHBOARD_ID" >> ACC_REPLS + +# A clean re-plan is a no-op: the published content matches the draft. +title "Plan right after deploy -- published is current, no change:" +trace $CLI bundle plan -o json | jq '.plan["resources.dashboards.dashboard1"].changes.published' + +# Make an out-of-band DRAFT update without republishing. This advances the draft's +# update_time past the published revision_create_time, so the published content is now +# stale. GetPublished still returns 200 (the previous revision), so the old +# "published = (GetPublished succeeds)" check reported published=true and MISSED this -- +# the regression this test guards. The timestamp comparison reports published=false, so +# the plan surfaces a pending republish (published: update, remote=false). +DASHBOARD_JSON="{\"serialized_dashboard\": \"{}\", \"warehouse_id\": \"$TEST_DEFAULT_WAREHOUSE_ID\"}" +$CLI lakeview update "$DASHBOARD_ID" --json "${DASHBOARD_JSON}" > /dev/null + +title "Plan after out-of-band draft update -- a republish is owed:" +trace $CLI bundle plan -o json | jq '.plan["resources.dashboards.dashboard1"].changes.published' diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml b/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml new file mode 100644 index 00000000000..a40dd40986a --- /dev/null +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml @@ -0,0 +1,27 @@ +# Regression test for stale-publish detection. The dashboard "published" state is a +# direct-engine concept derived from the publish/update timestamps, so run direct only. +Cloud = false +Local = true +RequiresWarehouse = true +# This test asserts plan output, not the recorded request sequence, so keep request +# recording off (inherited as true from the parent) to avoid flaky ordering. +RecordRequests = false + +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Ignore = [ + "databricks.yml", +] + +[Env] +# This test makes an out-of-band draft update and expects `bundle plan` to detect that a +# republish is owed. The inherited eventual-consistency simulation would make the plan's +# own read observe stale values and mask the change, so opt out of it here. +INJECT_STALE_ON_DIRECT = "0" +MSYS_NO_PATHCONV = "1" + +[[Repls]] +Old = '2\d\d\d-\d\d-\d\d(T| )\d\d:\d\d:\d\d(\.\d+(Z|\+\d\d:\d\d)?)?Z' +New = "[TIMESTAMP]" +Order = 9 From da4fa34f7eec1eabccfa6485605306dd5a235f90 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 3 Aug 2026 11:43:22 +0200 Subject: [PATCH 3/7] acceptance: use read_id.py to capture dashboard id Replace the inline `bundle summary | jq` + manual ACC_REPLS append with read_id.py, which reads the id from state, prints it, and registers the [DASHBOARD1_ID] repl in one step. Goldens are unchanged. Co-authored-by: Isaac --- .../resources/dashboards/publish-failure-stale-content/script | 3 +-- .../resources/dashboards/republish-after-draft-update/script | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script index f192ec5e7b4..2fb37909fa7 100644 --- a/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script +++ b/acceptance/bundle/resources/dashboards/publish-failure-stale-content/script @@ -10,8 +10,7 @@ unset MSYS_NO_PATHCONV # First deploy: dashboard is created and published successfully. trace $CLI bundle deploy -replace_ids.py -DASHBOARD_ID=$($CLI bundle summary --output json | jq -r '.resources.dashboards.dashboard1.id') +DASHBOARD_ID=$(read_id.py dashboard1) DASHBOARD=$(retry $CLI lakeview get $DASHBOARD_ID) ETAG_1=$(echo "$DASHBOARD" | jq -r '.etag') add_repl.py "$ETAG_1" ETAG_1 diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/script b/acceptance/bundle/resources/dashboards/republish-after-draft-update/script index 7e131a5de78..b022e61ed81 100644 --- a/acceptance/bundle/resources/dashboards/republish-after-draft-update/script +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/script @@ -8,8 +8,7 @@ trap cleanup EXIT # Deploy: the dashboard is created and published, so the published revision matches the # draft (revision_create_time >= update_time). trace $CLI bundle deploy -DASHBOARD_ID=$($CLI bundle summary --output json | jq -r '.resources.dashboards.dashboard1.id') -echo "$DASHBOARD_ID:DASHBOARD_ID" >> ACC_REPLS +DASHBOARD_ID=$(read_id.py dashboard1) # A clean re-plan is a no-op: the published content matches the draft. title "Plan right after deploy -- published is current, no change:" From 1c1ff686453948bd1dbd68a8338cc61d6b0fa4e1 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 3 Aug 2026 12:32:26 +0200 Subject: [PATCH 4/7] acceptance: revert read_id.py in republish test (Windows path breakage) read_id.py cannot be used in republish-after-draft-update because the test runs with MSYS_NO_PATHCONV=1, which mangles the helper's own script path on Windows Git Bash. Restore the inline id capture and note why. Co-authored-by: Isaac --- .../resources/dashboards/republish-after-draft-update/script | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/script b/acceptance/bundle/resources/dashboards/republish-after-draft-update/script index b022e61ed81..dccf0bae02a 100644 --- a/acceptance/bundle/resources/dashboards/republish-after-draft-update/script +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/script @@ -8,7 +8,10 @@ trap cleanup EXIT # Deploy: the dashboard is created and published, so the published revision matches the # draft (revision_create_time >= update_time). trace $CLI bundle deploy -DASHBOARD_ID=$(read_id.py dashboard1) +# Inline id capture rather than read_id.py: this test runs with MSYS_NO_PATHCONV=1, which +# mangles the helper's own script path on Windows Git Bash (C:\c\a\... not found). +DASHBOARD_ID=$($CLI bundle summary --output json | jq -r '.resources.dashboards.dashboard1.id') +echo "$DASHBOARD_ID:DASHBOARD_ID" >> ACC_REPLS # A clean re-plan is a no-op: the published content matches the draft. title "Plan right after deploy -- published is current, no change:" From e141e1cbddff63c415406727d2131ebfdab02f46 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 3 Aug 2026 12:38:29 +0200 Subject: [PATCH 5/7] acceptance: unset MSYS_NO_PATHCONV in republish test, use read_id.py The inherited MSYS_NO_PATHCONV=1 is only needed for scripts that pass leading-slash paths to the CLI; this one does not. Unset it (matching the sibling dashboard tests) so read_id.py resolves its own script path on Windows, and drop the now-redundant test.toml override. Co-authored-by: Isaac --- .../dashboards/republish-after-draft-update/script | 9 +++++---- .../dashboards/republish-after-draft-update/test.toml | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/script b/acceptance/bundle/resources/dashboards/republish-after-draft-update/script index dccf0bae02a..9cd9a12869f 100644 --- a/acceptance/bundle/resources/dashboards/republish-after-draft-update/script +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/script @@ -5,13 +5,14 @@ cleanup() { } trap cleanup EXIT +# This script passes no leading-slash paths to the CLI, so the inherited MSYS_NO_PATHCONV=1 +# is not needed; unset it so bin/*.py helpers resolve their own script path on Windows. +unset MSYS_NO_PATHCONV + # Deploy: the dashboard is created and published, so the published revision matches the # draft (revision_create_time >= update_time). trace $CLI bundle deploy -# Inline id capture rather than read_id.py: this test runs with MSYS_NO_PATHCONV=1, which -# mangles the helper's own script path on Windows Git Bash (C:\c\a\... not found). -DASHBOARD_ID=$($CLI bundle summary --output json | jq -r '.resources.dashboards.dashboard1.id') -echo "$DASHBOARD_ID:DASHBOARD_ID" >> ACC_REPLS +DASHBOARD_ID=$(read_id.py dashboard1) # A clean re-plan is a no-op: the published content matches the draft. title "Plan right after deploy -- published is current, no change:" diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml b/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml index a40dd40986a..eeda51e25b9 100644 --- a/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml @@ -19,7 +19,6 @@ Ignore = [ # republish is owed. The inherited eventual-consistency simulation would make the plan's # own read observe stale values and mask the change, so opt out of it here. INJECT_STALE_ON_DIRECT = "0" -MSYS_NO_PATHCONV = "1" [[Repls]] Old = '2\d\d\d-\d\d-\d\d(T| )\d\d:\d\d:\d\d(\.\d+(Z|\+\d\d:\d\d)?)?Z' From 2169c96bb5e62d957ad2e5372beb1674a9bcaba8 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 3 Aug 2026 12:51:20 +0200 Subject: [PATCH 6/7] acceptance: drop misparsed Ignore from republish test.toml The `Ignore` key sat under the `[EnvMatrix]` header, so TOML parsed it as `EnvMatrix.Ignore` (a bogus matrix variable) rather than the top-level `Ignore`. It was redundant anyway: the parent bundle/test.toml already ignores databricks.yml. Co-authored-by: Isaac --- .../dashboards/republish-after-draft-update/out.test.toml | 1 - .../dashboards/republish-after-draft-update/test.toml | 4 ---- 2 files changed, 5 deletions(-) diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/out.test.toml b/acceptance/bundle/resources/dashboards/republish-after-draft-update/out.test.toml index fd5c0ccb0ad..a29f11b9ab2 100644 --- a/acceptance/bundle/resources/dashboards/republish-after-draft-update/out.test.toml +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/out.test.toml @@ -2,4 +2,3 @@ Local = true Cloud = false RequiresWarehouse = true EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] -EnvMatrix.Ignore = ["databricks.yml"] diff --git a/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml b/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml index eeda51e25b9..3db92d45b49 100644 --- a/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml +++ b/acceptance/bundle/resources/dashboards/republish-after-draft-update/test.toml @@ -10,10 +10,6 @@ RecordRequests = false [EnvMatrix] DATABRICKS_BUNDLE_ENGINE = ["direct"] -Ignore = [ - "databricks.yml", -] - [Env] # This test makes an out-of-band draft update and expects `bundle plan` to detect that a # republish is owed. The inherited eventual-consistency simulation would make the plan's From 50ea47d2d915fbb1cdd6d242fbf2d0b1fce52bdb Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Mon, 3 Aug 2026 12:59:25 +0200 Subject: [PATCH 7/7] testing docs: prefer dotted EnvMatrix. over [EnvMatrix] header A top-level key placed below a [EnvMatrix] header is silently parsed as EnvMatrix. (a bogus matrix variable), leaving the real field unset. Document the dotted form as the safe idiom. Co-authored-by: Isaac --- .agent/rules/testing.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.agent/rules/testing.md b/.agent/rules/testing.md index 62b5d6ef126..698b69671ce 100644 --- a/.agent/rules/testing.md +++ b/.agent/rules/testing.md @@ -95,6 +95,24 @@ If the only reason for divergence is a server-side default that one engine sets **RULE: `EnvMatrix. = []` removes that variable from the inherited matrix** (see `ExpandEnvMatrix` in `acceptance/internal/config.go`). The root `test.toml` matrixes `DATABRICKS_BUNDLE_ENGINE = [terraform, direct]`, so a non-bundle test opts out of both engine runs with `EnvMatrix.DATABRICKS_BUNDLE_ENGINE = []`. The `out.test.toml` snapshot of inherited values is generated and committed by design. +**RULE: Write matrix variables in dotted form (`EnvMatrix. = [...]`) at the top of `test.toml`, not under a `[EnvMatrix]` header.** In TOML every key after a `[EnvMatrix]` header belongs to that table until the next header — a blank line does not end it. So a top-level key like `Ignore` placed below `[EnvMatrix]` is silently parsed as `EnvMatrix.Ignore` (a bogus matrix variable) instead of the real top-level field, and the test runs with the field unset. Dotted form keeps each key's table explicit and is immune to ordering: + +GOOD: + +```toml +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] +Ignore = ["databricks.yml"] +``` + +BAD: + +```toml +[EnvMatrix] +DATABRICKS_BUNDLE_ENGINE = ["direct"] + +Ignore = ["databricks.yml"] # parsed as EnvMatrix.Ignore, not top-level Ignore +``` + **RULE: If a test's `out.test.toml` is still in the older `[EnvMatrix]` block format, a regen rewrites it to the inline form and the post-test `git diff --exit-code` check fails** ("out.test.toml files that are out of date"). Regenerate just those files with `go test ./acceptance -run "^TestAccept$" -only-out-test-toml`, then commit. ### Reference