Every environment is a branch, every change a commit, every promotion a pull request. Business processes, formulas, warehouse models and environment settings live in your repository as text you can review - and warehouse models as a real dbt project.
2branches: Development and Production (Test too on Enterprise)
1commit for each Commit Development, and for each model saved
0secrets in the repository - credentials travel by name
How it works
- Connect GitHub. Settings › Source control. Install the Ductile GitHub App on your account or organisation, pick one repository, and name a branch for each environment - Development and Production, and Test on Enterprise - and a folder, if the repository holds other things too. The console checks you can see that installation before it trusts it.
- Commit Development. One commit on the Development branch with every business process, formula, model and environment setting. A save that changes nothing makes no commit.
- Promote by pull request. Promote (pull request) opens a pull request from Development’s branch to Production’s - on Enterprise, to Test’s first, then from Test to Production. Review, required reviewers and branch protection are GitHub’s. The environments above Development change only by pull request: the console never commits to them.
- Merge, and it is promoted. With Promote on merge on, a merged pull request promotes the business processes it carries, through the same checks and approvals as promoting by hand - a Production that requires approval gets a pending approval, not a deploy. Every outcome is recorded with the merge commit.
What the repository holds
One job - Order revenue in the Harbor Lane space, with its models and a Run dbt process on Snowflake - exactly as the console commits it:
- dbt/
- harbor-lane/
- order-revenue/
- macros/
- dai_formulas.sql
- models/
- intermediate/
- orders_enriched.sql
- marts/
- daily_revenue.sql
- staging/
- stg_customers.sql
- stg_orders.sql
- sources.yml
- intermediate/
- dbt_project.yml
- macros/
- order-revenue/
- harbor-lane/
- environments/
- dev.json
- prod.json
- formulas/
- harbor-lane/
- order-revenue/
- centstodollars.json
- fullname.json
- ispaid.json
- isweborder.json
- normalizeemail.json
- ordersize.json
- phonedigits.json
- roundmoney.json
- salesregion.json
- salestax.json
- statecode.json
- order-revenue/
- harbor-lane/
- models/
- harbor-lane/
- order-revenue/
- daily-revenue.json
- orders-enriched.json
- stg-customers.json
- stg-orders.json
- order-revenue/
- harbor-lane/
- processes/
- harbor-lane/
- order-revenue/
- run-dbt.json
- order-revenue/
- harbor-lane/
- credentials.json
- dai-source.json
dai-source.json
{
"environments": [
"environments/dev.json",
"environments/prod.json"
],
"format": "dai.source/v1",
"formulas": [
"formulas/harbor-lane/order-revenue/centstodollars.json",
"formulas/harbor-lane/order-revenue/fullname.json",
"formulas/harbor-lane/order-revenue/ispaid.json",
"formulas/harbor-lane/order-revenue/isweborder.json",
"formulas/harbor-lane/order-revenue/normalizeemail.json",
"formulas/harbor-lane/order-revenue/ordersize.json",
"formulas/harbor-lane/order-revenue/phonedigits.json",
"formulas/harbor-lane/order-revenue/roundmoney.json",
"formulas/harbor-lane/order-revenue/salesregion.json",
"formulas/harbor-lane/order-revenue/salestax.json",
"formulas/harbor-lane/order-revenue/statecode.json"
],
"models": [
"models/harbor-lane/order-revenue/daily-revenue.json",
"models/harbor-lane/order-revenue/orders-enriched.json",
"models/harbor-lane/order-revenue/stg-customers.json",
"models/harbor-lane/order-revenue/stg-orders.json"
],
"processes": [
"processes/harbor-lane/order-revenue/run-dbt.json"
]
}
credentials.json
[
{
"kind": "dql.credential.snowflakes3database",
"name": "Snowflake analytics",
"settings": [
"authType",
"databaseName",
"hostname",
"privateKey",
"role",
"username",
"warehouse"
]
}
]
models/harbor-lane/order-revenue/stg-orders.json
{
"definition": {
"columns": [
{
"formula": "=orders.id",
"name": "order_id"
},
{
"formula": "=orders.customer_id",
"name": "customer_id"
},
{
"formula": "=orders.order_day",
"name": "order_day"
},
{
"formula": "=RoundMoney(CentsToDollars(orders.total_cents))",
"name": "total"
},
{
"formula": "=StateCode(orders.ship_state)",
"name": "state"
},
{
"formula": "=orders.channel",
"name": "channel"
},
{
"formula": "=orders.financial_status",
"name": "status"
}
],
"from": {
"kind": "source",
"name": "shop.orders"
},
"joins": [],
"keptAs": "view",
"layer": "raw"
},
"description": "Shopify orders as they arrive, money in dollars.",
"format": "dai.source/v1",
"job": "Order revenue",
"name": "stg_orders",
"space": "Harbor Lane"
}
formulas/harbor-lane/order-revenue/salesregion.json
{
"body": "=IF(UPPER(TRIM(state)) in [\"CA\", \"OR\", \"WA\", \"NV\", \"AZ\"], \"West\", IF(UPPER(TRIM(state)) in [\"NY\", \"NJ\", \"MA\", \"PA\", \"CT\"], \"Northeast\", IF(UPPER(TRIM(state)) in [\"TX\", \"OK\", \"LA\", \"AR\"], \"South Central\", \"Central\")))",
"description": "",
"examples": [],
"format": "dai.source/v1",
"inputs": [
"state"
],
"job": "Order revenue",
"name": "SalesRegion",
"space": "Harbor Lane"
}
dbt/harbor-lane/order-revenue/models/marts/daily_revenue.sql
-- Revenue by day and region.
{{ config(materialized='table') }}
select
orders_enriched.order_day as order_day,
orders_enriched.region as region,
COUNT(orders_enriched.order_id) as orders,
COUNT(DISTINCT orders_enriched.customer_email) as customers,
SUM(orders_enriched.total) as revenue,
SUM(orders_enriched.sales_tax) as sales_tax
from {{ ref('orders_enriched') }} as orders_enriched
group by orders_enriched.order_day, orders_enriched.region
The rules it keeps
- No secrets in the repository A credential travels as its name, its kind and the names of its settings. A step refers to it as credential:<name>, and the same repository imports into another console whose credentials hold other secrets under the same names.
- Diffs you can read Keys are sorted and nothing time-dependent is written, so committing unchanged settings is byte-identical and a pull request shows only what changed.
- Each model, a commit With a repository connected, saving or deleting a model commits that dbt project - only its own folder, never another process’s draft - and a deleted model’s files leave the repository in the same commit.
- dbt files are written, not read The dbt/ folder is derived from the models: an import reads the models and formulas beside it, never the SQL, so the two cannot drift.
A dbt project you keep in Git
Run dbt also runs a dbt project straight from a repository - GitHub, GitLab, Bitbucket or your own Git server: the https address, a branch or tag, and a folder. A private repository is read with a read-only token taken from your vault when the step runs. See the dbt projects.
Plans
GitHub source control comes with Pro Team, Pro Business and Enterprise. Compare plans