Skip to contents

A refactor can change where a column’s numbers come from without changing the code’s shape at all: swap a sum() for a mean(), point a join at a different table, drop an input nobody remembers. The pipeline still runs. The dashboard still fills in. The numbers mean something else now.

lineage_check() turns that class of change into a failed pull request. It diffs two lineage extractions, classifies each change as breaking or non-breaking, and errors when a change crosses the threshold you set. On a GitHub Actions runner the findings also become ::error and ::warning annotations, so they show up on the PR itself.

The severity rule comes from lineage_diff(): a removed or changed edge is breaking when its target column feeds anything downstream, or lives in a target table, since those columns are what reports and dashboards consume. Pure additions are non-breaking. Details are in ?lineage_diff.

Trying it locally

No database needed: dbplyr::tbl_lazy() makes a data frame traceable as if it were a warehouse table. Version one of a pipeline:

library(dplyneage)
library(dplyr)

orders <- dbplyr::tbl_lazy(
  data.frame(order_id = 1L, customer_id = 1L, amount = 9.99),
  name = "orders"
)

v1 <- orders |>
  group_by(customer_id) |>
  summarise(total_spent = sum(amount, na.rm = TRUE))

old <- extract_lineage(v1)

A branch that adds a column passes, because additions can’t invalidate anything already consuming the output:

v2 <- orders |>
  group_by(customer_id) |>
  summarise(
    total_spent = sum(amount, na.rm = TRUE),
    max_amount = max(amount, na.rm = TRUE)
  )

lineage_check(old, extract_lineage(v2), annotate = FALSE)
#>   non-breaking: added edge orders.amount -> output.max_amount
#>   non-breaking: added column output.max_amount
#> Lineage check passed: 2 changes, 0 breaking.

A branch that quietly redefines total_spent does not:

v3 <- orders |>
  group_by(customer_id) |>
  summarise(total_spent = mean(amount, na.rm = TRUE))

lineage_check(old, extract_lineage(v3), annotate = FALSE)
#>   breaking: changed edge orders.amount -> output.total_spent: sum(amount, na.rm = TRUE) => mean(amount, na.rm = TRUE)
#> Error:
#> ! Lineage check failed: 1 breaking lineage change (1 total). Inspect with lineage_diff(old, new); fail_on = "none" reports without failing.

We pass annotate = FALSE here because this article is itself rendered on an Actions runner, where the default would print workflow commands instead of plain lines. In your CI job you leave the default alone; detection is automatic.

The extract script

CI needs one thing from you: a script that builds your pipeline’s lineage and returns it as its last value. Keep it in the repo, next to the code it describes, so every branch carries its own version.

# ci/extract-lineage.R
library(dplyr)

orders <- dbplyr::tbl_lazy(
  data.frame(order_id = 1L, customer_id = 1L, amount = 9.99),
  name = "orders"
)

extract_lineage(list(
  silver_orders = orders |> filter(amount > 0),
  gold_totals = orders |>
    group_by(customer_id) |>
    summarise(total_spent = sum(amount, na.rm = TRUE))
))

For a real warehouse, replace the tbl_lazy() frames with tbl(con, ...) tables. The script only builds lazy queries, so nothing is collected and CI never pulls data.

The Actions job

The job checks out the PR, adds a second worktree at main, runs both copies of the extract script in one R session, and lets lineage_check() decide. A breaking change raises the classed error, Rscript exits nonzero, and the check fails.

name: lineage
on:
  pull_request:
    branches: [main]
permissions:
  contents: read
jobs:
  lineage-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Check out main for comparison
        run: |
          git fetch origin main
          git worktree add ../main-tree origin/main
      - uses: r-lib/actions/setup-r@v2
        with:
          use-public-rspm: true
      - name: Install packages
        run: Rscript -e 'install.packages(c("dplyneage", "dplyr", "dbplyr"))'
      - name: Lineage check
        run: |
          Rscript -e '
            old <- source("../main-tree/ci/extract-lineage.R", chdir = TRUE)$value
            new <- source("ci/extract-lineage.R", chdir = TRUE)$value
            dplyneage::lineage_check(old, new)
          '

The worktree is what lets both lineages build in one session with no serialization step: main’s copy of the script produces old, the branch’s copy produces new. Swap the install step for r-lib/actions/setup-r-dependencies or renv if your project already manages packages that way, and adjust the script paths to match your layout.

Comparing against a committed artifact

The worktree exists only to rebuild main’s lineage. If that lineage is committed as a file instead, the job needs one extraction. lineage_json() writes the file from the same script:

lineage_json(
  source("ci/extract-lineage.R", chdir = TRUE)$value,
  path = "ci/lineage.json"
)

lineage_from_json() reads it back as old, so the step that checks out main and the second source() call both drop out of the job:

      - name: Lineage check
        run: |
          Rscript -e '
            old <- dplyneage::lineage_from_json("ci/lineage.json")
            new <- source("ci/extract-lineage.R", chdir = TRUE)$value
            dplyneage::lineage_check(old, new)
          '

The file has to track main. A workflow that runs on pushes to main, rewrites ci/lineage.json, and commits the result keeps it current, the way any generated file kept in a repository is maintained; until that run lands, a pull request is checked against the lineage of the previous merge. In return, the artifact documents the pipeline on its own: its diff shows which edges changed, and jq can query it without R.

Tuning the policy

fail_on sets the threshold. The default "breaking" lets additive changes merge without ceremony. "any" fails on every provenance change, which suits pipelines under regulatory review, where even an added column should be acknowledged. "none" never fails and just prints the findings, useful while you calibrate.

For custom reporting, catch the classed condition. It carries the full diff:

tryCatch(
  lineage_check(old, new),
  dplyneage_lineage_check_failure = function(cnd) {
    write_pr_comment(format_my_way(cnd$diff))
    stop(cnd)
  }
)