Cases
CaseDeployment ordering

Data before code is a graph problem

A later copy-only commit could deploy code that inherited an earlier commit's unfinished data dependency while a file-diff gate saw nothing relevant.

23 July 20263 min read
What changed

The dependency became a graph invariant that requires a successful workflow commit between the required change and the release.

Useful takeaway

A release obligation belongs to ancestry, not to the event that happens to trigger deployment.

  • A later-commit bypass reproduced in a synthetic Git history
  • Success before the required change is rejected
  • Missing or unreadable workflow proof fails closed

“Run the data pipeline before deploying the code” sounds like a checklist item. It is actually a graph invariant.

That distinction matters when releases are assembled by agents, approvals happen asynchronously, and more than one commit can land while a workflow is waiting. A checklist describes the happy path. A graph invariant keeps holding when the order changes.

File diffs do not carry obligations

Consider this history:

o---A---B  main
    |
    data contract changes
    pipeline pending

        B changes only copy

The application at B contains everything introduced at A. It also inherits A's unmet data dependency.

This check misses it:

git diff --name-only A B | grep '^pipeline/'

B did not change the pipeline, so the gate concludes there is nothing to wait for. The question is locally accurate and globally wrong.

Release safety depends on the newest dependency-bearing commit reachable from the release:

required_sha="$(git log -1 --format=%H -- \
  data/production-snapshot.json \
  pipeline/parse.ts \
  pipeline/load.ts \
  src/data/read-model.ts)"

That commit is the obligation. Any production release containing it must be covered by a successful data workflow.

Green needs a location

Let R be the latest required data commit, W the commit used by a successful data workflow, and D the release commit. The safe relationship is:

R <= W <= D

Here <= means “is an ancestor of.”

git merge-base --is-ancestor "$required_sha" "$workflow_sha"
git merge-base --is-ancestor "$workflow_sha" "$release_sha"

The first line rejects a green run that predates the data change. The second rejects a run from a branch or future commit that the release does not contain.

Put the gate before the deploy

The GitHub Actions shape is deliberately small:

jobs:
  await-data:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Resolve newest data obligation
        id: required
        run: |
          sha="$(git log -1 --format=%H -- pipeline/ data/ src/data/)"
          echo "sha=$sha" >> "$GITHUB_OUTPUT"

      - name: Wait for a covering pipeline run
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          scripts/wait-for-workflow-ancestor.sh \
            "Production Data Pipeline" \
            "${{ steps.required.outputs.sha }}" \
            "$GITHUB_SHA"

  deploy:
    needs: await-data

Full history matters. A shallow checkout cannot answer arbitrary ancestry questions. The complete reconstructed data before code workflow also uses a deployment concurrency group. When a new push arrives, it replaces the older wait and recomputes the obligation.

Fail closed when proof is unavailable

Failure to read workflow state cannot mean permission to deploy:

if ! successful_shas="$(gh "${args[@]}")"; then
  echo "Could not read workflow runs." >&2
  exit 1
fi

A timeout blocks for the same reason. The prerequisite being slow may be harmless. The deploy guessing that it probably succeeded is not.

Test the bypass

An ancestry helper looks correct when it accepts the current commit. The useful fixtures are:

before required       -> reject
at required           -> accept
after required        -> accept
outside release line  -> reject
invalid commit        -> reject
API unavailable       -> reject

The release ancestry gate creates a temporary Git history and a fake workflow API. If the production failure depends on ancestry, the test fixture should have ancestry.

The same pattern covers schema before query code, a model artifact before inference code, search index before a new search surface, and access policy before a route becomes reachable.

“Run this first” relies on memory. “No release can pass without a covering success in its ancestry” is a system.

Reusable source · inspect before copying
Data-before-code workflowdata-before-code.yml · yaml
name: Deploy after data

on:
  push:
    branches: [main]

permissions:
  actions: read
  contents: read

concurrency:
  group: production-deploy
  cancel-in-progress: true

jobs:
  await-data:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Resolve the newest data-dependent change
        id: required
        shell: bash
        run: |
          set -euo pipefail
          sha="$(git log -1 --format=%H -- \
            data/production-snapshot.json \
            pipeline/parse.ts \
            pipeline/load.ts \
            src/data/read-model.ts)"
          echo "sha=$sha" >> "$GITHUB_OUTPUT"

      - name: Wait for the data pipeline to cover that change
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        shell: bash
        run: |
          scripts/wait-for-workflow-ancestor.sh \
            "Production Data Pipeline" \
            "${{ steps.required.outputs.sha }}" \
            "$GITHUB_SHA"

  deploy:
    needs: await-data
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm test
      - run: ./scripts/deploy-production.sh "$GITHUB_SHA"
Ancestry gate testsrelease-ancestry-gate.test.sh · shell
#!/usr/bin/env bash
set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
subject="$script_dir/release-ancestry-gate.sh"
fixture_dir="$(mktemp -d)"
trap 'rm -rf -- "$fixture_dir"' EXIT

git -C "$fixture_dir" init -q
git -C "$fixture_dir" config user.email "test@example.com"
git -C "$fixture_dir" config user.name "Release Gate Test"

printf 'zero\n' > "$fixture_dir/value.txt"
git -C "$fixture_dir" add value.txt
git -C "$fixture_dir" commit -qm "before requirement"
before_sha="$(git -C "$fixture_dir" rev-parse HEAD)"

printf 'one\n' > "$fixture_dir/value.txt"
git -C "$fixture_dir" commit -qam "required"
required_sha="$(git -C "$fixture_dir" rev-parse HEAD)"

printf 'two\n' >> "$fixture_dir/value.txt"
git -C "$fixture_dir" commit -qam "pipeline"
pipeline_sha="$(git -C "$fixture_dir" rev-parse HEAD)"

printf 'three\n' >> "$fixture_dir/value.txt"
git -C "$fixture_dir" commit -qam "release"
release_sha="$(git -C "$fixture_dir" rev-parse HEAD)"

fake_bin="$fixture_dir/bin"
mkdir -p "$fake_bin"
# The generated fake reads FAKE_SUCCESS_SHA when it runs, not while this fixture is created.
# shellcheck disable=SC2016
printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$FAKE_SUCCESS_SHA"\n' > "$fake_bin/gh"
chmod +x "$fake_bin/gh"

run_gate() {
  (
    cd "$fixture_dir"
    PATH="$fake_bin:$PATH" \
      FAKE_SUCCESS_SHA="$1" \
      MAX_ATTEMPTS=1 \
      POLL_SECONDS=0 \
      bash "$subject" "Example Data Pipeline" "$required_sha" "$release_sha"
  )
}

run_gate "$required_sha"
run_gate "$pipeline_sha"

if run_gate "$before_sha" 2>/dev/null; then
  echo "expected a run before the requirement to fail" >&2
  exit 1
fi

echo "workflow ancestry tests passed"
Optional: open the original field-note URL