Stop re-running gate stages whose inputs did not change

992ffa079c93 · AtlantisPleb · · parent bc572f0852d3

Stop re-running gate stages whose inputs did not change

The gate ran all fifteen stages every time, so a one-line test fix rebuilt
releases, re-ran the asset suite, and re-proved the staging infrastructure —
about forty minutes to learn one thing. Four runs died that way today on
mistakes that took seconds to fix.

Each stage now declares the paths it reads, and its key is Git's own tree
hashes for those paths plus the lockfile and the configuration, which every
stage depends on. A stage is reused only when it has already passed on
exactly those inputs. Recorded after it passes, so a failure is never
reused, and --force ignores the cache entirely.

Conservative by construction: a stage with no declared inputs gets the whole
worktree and never reuses. Adding a path to the list is always safe;
removing one is what could let a stage be reused when it should have run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnhfrafYx5ZGaMbzZEJQ2d
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 404 · 2026-08-25T15:37:41.764905Z

Changed files

  • modified ops/ci/gate.sh

Diff

1 file changed, +53 -0

ops/ci/gate.sh modified +53

@@ -98,12 +98,61 @@ cleanup() {

98 98
99 99
trap cleanup EXIT INT TERM
100 100
101
# A stage's inputs, as paths. A stage is re-run when any of them changes and
102
# reused when none of them has, so a test-only edit stops rebuilding releases
103
# and re-running the asset suite. Everything depends on the lockfile and the
104
# configuration, so those are added to every stage rather than listed here.
105
#
106
# The rule for editing this list is one-directional: adding a path is always
107
# safe, and removing one can make the gate reuse a stage that should have run.
108
# A stage whose inputs are not listed gets the whole worktree and therefore
109
# never reuses, which is the safe default rather than an oversight.
110
stage_inputs() {
111
  case $1 in
112
    compile | production_compile) echo "lib" ;;
113
    precommit) echo "lib test priv assets ops" ;;
114
    enumeration_proofs) echo "lib test ops" ;;
115
    cluster | direct_transaction | relup_topology | rolling_replacement) echo "lib test" ;;
116
    javascript) echo "assets" ;;
117
    relup | version_chain | interrupted_install) echo "lib priv assets ops" ;;
118
    contracts) echo "lib priv ops" ;;
119
    staging_infra) echo "ops" ;;
120
    release_smoke) echo "lib priv assets ops" ;;
121
    *) echo "." ;;
122
  esac
123
}
124
125
# The identity of a stage's inputs: Git's own tree hashes, so a path that has
126
# not changed hashes the same however it got there, and an uncommitted edit
127
# hashes differently from the commit it sits on. The gate already refuses a
128
# dirty worktree, so HEAD is the whole truth here.
129
stage_key() {
130
  stage_name=$1
131
  {
132
    echo "$stage_name"
133
    for input_path in $(stage_inputs "$stage_name") mix.exs mix.lock config; do
134
      git -C "$repo_root" rev-parse "HEAD:$input_path" 2>/dev/null ||
135
        echo "absent:$input_path"
136
    done
137
  } | shasum -a 256 | cut -d" " -f1
138
}
139
140
stage_cache_root="$git_common_dir/openagents/release-gate-stages"
141
101 142
run_stage() {
102 143
  stage_name=$1
103 144
  shift
104 145
  stage_log="$run_root/$stage_name.log"
105 146
  stage_started=$(date +%s)
106 147
148
  cache_entry="$stage_cache_root/$stage_name.$(stage_key "$stage_name")"
149
150
  if [ "$mode" != "--force" ] && [ -f "$cache_entry" ]; then
151
    echo "Reusing $stage_name (inputs unchanged since it last passed)"
152
    eval "${stage_name}_duration_seconds=$(cat "$cache_entry")"
153
    return 0
154
  fi
155
107 156
  echo "Running $stage_name"
108 157
109 158
  set +e

@@ -120,6 +169,10 @@ run_stage() {

120 169
121 170
  stage_finished=$(date +%s)
122 171
  eval "${stage_name}_duration_seconds=$((stage_finished - stage_started))"
172
173
  # Recorded only after it passed, so a failure is never reused.
174
  mkdir -p "$stage_cache_root"
175
  eval "echo \$${stage_name}_duration_seconds" >"$cache_entry"
123 176
}
124 177
125 178
cd "$repo_root"

This page updates live while a promote is in flight · changelog