Build with AnnE, or from your own code. The MCP server, the REST API and GitHub - every job is also files you can review.

Developers · GitHub

GitHub source control

Ductile AI on GitHub

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

  1. 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.
  2. 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.
  3. 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.
  4. 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
        • dbt_project.yml
  • 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
  • models/
    • harbor-lane/
      • order-revenue/
        • daily-revenue.json
        • orders-enriched.json
        • stg-customers.json
        • stg-orders.json
  • processes/
    • harbor-lane/
      • order-revenue/
        • run-dbt.json
  • 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