Looker has changed how organizations model and interact with data. However, managing the release lifecycle of Looker projects across multiple instances has traditionally been a challenge. How do you deploy LookML code, keep shared folders in sync, run automated syntax and data validations, and ensure you can roll back instantly if a release breaks production?
By structuring a complete GitOps pipeline using GitHub Actions, we can bridge this gap and establish a secure, automated release process. You can find the fully functional blueprint in the looker_cicd_sample repository.
Here is how to set up an end-to-end Looker GitOps pipeline that handles PR validation, path-based review routing, automated backups, and gated production releases.
The Looker GitOps Lifecycle
A robust Looker pipeline spans two separate instances (Dev and Production) and relies on automated gates to ensure code quality:
[ Developer Branch ]
│
▼
[ Open Pull Request ] ──► [ 📝 PR Checklist Template ]
│ ──► [ 🧪 Looker CI ]
│ ──► [ 📐 LAMS Style Linter ]
▼ ──► [ 👥 Route to CODEOWNERS ]
[ Merge to master ]
│
▼ (Native Webhook)
[ Dev Prod Mode ]
│
▼ (Release Please PR)
[ Tag pushed (v1.0.0) ]
│
▼
[ Gated PRD Deploy ]
│
▼
[ Manual Gate Approval ]
│
▼
[ Deploy LookML & Deploy Content ]
- Continuous Integration (PR Validation): Every pull request undergoes automated testing (via Looker CI) and styling audits (via LAMS) before it is allowed to merge.
- Native Webhook Promotion: Merging a PR into
masterinstantly deploys the code to the Dev instance’s production mode (serving as a QA sandbox) via secure, native GitHub webhooks. - Gated Production Releases: Releases to the Production (PRD) instance are tag-driven (e.g.,
v1.0.0) and managed by GitHub Environments with required manual approvals. - Cohesive Content Backups & Rollbacks: Shared folder dashboards and looks are backed up to a versioned GCS bucket upon release. If a release fails, redeploying an older tag automatically restores both the LookML code and its corresponding historical dashboard state.
1. PR Gates: Looker CI & LAMS Linter
Before code ever reaches your master branch, it must pass through automated quality checks.
Pull Request Template
To set expectations early, we use a custom .github/PULL_REQUEST_TEMPLATE.md to prompt developers with a standardized checklist when opening a PR. This forces developers to self-verify that they’ve run LookML validations, confirmed that all new/modified dimensions have descriptions, and ensured database queries perform correctly.
Looker CI
We integrate pull requests with Looker CI to validate code integrity against your live database. On every PR, Looker CI triggers:
- LookML Validator: Catches syntax and logic errors in the LookML.
- SQL Validator: Runs queries against the underlying database for every Explored dimension to ensure no database columns are broken.
- Assert Validator: Executes your LookML custom data assertions.
- Content Validator: Verifies that no dashboards or Looks are broken by the model changes.
LAMS (Look At Me Sideways)
To maintain code quality and styling consistency, we run LAMS on every commit. LAMS parses the LookML and writes warnings or errors directly as inline comments on the PR lines that violated the styling rules (such as enforcing that all dimensions must have descriptions).
Code Review Routing (CODEOWNERS)
To prevent bottlenecks, we use a CODEOWNERS file at the root of the repository. This automatically routes pull requests to specific team experts depending on which LookML files are touched (e.g., routing changes in finance models to the finance data team, and core configurations to the platform leads).
2. Gated Deployments & Automatic Rollbacks
Once a Release PR is merged, it tags the repository (e.g., v1.0.0) and starts the Deploy to PRD pipeline.
Manual Approval Gates
The deployment targets a GitHub Environment named PRD. This environment is configured with a manual approval rule, requiring a Data Lead or Administrator to explicitly review and click “Approve” in the Actions UI before the runner executes the deployment script.
Automating Content Syncing with looker-deployer
To sync user-facing Looker objects (Dashboards and Looks) along with our LookML code, we use looker-deployer—an open-source python utility built on Looker’s API SDK:
- Exporting (
ldeploy content export): The pipeline queries the Dev Looker instance, reads the content of the Shared folder, and exports the JSON blueprints of all Dashboards and Looks into a local workspace directory. - Importing (
ldeploy content import): The pipeline uploads those JSON files to Google Cloud Storage. When importing to PRD,looker-deployerreads the JSON folder, reconstructs the folder tree hierarchy, and recreates/updates the corresponding Dashboards and Looks on the PRD instance.
Automated Rollbacks via GCS Snapshots
If a deployment causes issues in production, you can roll back instantly. The deployment pipeline checks GCS for an existing backup tag:
- New Release: If the backup does not exist, the pipeline exports the shared folder content from Dev, uploads it to GCS, and imports it to PRD.
- Rollback: If you trigger a manual run targeting an older tag (e.g.
v0.9.0), the pipeline detects that the backup already exists on GCS. It skips the export (preserving history) and directly downloads thev0.9.0snapshot from GCS and imports it into PRD. This ensures your dashboards and Looks revert back to the exact state they were in at that tag.
Key Configuration Steps
To configure this workflow on your repository:
GCP Setup
export PROJECT_ID="your-gcp-project-id"
export BUCKET_NAME="looker-migrations-snapshots-gitops"
export SA_NAME="github-deployer"
export SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
# Set your active project
gcloud config set project $PROJECT_ID
- Create GCS Bucket: Create a versioned bucket to store content backups
# Create the bucket (defaults to US multi-region; add --location=REGION if needed)
gcloud storage buckets create gs://$BUCKET_NAME
# Enable versioning for backups
gcloud storage buckets update gs://$BUCKET_NAME --versioning
- Create Service Account: Create a Service Account
gcloud iam service-accounts create $SA_NAME \
--display-name="GitHub Deployer for Looker Migrations"
- Grant Permissions: Assign the service account Storage Object Admin permissions specifically on your backup GCS bucket.
gcloud storage buckets add-iam-policy-binding gs://$BUCKET_NAME \
--member="serviceAccount:$SA_EMAIL" \
--role="roles/storage.objectAdmin"
- Generate Keys: Create and download a JSON credential key for the service account, and save it in your GitHub Secrets as
GCP_SA_KEY.
gcloud iam service-accounts keys create GCP_SA_KEY.json \
--iam-account=$SA_EMAIL
Looker Setup
- Configure Webhook on Dev: Set a Webhook Deploy Secret in Looker Dev Project Settings, and configure a webhook in GitHub pointing to
https://<dev-url>/webhooks/projects/<project_id>/deploysigned with that secret. - Enable Advanced Deploy Mode on PRD: In Looker PRD Project Settings, toggle on Enable Advanced Deploy Mode.
- Configure Environment Gates: In GitHub, create a
PRDEnvironment and add required reviewers to gate deployment runs. - Set Up Secrets: Add your API credentials as secrets (
DEV_LOOKERSDK_BASE_URL,PRD_LOOKERSDK_BASE_URL, etc.). Important: Ensure your base URLs explicitly include the port suffix (e.g.,https://domain.looker.app:443) to prevent dependency parser crashes.
deploy-prd.yml
name: Deploy to PRD
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
deploy:
name: Deploy to PRD
runs-on: ubuntu-latest
environment: PRD
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
GCP_SERVICE_ACCOUNT: ${{ vars.GCP_SERVICE_ACCOUNT }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Ruby
uses: actions/setup-ruby@v1
with:
ruby-version: '2.7'
- name: Install Gazer Gem
run: gem install gazer
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install Looker-Deployer
run: pip install looker-deployer
- name: Check Auth Configurations
run: |
echo "Checking GCP Auth variables..."
if [ -n "$GCP_SA_KEY" ]; then
echo "-> Service Account Key authentication is configured."
else
echo "-> Service Account Key is NOT configured."
fi
- name: Authenticate to GCP (Service Account Key)
if: env.GCP_SA_KEY != ''
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ env.GCP_SA_KEY }}
- name: Authenticate to GCP (Workload Identity Federation)
if: env.GCP_SA_KEY == '' && env.GCP_WORKLOAD_IDENTITY_PROVIDER != '' && env.GCP_SERVICE_ACCOUNT != ''
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ env.GCP_SERVICE_ACCOUNT }}
- name: Set up Cloud SDK
uses: google-github-actions/setup-gcloud@v2
- name: Configure Credentials and Connection Files
env:
DEV_LOOKERSDK_BASE_URL: ${{ secrets.DEV_LOOKERSDK_BASE_URL }}
PRD_LOOKERSDK_BASE_URL: ${{ secrets.PRD_LOOKERSDK_BASE_URL }}
DEV_CLIENT_ID: ${{ secrets.DEV_LOOKERSDK_CLIENT_ID }}
DEV_CLIENT_SECRET: ${{ secrets.DEV_LOOKERSDK_CLIENT_SECRET }}
PRD_CLIENT_ID: ${{ secrets.PRD_LOOKERSDK_CLIENT_ID }}
PRD_CLIENT_SECRET: ${{ secrets.PRD_LOOKERSDK_CLIENT_SECRET }}
run: |
DEV_BASE_URL="${DEV_LOOKERSDK_BASE_URL%/}"
PRD_BASE_URL="${PRD_LOOKERSDK_BASE_URL%/}"
# Safeguard workaround: append port 443 if no port exists in secret URL
if [[ ! "${DEV_BASE_URL}" =~ :[0-9]+$ ]]; then
DEV_BASE_URL="${DEV_BASE_URL}:443"
fi
if [[ ! "${PRD_BASE_URL}" =~ :[0-9]+$ ]]; then
PRD_BASE_URL="${PRD_BASE_URL}:443"
fi
cat <<EOF > looker.ini
[dev]
base_url=$DEV_BASE_URL
client_id=$DEV_CLIENT_ID
client_secret=$DEV_CLIENT_SECRET
[prd]
base_url=$PRD_BASE_URL
client_id=$PRD_CLIENT_ID
client_secret=$PRD_CLIENT_SECRET
EOF
- name: Check GCS Backup Existence
id: check_backup
env:
TAG: ${{ github.ref_name }}
run: |
GCS_PATH="gs://looker-migrations-snapshots-gitops/looker_backups/${TAG}/"
if gcloud storage ls "${GCS_PATH}**" > /dev/null 2>&1; then
echo "backup_exists=true" >> $GITHUB_OUTPUT
echo "Backup snapshot for tag ${TAG} already exists. Skipping export phase."
else
echo "backup_exists=false" >> $GITHUB_OUTPUT
echo "No backup found for tag ${TAG}. New release backup will be created."
fi
- name: Backup Dev Looker Content to GCS
if: steps.check_backup.outputs.backup_exists == 'false'
env:
TAG: ${{ github.ref_name }}
run: |
GCS_PATH="gs://looker-migrations-snapshots-gitops/looker_backups/${TAG}/"
ldeploy content export --ini ./looker.ini --env dev --folders 1 --local-target ./looker_content_export
gcloud storage cp --recursive ./looker_content_export/* "$GCS_PATH"
- name: Authenticate and Deploy LookML to PRD
env:
TAG: ${{ github.ref_name }}
PRD_LOOKERSDK_BASE_URL: ${{ secrets.PRD_LOOKERSDK_BASE_URL }}
PRD_CLIENT_ID: ${{ secrets.PRD_LOOKERSDK_CLIENT_ID }}
PRD_CLIENT_SECRET: ${{ secrets.PRD_LOOKERSDK_CLIENT_SECRET }}
run: |
# Authenticate to PRD instance API and trigger ADM Ref deployment
auth_resp=$(curl -s -X POST -F "client_id=$PRD_CLIENT_ID" -F "client_secret=$PRD_CLIENT_SECRET" "$PRD_LOOKERSDK_BASE_URL/api/4.0/login")
token=$(echo "$auth_resp" | python3 -c "import sys, json; print(json.load(sys.stdin)['access_token'])")
curl -s -X PATCH -H "Authorization: Bearer $token" -H "Content-Type: application/json" -d '{"workspace_id":"dev"}' "$PRD_LOOKERSDK_BASE_URL/api/4.0/session"
curl -s -X POST -H "Authorization: Bearer $token" "$PRD_LOOKERSDK_BASE_URL/api/4.0/projects/looker_cicd_sample/deploy_ref_to_production?ref=$TAG"
- name: Import Migrated Content to PRD Looker
env:
TAG: ${{ github.ref_name }}
run: |
GCS_PATH="gs://looker-migrations-snapshots-gitops/looker_backups/${TAG}/"
mkdir -p ./looker_content_import
gcloud storage cp --recursive "${GCS_PATH}*" ./looker_content_import/
ldeploy content import --ini ./looker.ini --env prd --folders ./looker_content_import/Shared --recursive
lams.yml
name: Looker LAMS Linting
on:
pull_request:
branches:
- master
jobs:
lams:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install LAMS
run: npm install -g @looker/look-at-me-sideways@3
- name: Run LAMS
run: |
lams --reporting=no > lams_output.txt 2>&1
cat lams_output.txt
- name: Post LAMS Report as PR Comment
if: failure()
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
if [ -f lams_output.txt ]; then
REPORT=$(cat lams_output.txt)
# Construct the Markdown body
COMMENT_BODY=$(cat <<EOF
### ❌ Looker LAMS Linting Errors
LookML style validation failed. Review the style violations below:
\`\`\`text
$REPORT
\`\`\`
*Please update your branch with the correct style rules to pass validation checks.*
EOF
)
# Post comment to PR
gh pr comment "$PR_NUMBER" --body "$COMMENT_BODY"
else
echo "No LAMS output file found."
fi
release.yml
name: release-please
on:
push:
branches:
- master
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/release-please-action@v4
with:
release-type: simple