Approve and deliver
Approve the exact reviewed commit, publish its pull request, and track completion.
Publication is a separate workflow. It loads the issue’s record and asks a person to approve the verified commit. Approval permits pushing that commit and opening or updating its pull request.
Record the decision
Create .foundry/marbles/_factory/approve.ts:
Complete approve.ts
import { step, workflow } from "@foundry.sh/marbles";
import { publish } from "./publish";
import {
chronicle,
issueInput,
recordEvent,
recordSchema,
requireRecord,
workInput,
} from "./record";
const load = step("factory-load")
.input(issueInput)
.output(recordSchema)
.do(({ input }) => requireRecord(input.issue));
const approve = step("factory-approve")
.input(workInput)
.output(recordSchema)
.do(async ({ input: { work }, ask, run }) => {
if (
work.status === "approved" &&
work.decision?.approved &&
work.change?.revision === work.decision.revision &&
work.verification?.revision === work.decision.revision
) {
return work;
}
if (
work.status !== "verified" ||
!work.change ||
work.verification?.revision !== work.change.revision
) {
throw new Error(
"Verify the current candidate before requesting publication."
);
}
const decision = await ask.approval({
body: `${chronicle(work)}\n\nApproval permits pushing this commit and opening or updating its pull request.`,
title: `Publish ${work.issue.number} at ${work.change.revision}?`,
});
return recordEvent(
{
...work,
decision: {
approved: decision.approved,
note: decision.note ?? "",
revision: work.change.revision,
},
status: decision.approved ? "approved" : "declined",
},
run.id,
"publication decision",
`${decision.approved ? "Approved" : "Declined"}: ${decision.note ?? ""}`
);
});
workflow("factory-deliver")
.input(issueInput)
.do(({ input }) => publish({ work: approve({ work: load({}, input) }) }));
The ask happens before the decision write. If the run parks, Marbles can replay that step with the recorded answer. Earlier implementation and verification results stay in the issue’s artifact.
Publish the candidate
Create .foundry/marbles/_factory/publish.ts:
Complete publish.ts
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { step } from "@foundry.sh/marbles";
import { z } from "zod";
import {
baseBranch,
chronicle,
recordEvent,
recordSchema,
repository,
workInput,
} from "./record";
const prSchema = z.object({
headRefOid: z.string(),
number: z.number(),
state: z.string(),
url: z.string(),
});
export const publish = step("factory-publish")
.input(workInput)
.output(recordSchema)
.do(async ({ input: { work }, exec, report, run }) => {
if (work.status === "declined") {
return work;
}
const { change } = work;
if (
work.status !== "approved" ||
!change ||
!work.decision?.approved ||
work.decision.revision !== change.revision ||
work.verification?.revision !== change.revision
) {
throw new Error(
"Publication requires approval and verification of this exact commit."
);
}
const head = (
await exec(["git", "rev-parse", `refs/heads/${change.branch}`])
).stdout.trim();
if (head !== change.revision) {
throw new Error("The branch moved after verification.");
}
const listed = await exec([
"gh",
"pr",
"list",
"--repo",
repository,
"--head",
change.branch,
"--base",
baseBranch,
"--state",
"all",
"--json",
"number,url,state,headRefOid",
"--limit",
"2",
]);
const prs = z.array(prSchema).parse(JSON.parse(listed.stdout));
if (prs.length > 1 || prs.some((pr) => pr.state !== "OPEN")) {
throw new Error("Reconcile the existing pull request before publishing.");
}
const folder = await mkdtemp(join(tmpdir(), "factory-pr-"));
try {
const body = join(folder, "body.md");
await writeFile(
body,
`Closes #${work.issue.number}\n\n${chronicle(work)}`
);
await exec([
"git",
"push",
"origin",
`${change.revision}:refs/heads/${change.branch}`,
]);
const [existing] = prs;
if (existing) {
await exec([
"gh",
"pr",
"edit",
String(existing.number),
"--repo",
repository,
"--body-file",
body,
]);
} else {
await exec([
"gh",
"pr",
"create",
"--repo",
repository,
"--head",
change.branch,
"--base",
baseBranch,
"--title",
work.issue.title,
"--body-file",
body,
]);
}
const response = await exec([
"gh",
"pr",
"view",
change.branch,
"--repo",
repository,
"--json",
"number,url,state,headRefOid",
]);
const pr = prSchema.parse(JSON.parse(response.stdout));
if (pr.headRefOid !== change.revision) {
throw new Error("The remote head does not match the approved commit.");
}
const next = await recordEvent(
{
...work,
pullRequest: {
number: pr.number,
revision: change.revision,
url: pr.url,
},
status: "pr-open",
},
run.id,
"pull request",
`${pr.url}\nCommit: ${change.revision}`
);
report.result({
body: pr.url,
title: `Issue #${work.issue.number} ready for repository review`,
});
return next;
} catch (error) {
await recordEvent(work, run.id, "publication interrupted", String(error));
throw error;
} finally {
await rm(folder, { force: true, recursive: true });
}
});
The step checks the local branch, pushes the approved SHA, and reuses an
existing open PR for that branch. It records the returned URL and checks
that the remote head matches. A partial failure can leave a push or PR
completed before the log write. Inspect it before continuing.
If the record still says approved and its commit is unchanged, rerun
factory-deliver to finish publication using the recorded decision.
The PR body links the issue and carries the work log. Review it before publishing if the log contains information that should stay local. GitHub’s PR command documents issue-closing references and explicit base and head branches.
Observe merge and closure
Create .foundry/marbles/_factory/status.ts:
Complete status.ts
import { step } from "@foundry.sh/marbles";
import { z } from "zod";
import {
chronicle,
issueInput,
recordEvent,
repository,
requireRecord,
} from "./record";
export const status = step("factory-status")
.input(issueInput.extend({ quiet: z.boolean().default(false) }))
.do(async ({ input, exec, report, run }) => {
let work = await requireRecord(input.issue);
if (work.pullRequest) {
const response = await exec([
"gh",
"pr",
"view",
String(work.pullRequest.number),
"--repo",
repository,
"--json",
"state,headRefOid,mergeCommit,mergedAt",
]);
const pr = z
.object({
headRefOid: z.string(),
mergeCommit: z.object({ oid: z.string() }).nullable(),
mergedAt: z.string().nullable(),
state: z.enum(["OPEN", "CLOSED", "MERGED"]),
})
.parse(JSON.parse(response.stdout));
const issue = await exec([
"gh",
"issue",
"view",
String(input.issue),
"--repo",
repository,
"--json",
"state",
]);
const issueState = z
.object({ state: z.string() })
.parse(JSON.parse(issue.stdout)).state;
const matches = pr.headRefOid === work.pullRequest.revision;
const currentCandidate = pr.headRefOid === work.change?.revision;
let nextStatus = work.status;
if (pr.state === "CLOSED" || !matches) {
nextStatus = "blocked";
}
if (pr.state === "MERGED" && matches && currentCandidate) {
nextStatus = issueState === "CLOSED" ? "closed" : "merged";
}
if (pr.state === "MERGED" && !currentCandidate) {
nextStatus = "blocked";
}
if (nextStatus !== work.status) {
work = await recordEvent(
{ ...work, status: nextStatus },
run.id,
"delivery observed",
JSON.stringify(
{ currentCandidate, issueState, pr, publishedHeadMatches: matches },
null,
2
)
);
}
}
if (!input.quiet) {
report.result({
body: chronicle(work),
title: `Issue #${input.issue}: ${work.status}`,
});
}
return work;
});
Then create .foundry/marbles/delivery.ts:
import "./_factory/approve";
import "./_factory/status";
Open marbles, start the dashboard, and launch factory-deliver with
{"issue":42}. Read the evidence and answer the publication question.
Use the dashboard because roll cannot answer it.
Repository review, CI, branch protection, and merge still happen through your normal GitHub process. Afterward, run:
marbles roll factory-status --input '{"issue":42}'
The status step reads the PR’s state and head and the issue’s state. It marks the record closed only after observing a merge of the reviewed head and a closed issue. A moved head requires fresh verification. A rejected candidate or closed, unmerged PR is not delivery.
For ongoing maintenance, continue with Review existing pull requests or Run an on-call shift.