merge-steward 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,6 +38,25 @@ export async function reconcile(ctx) {
38
38
  // Truth guard: verify entry against GitHub before processing.
39
39
  if (await sanitizeEntry(ctx, entry))
40
40
  continue;
41
+ // Stale dependency guard: if this entry's spec was built on top of
42
+ // another entry that was dequeued or evicted (without downstream
43
+ // invalidation), the spec is contaminated with the removed entry's
44
+ // changes. Reset so it rebuilds on the correct base next tick.
45
+ // Exclude "merged" — a merged dependency means main advanced to its
46
+ // spec, so our cumulative spec is still valid (speculative consistency).
47
+ if (entry.specBasedOn) {
48
+ const dep = ctx.store.getEntry(entry.specBasedOn);
49
+ if (!dep || dep.status === "dequeued" || dep.status === "evicted") {
50
+ emit(ctx, entry, "invalidated", {
51
+ detail: `dependency ${entry.specBasedOn} is ${dep?.status ?? "removed"}`,
52
+ });
53
+ await cleanupSpec(ctx, entry);
54
+ ctx.store.transition(entry.id, "preparing_head", {
55
+ ...CLEAN_CI, ...CLEAN_SPEC,
56
+ }, `stale dependency ${dep?.status ?? "removed"}`);
57
+ continue;
58
+ }
59
+ }
41
60
  const isHead = i === 0;
42
61
  const prevEntry = i > 0 ? ctx.store.getEntry(allActive[i - 1].id) ?? null : null;
43
62
  const phase = entry.status;
package/dist/service.d.ts CHANGED
@@ -67,6 +67,12 @@ export declare class MergeStewardService {
67
67
  updateHeadByPR(prNumber: number, headSha: string): void;
68
68
  /** Acknowledge an external merge (webhook: PR merged outside queue). */
69
69
  acknowledgeExternalMerge(prNumber: number): void;
70
+ /**
71
+ * Reset all active entries positioned after the given entry to preparing_head
72
+ * with clean spec/CI state. Prevents downstream specs that included the
73
+ * dequeued entry's changes from being merged to main.
74
+ */
75
+ private invalidateDownstreamOf;
70
76
  private runTick;
71
77
  private scheduleNextTick;
72
78
  private describeMainBroken;
package/dist/service.js CHANGED
@@ -127,6 +127,7 @@ export class MergeStewardService {
127
127
  if (!entry)
128
128
  return false;
129
129
  this.store.dequeue(entryId);
130
+ this.invalidateDownstreamOf(entry);
130
131
  this.logger.info({ entryId, prNumber: entry.prNumber }, "Entry dequeued");
131
132
  return true;
132
133
  }
@@ -252,6 +253,7 @@ export class MergeStewardService {
252
253
  const entry = this.store.getEntryByPR(this.config.repoId, prNumber);
253
254
  if (entry) {
254
255
  this.store.dequeue(entry.id);
256
+ this.invalidateDownstreamOf(entry);
255
257
  this.logger.info({ prNumber, entryId: entry.id }, "PR dequeued");
256
258
  }
257
259
  }
@@ -275,6 +277,29 @@ export class MergeStewardService {
275
277
  this.logger.info({ prNumber, entryId: entry.id }, "External merge acknowledged");
276
278
  }
277
279
  }
280
+ /**
281
+ * Reset all active entries positioned after the given entry to preparing_head
282
+ * with clean spec/CI state. Prevents downstream specs that included the
283
+ * dequeued entry's changes from being merged to main.
284
+ */
285
+ invalidateDownstreamOf(removedEntry) {
286
+ const allActive = this.store.listActive(this.config.repoId);
287
+ let invalidated = 0;
288
+ for (const downstream of allActive) {
289
+ if (downstream.position <= removedEntry.position)
290
+ continue;
291
+ if (TERMINAL_STATUSES.includes(downstream.status))
292
+ continue;
293
+ this.store.transition(downstream.id, "preparing_head", {
294
+ ciRunId: null, ciRetries: 0,
295
+ specBranch: null, specSha: null, specBasedOn: null,
296
+ }, `invalidated: entry ${removedEntry.id.slice(0, 8)} dequeued`);
297
+ invalidated++;
298
+ }
299
+ if (invalidated > 0) {
300
+ this.logger.info({ removedEntryId: removedEntry.id, invalidated }, "Invalidated downstream entries after dequeue");
301
+ }
302
+ }
278
303
  async runTick() {
279
304
  if (this.tickInProgress)
280
305
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {