lookml
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.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


