lookml

Promoting a dashboard is a string replacement

Promoting a dashboard is a string replacement

Business intelligence as code is a good idea that stops one step short. The semantic layer is text files in a repository — models, views, dashboards — so they diff, they review, they branch. Everything you want.

Then you try to have a staging environment.

The tool's deployment model is that a project points at a branch, and the names declared inside a project are global to the instance. One instance cannot hold two models called revenue. So if you want to see a change before customers do, and you only have one instance, the staging copy cannot be called what the production copy is called.

The convention that falls out of that is a suffix. In the staging project, every model file is <name>-staging.model.lkml and declares the model <name>-staging. In production, no suffix. Promotion is not a deploy. It is a rename, and it has to be applied transitively, because dashboards name the model they read from:

dashboard: revenue_totals
  model: revenue-staging      ← has to become `revenue`

So the pipeline step that promotes a release is, stripped of ceremony, a string replacement over a directory. That sounds like a shortcut until you write it, and then it turns out the interesting decisions are all in which strings.

Anchoring the regex on names that exist

The naive version matches anything ending in the suffix. The version that shipped derives the set of legal model names from the source tree first, and only then builds the pattern:

const modelFiles = await getRelativePaths(sourcePath, '**/*.model.lkml')

const modelNames = modelFiles
  .map((file) => file.split('/').pop()?.replace(fullSourceSuffix, ''))
  .filter((name) => name) as Array<string>

const replacementRegex = new RegExp(
  `model:\\s+(${modelNames.map(escapeRegex).join('|')})${sourceModelSuffix}`,
  'g',
)

The alternation is the point. A dashboard that references a model this project does not define is left untouched rather than rewritten into a name that does not exist. A generic -staging$ pattern would have rewritten it confidently and produced a project that validates as text and fails at query time, which is the worst place to find out.

It also means the promotion is self-limiting. Whatever the staging project contains defines exactly what can be promoted; anything else passes through as a literal. That is a much better property than cleverness, and it costs one glob.

escapeRegex is there because model names are file names, and file names are allowed to contain characters that mean something to a regex engine. It has never fired in anger. It should still be there.

The naming rule is the product

The part of this action I would defend hardest is the part that does nothing:

if (srcFile.endsWith(fullSourceSuffix)) {
  const newDestFile = destFile.replace(fullSourceSuffix, `${destinationSuffix}.model.lkml`)
  await io.cp(srcFile, newDestFile)
} else {
  core.setFailed(
    `Model files do not comply with the naming convention. Please make sure ` +
      `all model files are ending with ${sourceModelSuffix}.`,
  )
  process.exit(1)
}

A model file in the staging project that is not suffixed is not a mistake in naming. It is a production model living in the staging project, and every staging change to it is already live. Promotion would copy it onto itself and hide the fact.

That failure is silent in every other direction. Nothing in the BI tool objects; the file is valid, the model resolves, the dashboards work. The only place it can be caught is a build step that knows what the convention is supposed to be — so the build step exists mainly to enforce the convention, and the file copying is the incidental part.

Which is why the dry-run flag went in shortly afterwards. With it set, the action resolves inputs, walks the tree, applies the naming check, logs what it would copy, and writes nothing:

if (this.wfInputs.dryRun) {
  console.log(`[dry-run] Copying ${srcFile} to ${newDestFile}`)
} else {
  await io.cp(srcFile, newDestFile)
}

Now the same action runs on every pull request as validation and on merge as promotion. Reviewers find out that a model was added without the suffix while they are still reviewing, instead of at the moment somebody tries to release. One flag turned a deployment step into a linter, and the linter is what people actually notice.

Rebuild, do not merge

The destination is removed before anything is written:

await io.rmRF(this.wfInputs.destinationPath)

Three lines in, and it is the reason the action is safe to run repeatedly. A promotion that merges into whatever is already on disk will happily keep a file that was deleted upstream — and a stale view file in a BI project is not inert, it is a definition somebody's dashboard may still resolve against. Wiping first makes the output a pure function of the input, which is the property you want when the same step runs on a pull request and on main and has to mean the same thing both times.

Views are copied verbatim. They do not carry model references, so there is nothing in them to rewrite.

The suffix does not stay in CI

Here is the cost, and it is the part I would warn anyone about before they adopt this pattern: a naming convention introduced to work around a BI instance limitation leaks into the application that embeds the dashboards.

The application addresses a dashboard by a qualified identifier, model::page. That model segment is environment-specific now, so the front end has to know the suffix and apply it:

export const normalizePagedId = (pageId: string, modelSuffix?: string): string => {
  const modelSeparator = '::'

  if (modelSuffix && pageId.includes(modelSeparator)) {
    const [model, page] = pageId.split(modelSeparator)
    const modelWithSuffix = model.endsWith(modelSuffix) ? model : `${model}${modelSuffix}`

    return `${modelWithSuffix}${modelSeparator}${page}`
  }

  return pageId
}

The suffix is served to the browser as part of the embed session payload, next to the tokens, because the browser has no other way to know which environment it is looking at. Note the endsWith guard: identifiers arrive both already qualified and not, depending on whether they came from a link or from an API listing, and appending twice produces a model nobody has ever heard of.

So a CI naming convention became a runtime concern in a TypeScript front end, and a piece of environment configuration crossed a network boundary to get there. That is what sharing one BI instance across two environments actually costs. The licence saving is real and the accounting is honest only if you put this on the other side of it.

What I would do differently

Parse the LookML. There are parsers; we matched on model: because that is the only reference a dashboard file makes and a regex reads in ten seconds. It is still a text transformation over a structured language, and the day someone writes a dashboard with a model: inside a string literal, the regex is wrong and nothing says so.

Or — better, and more expensive — two instances, and delete all of this. The suffix exists only because the namespace is shared. Every complication above, including the one that ends up in the browser, is downstream of that single constraint. Work around a constraint long enough and the workaround stops being visible as a cost; it just becomes how the thing is built.

The bundled action itself is unremarkable and that is fine: TypeScript, ncc into a single dist/index.js, node20 in the action manifest, a testData directory with a handful of model, view and dashboard files that exercise the naming gate. The whole thing is about a hundred and twenty lines. The hundred and twenty lines are not the interesting part. The convention they enforce is.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev