Skip to content

Commit 73d0740

Browse files
committed
ci: add shared dependency gate to skip stale-lock CI runs
A dependency PR triggers CI twice: once on the contributor's commit (gemfiles changed, lockfiles still stale) and again on the lock-dependency bot's regenerated-lock commit. On the first, stale run every bundler-dependent job fails BUNDLE_FROZEN, wasting compute - most expensively the macOS matrix (6 jobs, ~10x runner billing). Add a reusable Dependency Gate workflow (_deps_gate.yml) as a single source of truth, adopted by the workflows that lacked an early gate: test-macos, test-yjit, and check (via its build job). The gate reuses lock-dependency's dorny/paths-filter + dependency_filters.yml to detect dependency changes, then probes every base gemfile for lockfile consistency. Dependent jobs run only when proceed == 'true'. The probe (deps_gate_probe.rb) calls Bundler's frozen equivalence check without installing gems or hitting the network, so it is cheap and runs once under Ruby 4.0 / Bundler 2.7.2 regardless of each job's Ruby. This keeps the first, stale run cheap; it cannot reduce the two runs to one, since the bot's lock commit always re-triggers CI.
1 parent 63e6998 commit 73d0740

6 files changed

Lines changed: 143 additions & 0 deletions

File tree

.github/scripts/deps_gate.sh

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Dependency gate decision (see .github/workflows/_deps_gate.yml).
4+
#
5+
# Runs only when dependency files changed. Probes every base gemfile against its
6+
# lockfile and emits proceed=true|false to "$GITHUB_OUTPUT":
7+
# true - every lockfile is consistent with its gemfile; dependent (expensive)
8+
# jobs may run.
9+
# false - at least one lockfile is stale; dependent jobs should skip and wait
10+
# for the lock-dependency bot to regenerate and commit the lockfiles,
11+
# which re-triggers CI on a fresh commit.
12+
#
13+
# All base gemfiles are probed (not just one) so that a change isolated to a
14+
# single Ruby's gemfile, or a broad change (gemspec, appraisal) that affects
15+
# every lockfile, is caught.
16+
set -uo pipefail
17+
18+
export BUNDLE_FROZEN=true
19+
proceed=true
20+
21+
for gemfile in gemfiles/ruby-*.gemfile; do
22+
rc=0
23+
BUNDLE_GEMFILE="$gemfile" ruby .github/scripts/deps_gate_probe.rb || rc=$?
24+
case "$rc" in
25+
0)
26+
;;
27+
2)
28+
echo "::notice::Stale lockfile for ${gemfile}; waiting for the lock-dependency bot."
29+
proceed=false
30+
;;
31+
*)
32+
echo "::error::deps-gate probe errored for ${gemfile} (exit ${rc})."
33+
exit 1
34+
;;
35+
esac
36+
done
37+
38+
echo "Dependency files changed; all lockfiles consistent: ${proceed}."
39+
echo "proceed=${proceed}" >> "$GITHUB_OUTPUT"

.github/scripts/deps_gate_probe.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# frozen_string_literal: true
2+
3+
# Dependency gate probe (see .github/workflows/_deps_gate.yml).
4+
#
5+
# Checks whether the gemfile in BUNDLE_GEMFILE is consistent with its lockfile,
6+
# WITHOUT installing gems or touching the network: it only compares the gemfile's
7+
# declared dependencies against the lockfile. Must run under BUNDLE_FROZEN=true,
8+
# which is what makes Bundler raise on a divergence instead of silently updating
9+
# the lockfile.
10+
#
11+
# Exit codes:
12+
# 0 - consistent: the lockfile matches the gemfile.
13+
# 2 - stale: the gemfile changed but the lockfile has not been regenerated yet.
14+
# 1 - probe error (e.g. a future Bundler renamed the API): surfaced loudly so it
15+
# gets fixed, rather than silently gating out every dependency PR.
16+
require "bundler"
17+
18+
Bundler.ui.level = "error"
19+
20+
begin
21+
Bundler.definition.ensure_equivalent_gemfile_and_lockfile
22+
exit 0
23+
rescue Bundler::ProductionError, Bundler::GemfileError
24+
exit 2
25+
rescue => e
26+
warn "deps-gate: unexpected probe failure for #{ENV["BUNDLE_GEMFILE"]}: #{e.class}: #{e.message}"
27+
exit 1
28+
end

.github/workflows/_deps_gate.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
name: Dependency Gate
2+
3+
# Single source of truth for "may dependent (expensive) workflows run on this
4+
# commit yet?". Adopted by workflows whose jobs would otherwise run, and fail, on
5+
# a dependency PR before the lock-dependency bot has regenerated the lockfiles.
6+
#
7+
# A dependency PR triggers CI twice: once on the contributor's commit (gemfiles
8+
# changed, lockfiles still stale) and again on the bot's lock commit. This gate
9+
# lets expensive jobs skip the first, stale run and only run once the lockfiles
10+
# are consistent. It cannot reduce the two runs to one — the bot commit always
11+
# re-triggers CI — but it keeps the first run cheap.
12+
13+
on: # yamllint disable-line rule:truthy
14+
workflow_call:
15+
outputs:
16+
proceed:
17+
description: >-
18+
'true' when dependent jobs may run: either no dependency files changed,
19+
or every lockfile is consistent with its gemfile. 'false' when a
20+
dependency changed but at least one lockfile is still stale (the
21+
lock-dependency bot has not regenerated and committed it yet).
22+
value: ${{ jobs.changes.outputs.dependencies != 'true' || jobs.gate.outputs.proceed == 'true' }}
23+
24+
# Default permissions for all jobs
25+
permissions: {}
26+
27+
jobs:
28+
changes:
29+
name: detect dependency changes
30+
runs-on: ubuntu-24.04
31+
outputs:
32+
dependencies: ${{ steps.changes.outputs.dependencies }}
33+
steps:
34+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
35+
with:
36+
persist-credentials: false
37+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
38+
id: changes
39+
with:
40+
filters: .github/dependency_filters.yml
41+
42+
gate:
43+
name: probe lockfile consistency
44+
needs: changes
45+
if: ${{ needs.changes.outputs.dependencies == 'true' }}
46+
runs-on: ubuntu-24.04
47+
container:
48+
image: ghcr.io/datadog/images-rb/engines/ruby:4.0-gnu-gcc
49+
outputs:
50+
proceed: ${{ steps.decide.outputs.proceed }}
51+
steps:
52+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
53+
with:
54+
persist-credentials: false
55+
- name: Decide whether dependent jobs may proceed
56+
id: decide
57+
run: bash .github/scripts/deps_gate.sh

.github/workflows/check.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,17 @@ concurrency:
1616
permissions: {}
1717

1818
jobs:
19+
gate:
20+
uses: ./.github/workflows/_deps_gate.yml
21+
22+
# Gating `build` transitively skips the bundler-dependent jobs (standard, steep,
23+
# frozen_string_literal) on a dependency PR whose lockfiles are still stale. The
24+
# lock-independent jobs (semgrep, zizmor, actionlint, yaml-lint) do not need
25+
# `build`, so they keep running.
1926
build:
2027
name: build
28+
needs: gate
29+
if: ${{ needs.gate.outputs.proceed == 'true' }}
2130
runs-on: ubuntu-24.04
2231
container:
2332
image: ghcr.io/datadog/images-rb/engines/ruby:4.0-gnu-gcc

.github/workflows/test-macos.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ on: # yamllint disable-line rule:truthy
1212
permissions: {}
1313

1414
jobs:
15+
gate:
16+
uses: ./.github/workflows/_deps_gate.yml
17+
1518
test-macos:
19+
needs: gate
20+
if: ${{ needs.gate.outputs.proceed == 'true' }}
1621
strategy:
1722
fail-fast: false
1823
matrix:

.github/workflows/test-yjit.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ on: # yamllint disable-line rule:truthy
1212
permissions: {}
1313

1414
jobs:
15+
gate:
16+
uses: ./.github/workflows/_deps_gate.yml
17+
1518
test-yjit:
19+
needs: gate
20+
if: ${{ needs.gate.outputs.proceed == 'true' }}
1621
strategy:
1722
fail-fast: false
1823
matrix:

0 commit comments

Comments
 (0)