Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions .github/workflows/promotion.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions improvements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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/<target>/` — 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
Expand Down
93 changes: 88 additions & 5 deletions src/promote-cmd.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
// `<blob>\t<path>` 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++) {
Expand Down Expand Up @@ -126,6 +139,66 @@ function orgScriptRun(
childRun({ rootDir: ROOT_DIR, script, org, connection, args });
}

// The paths git reports changed under resources/<org>/, 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<string, string>();
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))
Expand All @@ -141,15 +214,17 @@ async function transitionRun(
apply: boolean,
tokens: Map<string, string>,
allowEmptySourceDeletion: boolean,
deps: PromotionDeps,
): Promise<boolean> {
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),
Expand Down Expand Up @@ -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<void> {
const parsed = argumentsParse(args);
const configPath = resolve(ROOT_DIR, "promotion.yml");
Expand All @@ -198,6 +275,11 @@ export async function promotionCommandRun(
? tokensParse(TOKENS_ENV)
: new Map<string, string>();
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<string>();
Expand All @@ -209,6 +291,7 @@ export async function promotionCommandRun(
parsed.apply,
tokens,
deletionAuthorizedSources.has(sourceKey),
deps,
);
if (deleted)
deletionAuthorizedSources.add(
Expand Down
Loading
Loading