From c07fed1c314696ec5ac2c8fa9c38d2cd91f12669 Mon Sep 17 00:00:00 2001 From: Scott Lowe Date: Thu, 1 Oct 2026 16:36:43 -0700 Subject: [PATCH] fix(promotion): commit the files of transitions that applied when a later one fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a promotion failed partway, the workflow's commit step staged only the UUID state, but the failing transition had already rewritten tracked files in its target org, so `git pull --rebase` refused and nothing was pushed — not the state, and not the files of transitions that had already reached the platform. - promote-cmd truncates tmp/promotion-applied.txt at the start of each --apply run and, after each successful apply.ts, records every path git reports changed under resources// (from git, not the plan: apply's own pull and push can rewrite other files) with its content at that moment, written to git's object store (`-` for a deletion). - On a non-success outcome, the commit step stages the state files plus exactly those recorded blobs, commits, then resets and cleans resources/ so the failed transition's rewrites can't block the rebase. Staging the recorded content rather than the working tree means a later failed transition that rewrote the same file (two pipelines promoting into one org in one --all run) can't replace what was applied. - promotionCommandRun(args, deps) takes an injectable child runner. - tests/promotion-workflow-commit.test.ts runs the workflow's real commit step (read from promotion.yml, run with bash -e) against a bare origin: partial failure, the same-org case, success, and no changes. Refs TEST-141 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/promotion.yml | 24 ++- improvements.md | 63 +++++++ src/promote-cmd.ts | 93 +++++++++- tests/promote-cmd.test.ts | 231 +++++++++++++++++++++++ tests/promotion-workflow-commit.test.ts | 233 ++++++++++++++++++++++++ 5 files changed, 635 insertions(+), 9 deletions(-) create mode 100644 tests/promote-cmd.test.ts create mode 100644 tests/promotion-workflow-commit.test.ts diff --git a/.github/workflows/promotion.yml b/.github/workflows/promotion.yml index 350f8f6..5f679cd 100644 --- a/.github/workflows/promotion.yml +++ b/.github/workflows/promotion.yml @@ -96,20 +96,36 @@ jobs: PROMOTION_OUTCOME: ${{ steps.promotion.outcome }} run: | set -euo pipefail - paths=(':(glob).vapi-state.*.json') + git add -A -- ':(glob).vapi-state.*.json' if [[ "$PROMOTION_OUTCOME" == "success" ]]; then - paths+=(resources) + git add -A -- resources + elif [[ -s tmp/promotion-applied.txt ]]; then + # A later transition failed. Stage each file exactly as it stood + # when its transition finished applying (promote-cmd stored that + # content in git's object store), so the failed transition's + # rewrites — even of the same files — are never committed. + while IFS=$'\t' read -r blob path; do + [[ -n "$path" ]] || continue + if [[ "$blob" == "-" ]]; then + git rm -q --cached --ignore-unmatch -- ":(literal)$path" + else + git update-index --add --cacheinfo "100644,$blob,$path" + fi + done < tmp/promotion-applied.txt fi - if [[ -z "$(git status --porcelain -- "${paths[@]}")" ]]; then + if git diff --cached --quiet; then echo "Promotion produced no Git changes." exit 0 fi git config user.name "vapi-gitops[bot]" git config user.email "vapi-gitops[bot]@users.noreply.github.com" - git add -A -- "${paths[@]}" git commit -m "chore: record promoted Vapi state [skip promotion]" + # Unstaged rewrites from a failed transition would make the rebase + # refuse to run, and then nothing would be pushed, state included. + git reset --hard HEAD + git clean -fd -- resources for attempt in 1 2 3; do git pull --rebase origin main if git push; then diff --git a/improvements.md b/improvements.md index bf41714..39810d2 100644 --- a/improvements.md +++ b/improvements.md @@ -86,6 +86,7 @@ you which stack PR closes the row.** | 32 | Test suite never ran in CI; 20 tests rotted after the hash store | Regression guards for #22/#23 silently stopped running | None | RESOLVED 2026-09-30 (#56) | | 33 | `npm run sim` reported every run as passed | A failing suite exited 0 — false green | None | RESOLVED 2026-10-01 | | 34 | No pre-merge simulation signal; simulations only tested what was deployed | A PR that breaks an agent merges green | #33 | RESOLVED 2026-10-01 | +| 35 | A failed promotion pushed nothing, not even state | git lost track of resources already on the platform | None | RESOLVED 2026-10-01 | **Active backlog after cleanup:** `#2`, `#6`, `#8`, `#12`, `#20`, `#24–#26`, `#31`, and the open remainder of `#27` (wiring the listing-completeness verdict into push/delete/audit, and moving `cleanup.ts` onto the shared pager). Resolved entries stay in this file as historical incident notes per the maintenance directive; stale superseded backlog rows are not duplicated. @@ -1822,6 +1823,68 @@ None needed once the fix below lands. --- +## 35. A failed promotion pushed nothing, not even state + +**[RESOLVED 2026-10-01]** + +**Discovered:** 2026-10-01, while planning the promotion check gate (TEST-141). + +### Problem + +When a multi-transition promotion failed partway, the workflow meant to +commit the UUID state and leave resource files alone. But the failing +transition had already rewritten tracked files in its target org, so the +commit step's `git pull --rebase` refused ("You have unstaged changes") and +the job pushed nothing: not the state, and not the files of the transitions +that had already reached the platform. + +### Current behavior (Verified, before the fix) + +- `.github/workflows/promotion.yml` "Commit reconciled files and UUID state" + staged only `.vapi-state.*.json` on a non-success outcome, then ran + `git pull --rebase origin main` with the failed transition's rewrites + still in the working tree. +- `promotionPlanApply` writes target files before `apply.ts` runs, so any + update to an existing target file left a tracked modification behind. +- Reproduced in a scratch repo: transitions a→b (applies) then b→c (fails + with an existing file in c) → `error: cannot pull with rebase`, exit 128, + origin unchanged. + +### Risk + +Git and the platform disagree after any partial failure: b's resources are +live but not in git, and the next promotion plans from stale files. + +### Current mitigation + +None needed once the fix below lands. + +### Possible fix (landed) + +- `src/promote-cmd.ts` truncates `tmp/promotion-applied.txt` at the start of + each `--apply` run and, after each successful `apply.ts`, records every path + `git status` reports under `resources//` — read from git rather + than the plan, because apply's own pull and push can rewrite other files — + together with its content at that moment, written to git's object store + (`git hash-object -w`; `-` for a deleted file). +- On a non-success outcome the commit step stages the state files plus + exactly those recorded blobs (`git update-index --cacheinfo`), commits, then + `git reset --hard HEAD && git clean -fd -- resources` before rebasing, so the + failed transition's rewrites can't block the push. Because it stages the + recorded content rather than the working tree, a later failed transition + that rewrote the same file (two pipelines promoting into one org in one + `--all` run) can't replace what was actually applied. +- `promotionCommandRun(args, deps)` takes an injectable child runner for + tests (`tests/promote-cmd.test.ts`). `tests/promotion-workflow-commit.test.ts` + runs the workflow's real commit step against a bare origin, including the + same-org case. + +### Status + +**RESOLVED 2026-10-01.** + +--- + ## Out of scope (intentionally not improvements) - **State file is identity-only and not git-ignored.** It's intentionally diff --git a/src/promote-cmd.ts b/src/promote-cmd.ts index 9cad13a..408046b 100644 --- a/src/promote-cmd.ts +++ b/src/promote-cmd.ts @@ -1,5 +1,6 @@ -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { OrgConnection } from "./org-connection.ts"; import { childRun, connectionLoad, tokensParse } from "./org-connection.ts"; @@ -31,6 +32,18 @@ const ROOT_DIR = resolve( process.env.VAPI_GITOPS_ROOT ?? fileURLToPath(new URL("..", import.meta.url)), ); +// Files that transitions which finished applying left changed, one +// `\t` line each: the content as it stood when that transition +// finished, stored in git's object store (`-` for a deleted file). When a +// later transition fails, the promotion workflow commits exactly these blobs +// (plus state) and discards everything else, so git records what reached the +// platform — even if the failed transition rewrote one of the same files. +export const APPLIED_PATHS_FILE = "tmp/promotion-applied.txt"; + +export interface PromotionDeps { + childRun: typeof orgScriptRun; +} + function argumentsParse(args: string[]): PromotionArguments { const parsed: PromotionArguments = { all: false, apply: false }; for (let index = 0; index < args.length; index++) { @@ -126,6 +139,66 @@ function orgScriptRun( childRun({ rootDir: ROOT_DIR, script, org, connection, args }); } +// The paths git reports changed under resources//, including new and +// deleted files. Read from git rather than the plan because apply's pull and +// push can rewrite files the plan didn't name. +function changedPathsRead(org: string): string[] { + const output = execFileSync( + "git", + [ + "status", + "--porcelain", + "-z", + "--untracked-files=all", + "--", + `resources/${org}`, + ], + { cwd: ROOT_DIR, encoding: "utf8" }, + ); + const entries = output.split("\0").filter(Boolean); + const paths: string[] = []; + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + paths.push(entry.slice(3)); + // A rename or copy is followed by its original path. + if (entry[0] === "R" || entry[0] === "C") index++; + } + return paths; +} + +// The content a path has right now, written to git's object store so it +// survives later rewrites; "-" when the path no longer exists. +function blobWrite(path: string): string { + if (!existsSync(resolve(ROOT_DIR, path))) return "-"; + return execFileSync("git", ["hash-object", "-w", "--", path], { + cwd: ROOT_DIR, + encoding: "utf8", + }).trim(); +} + +function appliedPathsRecord(org: string): void { + const file = resolve(ROOT_DIR, APPLIED_PATHS_FILE); + // path → blob; a later successful transition's snapshot replaces an + // earlier one for the same path. + const recorded = new Map(); + if (existsSync(file)) + for (const line of readFileSync(file, "utf8").split("\n")) { + const tab = line.indexOf("\t"); + if (tab > 0) recorded.set(line.slice(tab + 1), line.slice(0, tab)); + } + try { + for (const path of changedPathsRead(org)) + recorded.set(path, blobWrite(path)); + } catch (error) { + console.warn( + `⚠️ Could not record applied paths for ${org}: ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + const lines = [...recorded].map(([path, blob]) => `${blob}\t${path}`); + writeFileSync(file, lines.length > 0 ? `${lines.join("\n")}\n` : ""); +} + function stateLoad(org: string) { const path = resolve(ROOT_DIR, `.vapi-state.${org}.json`); if (!existsSync(path)) @@ -141,15 +214,17 @@ async function transitionRun( apply: boolean, tokens: Map, allowEmptySourceDeletion: boolean, + deps: PromotionDeps, ): Promise { + const run = deps.childRun; if (apply) { - orgScriptRun( + run( "src/pull.ts", transition.source, orgConnection(config, transition.source, tokens), ["--bootstrap", "--bindings-only"], ); - orgScriptRun( + run( "src/pull.ts", transition.target, orgConnection(config, transition.target, tokens), @@ -177,17 +252,19 @@ async function transitionRun( const changedPaths = plan.changes.map( (change) => `resources/${transition.target}/${change.path}`, ); - orgScriptRun( + run( "src/apply.ts", transition.target, orgConnection(config, transition.target, tokens), ["--force", "--allow-new-files", "--resolve=ours", ...changedPaths], ); + appliedPathsRecord(transition.target); return plan.changes.some((change) => change.kind === "delete"); } export async function promotionCommandRun( args = process.argv.slice(2), + deps: PromotionDeps = { childRun: orgScriptRun }, ): Promise { const parsed = argumentsParse(args); const configPath = resolve(ROOT_DIR, "promotion.yml"); @@ -198,6 +275,11 @@ export async function promotionCommandRun( ? tokensParse(TOKENS_ENV) : new Map(); delete process.env[TOKENS_ENV]; + if (parsed.apply) { + const file = resolve(ROOT_DIR, APPLIED_PATHS_FILE); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, ""); + } // Applying a deletion removes the intermediate org's state entry. Carry the // reviewed authorization forward so the same deletion can reach later orgs. const deletionAuthorizedSources = new Set(); @@ -209,6 +291,7 @@ export async function promotionCommandRun( parsed.apply, tokens, deletionAuthorizedSources.has(sourceKey), + deps, ); if (deleted) deletionAuthorizedSources.add( diff --git a/tests/promote-cmd.test.ts b/tests/promote-cmd.test.ts new file mode 100644 index 0000000..f9b1320 --- /dev/null +++ b/tests/promote-cmd.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; + +// promote-cmd.ts binds its root at import, so one fixture repo serves the +// file; each test resets it. +const ROOT = mkdtempSync(join(tmpdir(), "promote-cmd-")); +process.env.VAPI_GITOPS_ROOT = ROOT; +const { APPLIED_PATHS_FILE, promotionCommandRun } = + await import("../src/promote-cmd.ts"); + +function git(...args: string[]): string { + return execFileSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@example.com", ...args], + { cwd: ROOT, encoding: "utf8" }, + ); +} + +function write(path: string, content: string): void { + mkdirSync(dirname(join(ROOT, path)), { recursive: true }); + writeFileSync(join(ROOT, path), content); +} + +function fixtureReset(): void { + rmSync(ROOT, { recursive: true, force: true }); + mkdirSync(ROOT, { recursive: true }); + write( + "promotion.yml", + "version: 1\norgs:\n a: {}\n b: {}\n c: {}\npipelines:\n release:\n orgs: [a, b, c]\n resources: ['**/*']\n", + ); + write("resources/a/assistants/intake.yml", "name: Intake\n"); + for (const org of ["a", "b", "c"]) write(`.vapi-state.${org}.json`, "{}\n"); + write(".gitignore", "tmp/\n.env.*\n"); + git("init", "-q", "-b", "main"); + git("add", "-A"); + git("commit", "-qm", "base"); +} + +interface ChildCall { + script: string; + org: string; +} + +// Stands in for pull.ts and apply.ts: apply into `failOrg` fails, and every +// other apply also rewrites one file the plan didn't name, as apply's own +// pull can. +function childRunFake(calls: ChildCall[], failOrg?: string) { + return (script: string, org: string) => { + calls.push({ script, org }); + if (script !== "src/apply.ts") return; + if (org === failOrg) throw new Error(`${script} failed for ${org}`); + write(`resources/${org}/assistants/pulled-by-apply.yml`, "name: Pulled\n"); + }; +} + +// The recorded `\t` lines, as [path, blob]. +function appliedEntries(): Array<[string, string]> { + const file = join(ROOT, APPLIED_PATHS_FILE); + if (!existsSync(file)) return []; + return readFileSync(file, "utf8") + .split("\n") + .filter(Boolean) + .map((line) => { + const [blob, path] = line.split("\t"); + return [path!, blob!]; + }); +} + +function appliedPaths(): string[] { + return appliedEntries().map(([path]) => path); +} + +process.env.VAPI_PROMOTION_TOKENS = JSON.stringify({ a: "t", b: "t", c: "t" }); + +test("after one transition applies and the next fails, only the applied transition's files are recorded", async () => { + fixtureReset(); + process.env.VAPI_PROMOTION_TOKENS = JSON.stringify({ + a: "t", + b: "t", + c: "t", + }); + const calls: ChildCall[] = []; + const log = console.log; + console.log = () => {}; + let failure: unknown; + try { + await promotionCommandRun(["--all", "--apply"], { + childRun: childRunFake(calls, "c"), + }); + } catch (error) { + failure = error; + } finally { + console.log = log; + } + assert.deepEqual( + { + failure: (failure as Error | undefined)?.message, + applies: calls + .filter((c) => c.script === "src/apply.ts") + .map((c) => c.org), + recorded: appliedPaths(), + // The failed transition's rewrites are on disk but not recorded. + cRewritten: existsSync(join(ROOT, "resources/c/assistants/intake.yml")), + }, + { + failure: "src/apply.ts failed for c", + applies: ["b", "c"], + recorded: [ + "resources/b/assistants/intake.yml", + "resources/b/assistants/pulled-by-apply.yml", + ], + cRewritten: true, + }, + ); +}); + +test("each --apply run starts a fresh record; a plan-only run leaves it alone", async () => { + fixtureReset(); + write(APPLIED_PATHS_FILE, "-\tresources/stale/assistants/old.yml\n"); + const log = console.log; + console.log = () => {}; + try { + await promotionCommandRun(["--all"], { childRun: childRunFake([]) }); + const afterPlan = appliedPaths(); + process.env.VAPI_PROMOTION_TOKENS = JSON.stringify({ + a: "t", + b: "t", + c: "t", + }); + await promotionCommandRun( + ["--pipeline", "release", "--from", "a", "--to", "b", "--apply"], + { + childRun: childRunFake([]), + }, + ); + assert.deepEqual( + [afterPlan, appliedPaths()], + [ + ["resources/stale/assistants/old.yml"], + [ + "resources/b/assistants/intake.yml", + "resources/b/assistants/pulled-by-apply.yml", + ], + ], + ); + } finally { + console.log = log; + } +}); + +test("a deleted or renamed file is recorded by its current path", async () => { + fixtureReset(); + write("resources/b/assistants/old-name.yml", "name: Old\n"); + write("resources/b/assistants/gone.yml", "name: Gone\n"); + git("add", "-A"); + git("commit", "-qm", "b files"); + git( + "mv", + "resources/b/assistants/old-name.yml", + "resources/b/assistants/new-name.yml", + ); + rmSync(join(ROOT, "resources/b/assistants/gone.yml")); + process.env.VAPI_PROMOTION_TOKENS = JSON.stringify({ + a: "t", + b: "t", + c: "t", + }); + const log = console.log; + console.log = () => {}; + try { + await promotionCommandRun( + ["--pipeline", "release", "--from", "a", "--to", "b", "--apply"], + { + childRun: childRunFake([]), + }, + ); + } finally { + console.log = log; + } + assert.deepEqual(appliedPaths().sort(), [ + "resources/b/assistants/gone.yml", + "resources/b/assistants/intake.yml", + "resources/b/assistants/new-name.yml", + "resources/b/assistants/pulled-by-apply.yml", + ]); +}); + +test.after(() => rmSync(ROOT, { recursive: true, force: true })); + +test("each applied file is recorded with its content at apply time, and a deletion as -", async () => { + fixtureReset(); + write("resources/b/assistants/gone.yml", "name: Gone\n"); + git("add", "-A"); + git("commit", "-qm", "b files"); + process.env.VAPI_PROMOTION_TOKENS = JSON.stringify({ + a: "t", + b: "t", + c: "t", + }); + const log = console.log; + console.log = () => {}; + try { + await promotionCommandRun( + ["--pipeline", "release", "--from", "a", "--to", "b", "--apply"], + { childRun: childRunFake([]) }, + ); + } finally { + console.log = log; + } + // A later rewrite of the file doesn't change what was recorded. + write("resources/b/assistants/intake.yml", "name: Rewritten later\n"); + const entries = Object.fromEntries(appliedEntries()); + assert.deepEqual( + [ + git("cat-file", "-p", entries["resources/b/assistants/intake.yml"]!), + entries["resources/b/assistants/gone.yml"], + ], + ["name: Intake\n", "-"], + ); +}); diff --git a/tests/promotion-workflow-commit.test.ts b/tests/promotion-workflow-commit.test.ts new file mode 100644 index 0000000..765916d --- /dev/null +++ b/tests/promotion-workflow-commit.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +// Runs the promotion workflow's real "Commit reconciled files and UUID +// state" step (read from .github/workflows/promotion.yml, run with bash -e) +// against a clone of a bare origin, after a promotion driven through +// promote-cmd with a fake child runner. An edit to the step that brings back +// "a failed promotion pushes nothing" — or commits a failed transition's +// rewrite — fails here. + +const REPO = fileURLToPath(new URL("..", import.meta.url)); +const WORK = mkdtempSync(join(tmpdir(), "promotion-workflow-")); +const ORIGIN = join(WORK, "origin.git"); +const CLONE = join(WORK, "clone"); +// promote-cmd.ts binds its root at import. +process.env.VAPI_GITOPS_ROOT = CLONE; +const { promotionCommandRun } = await import("../src/promote-cmd.ts"); + +interface Workflow { + jobs: { promote: { steps: Array<{ name?: string; run?: string }> } }; +} + +const COMMIT_STEP = ( + parseYaml( + readFileSync(join(REPO, ".github/workflows/promotion.yml"), "utf8"), + ) as Workflow +).jobs.promote.steps.find( + (step) => step.name === "Commit reconciled files and UUID state", +)!.run!; + +function git(cwd: string, ...args: string[]): string { + return execFileSync( + "git", + ["-c", "user.name=t", "-c", "user.email=t@example.com", ...args], + { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); +} + +function write(path: string, content: string): void { + mkdirSync(dirname(join(CLONE, path)), { recursive: true }); + writeFileSync(join(CLONE, path), content); +} + +// A fresh bare origin and clone holding `files`, with every org's state. +function repoReset( + orgs: string[], + pipelines: Record, + files: Record, +): void { + rmSync(WORK, { recursive: true, force: true }); + mkdirSync(WORK, { recursive: true }); + execFileSync("git", ["init", "-q", "--bare", "-b", "main", ORIGIN]); + execFileSync("git", ["clone", "-q", ORIGIN, CLONE], { stdio: "ignore" }); + const pipelineYaml = Object.entries(pipelines) + .map( + ([name, list]) => + ` ${name}:\n orgs: [${list.join(", ")}]\n resources: ['**/*']`, + ) + .join("\n"); + write( + "promotion.yml", + `version: 1\norgs:\n${orgs.map((org) => ` ${org}: {}`).join("\n")}\npipelines:\n${pipelineYaml}\n`, + ); + for (const org of orgs) write(`.vapi-state.${org}.json`, "{}\n"); + write(".gitignore", "tmp/\n.env.*\n"); + for (const [path, content] of Object.entries(files)) write(path, content); + git(CLONE, "add", "-A"); + git(CLONE, "commit", "-qm", "base"); + git(CLONE, "push", "-q", "origin", "main"); +} + +// promote --all --apply: every apply writes its org's state (as apply.ts +// does), and the `failAt`-th apply (1-based) then fails. +async function promote(failAt?: number): Promise { + let applies = 0; + process.env.VAPI_PROMOTION_TOKENS = '{"a":"t","b":"t","c":"t"}'; + const log = console.log; + console.log = () => {}; + try { + await promotionCommandRun(["--all", "--apply"], { + childRun: (script: string, org: string) => { + if (script !== "src/apply.ts") return; + write( + `.vapi-state.${org}.json`, + `{"assistants":{"intake":{"uuid":"uuid-${org}"}}}\n`, + ); + if (++applies === failAt) + throw new Error(`${script} failed for ${org}`); + }, + }); + return undefined; + } catch (error) { + return (error as Error).message; + } finally { + console.log = log; + } +} + +function commitStep(outcome: "success" | "failure"): { + code: number | null; + output: string; +} { + const result = spawnSync("bash", ["-e", "-c", COMMIT_STEP], { + cwd: CLONE, + encoding: "utf8", + env: { ...process.env, PROMOTION_OUTCOME: outcome }, + }); + return { code: result.status, output: `${result.stdout}${result.stderr}` }; +} + +function originFile(path: string): string | undefined { + try { + return git(ORIGIN, "show", `main:${path}`); + } catch { + return undefined; + } +} + +const INTAKE = (who: string) => `name: Intake (${who})\n`; + +test("a failed promotion still pushes state and the files of transitions that applied, not the failed rewrite", async () => { + repoReset( + ["a", "b", "c"], + { release: ["a", "b", "c"] }, + { + "resources/a/assistants/intake.yml": INTAKE("a"), + // c already tracks a file, so the failed b → c rewrite leaves a tracked + // modification behind — what made the rebase refuse before. + "resources/c/assistants/intake.yml": INTAKE("old c"), + }, + ); + const failure = await promote(2); + const step = commitStep("failure"); + assert.deepEqual( + { + failure, + code: step.code, + b: originFile("resources/b/assistants/intake.yml"), + c: originFile("resources/c/assistants/intake.yml"), + bState: originFile(".vapi-state.b.json"), + cState: originFile(".vapi-state.c.json"), + }, + { + failure: "src/apply.ts failed for c", + code: 0, + b: INTAKE("a"), + c: INTAKE("old c"), + bState: '{"assistants":{"intake":{"uuid":"uuid-b"}}}\n', + cState: '{"assistants":{"intake":{"uuid":"uuid-c"}}}\n', + }, + ); +}); + +test("when a later transition into the same org fails, the earlier transition's content is what gets committed", async () => { + // p1: a → c applies; p2: b → c rewrites the same file, then fails. + repoReset( + ["a", "b", "c"], + { p1: ["a", "c"], p2: ["b", "c"] }, + { + "resources/a/assistants/intake.yml": INTAKE("a"), + "resources/b/assistants/intake.yml": INTAKE("b"), + }, + ); + const failure = await promote(2); + const step = commitStep("failure"); + assert.deepEqual( + [failure, step.code, originFile("resources/c/assistants/intake.yml")], + ["src/apply.ts failed for c", 0, INTAKE("a")], + ); +}); + +test("a successful promotion commits every resource change and the state", async () => { + repoReset( + ["a", "b"], + { release: ["a", "b"] }, + { + "resources/a/assistants/intake.yml": INTAKE("a"), + }, + ); + const failure = await promote(); + const step = commitStep("success"); + assert.deepEqual( + [ + failure, + step.code, + originFile("resources/b/assistants/intake.yml"), + originFile(".vapi-state.b.json"), + ], + [ + undefined, + 0, + INTAKE("a"), + '{"assistants":{"intake":{"uuid":"uuid-b"}}}\n', + ], + ); +}); + +test("a promotion that changed nothing pushes nothing", async () => { + repoReset( + ["a", "b"], + { release: ["a", "b"] }, + { + "resources/a/assistants/intake.yml": INTAKE("a"), + "resources/b/assistants/intake.yml": INTAKE("a"), + }, + ); + const head = git(ORIGIN, "rev-parse", "main"); + const failure = await promote(); + const step = commitStep("success"); + assert.deepEqual( + [ + failure, + step.code, + step.output.includes("Promotion produced no Git changes."), + git(ORIGIN, "rev-parse", "main"), + ], + [undefined, 0, true, head], + ); +}); + +test.after(() => rmSync(WORK, { recursive: true, force: true }));