/** * The privileged half of the verbs, wired to real commands (#126). * * This is the only module in the verb path that performs a mutation, and it runs * in the daemon. Push and pull-request creation are delegated to `gitops.ts` * rather than re-shelled here, so there is one implementation of "how does a * run's work reach the remote" — fast-forward only, no force path. The remaining * verbs shell `gh` through {@link credentialedEnv} for the same reason: one * named construction site, so its call sites are the audit. * * Kept apart from `server.ts` deliberately: every policy check above it is * testable against the {@link VerbActions} interface with no repository, no * network and no `gh` on PATH, which is what makes the adversarial cases cheap * enough to actually write. */ import { credentialedEnv, openRunPr, pushRunBranch, repoSlugFor, scrubUserinfo } from "../gitops.ts"; import { livingDaemon } from "../lifecycle.ts"; import { stateDir } from "../config.ts"; import { ensureMirror } from "../worktree.ts"; import { launchTransientUnit, readUpgradeJournal } from "../upgrade-journal.ts"; import { classifyUpgrade } from "../upgrade-verify.ts"; import { GraphqlBreaker, graphqlBreakerRefusal, isGraphqlSurface, isTransientServer5xx, prUrlParts, } from "../tracker/github.ts"; import type { ProjectConfig, ReleaseShape, RepoTarget } from "../types.ts"; import type { ActionOutcome, InstallExecution, ReleaseExecution, VerbActions } from "./server.ts"; /** * What this daemon can actually cut. * * The four GitHub-shaped ones, and deliberately not `package-publish` or * `deploy`: the daemon holds a GitHub credential and nothing else — no npm * token, no registry login, no deploy key — and that is deliberate. A shape * outside this list is refused by name in `server.ts` rather than * attempted and reported as a command failure, because "we tried and it did not * work" and "we were never able to do this" are different answers and only one * of them tells an operator to go and do it themselves. */ export const GITHUB_RELEASABLE_SHAPES: readonly ReleaseShape[] = [ "version-bump-pr", "git-tag", "git-push-tags", "github-release", ]; /** One command, its captured output, and no interpretation. */ export interface CommandRun { ok: boolean; stdout: string; stderr: string; } export type CommandRunner = ( argv: string[], opts: { cwd?: string; env?: Record }, ) => Promise; export type MirrorPreparer = (repo: RepoTarget, mirrorRoot: string) => Promise; type CommitOutcome = { ok: true; sha: string } | { ok: false; stderr: string }; type ReadOutcome = { ok: true; value: T } | { ok: false; stderr: string }; interface VersionPullRequest { url: string; headRefOid: string; baseRefOid: string; headRefName: string; baseRefName: string; files: { path: string }[]; } const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; const RELEASE_VERSION_TAG = new RegExp(`^v(${SEMVER.source.slice(1, -1)})$`); function versionFromTag(tag: string): string | undefined { return RELEASE_VERSION_TAG.exec(tag)?.[1]; } function compareSemver(left: string, right: string): number | undefined { const l = SEMVER.exec(left); const r = SEMVER.exec(right); if (l === null || r === null) return undefined; for (let index = 1; index <= 3; index += 1) { const a = BigInt(l[index] as string); const b = BigInt(r[index] as string); if (a !== b) return a < b ? -1 : 1; } const leftPre = l[4]?.split("."); const rightPre = r[4]?.split("."); if (leftPre === undefined || rightPre === undefined) { return leftPre === rightPre ? 0 : leftPre === undefined ? 1 : -1; } const count = Math.max(leftPre.length, rightPre.length); for (let index = 0; index < count; index += 1) { const a = leftPre[index]; const b = rightPre[index]; if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1; if (a === b) continue; const aNumeric = /^\d+$/.test(a); const bNumeric = /^\d+$/.test(b); if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1; if (aNumeric !== bNumeric) return aNumeric ? -1 : 1; return a < b ? -1 : 1; } return 0; } function objectFromJson(raw: string): Record | undefined { try { const parsed: unknown = JSON.parse(raw); return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record) : undefined; } catch { return undefined; } } function exactVersionChange( base: Record, head: Record, version: string, ): boolean { if (typeof base.version !== "string") return false; return JSON.stringify({ ...base, version }) === JSON.stringify(head); } function versionPullRequest(raw: unknown): VersionPullRequest | undefined { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined; const pr = raw as Record; const files = pr.files; if ( typeof pr.url !== "string" || !FULL_COMMIT.test(String(pr.headRefOid)) || !FULL_COMMIT.test(String(pr.baseRefOid)) || typeof pr.headRefName !== "string" || typeof pr.baseRefName !== "string" || !Array.isArray(files) || files.some( (file) => typeof file !== "object" || file === null || Array.isArray(file) || typeof (file as Record).path !== "string", ) ) { return undefined; } return { url: pr.url, headRefOid: String(pr.headRefOid).toLowerCase(), baseRefOid: String(pr.baseRefOid).toLowerCase(), headRefName: pr.headRefName, baseRefName: pr.baseRefName, files: files.map((file) => ({ path: String((file as Record).path) })), }; } const FULL_COMMIT = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i; const RELEASE_TAG_CONFIG = [ "-c", "user.name=conductor", "-c", "user.email=conductor@invalid", "-c", "tag.gpgSign=false", ]; function commitFrom(stdout: string): string | undefined { const value = stdout.trim().split(/\s+/, 1)[0]; return value !== undefined && FULL_COMMIT.test(value) ? value.toLowerCase() : undefined; } const spawnCommand: CommandRunner = async (argv, opts) => { const proc = Bun.spawn(argv, { // Always a closed stream, matching the tracker adapter: a command that does // not read stdin sees EOF rather than a pipe nobody ends. stdin: new Blob([""]), stdout: "pipe", stderr: "pipe", ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...(opts.env === undefined ? {} : { env: opts.env }), }); const [stdout, stderr, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); return { ok: code === 0 && !proc.signalCode, stdout, stderr }; }; function failed(run: CommandRun, argv: string[]): { ok: false; stderr: string } { return { ok: false, stderr: scrubUserinfo(run.stderr.trim() || `\`${argv.join(" ")}\` failed with no stderr`), }; } /** * The production {@link VerbActions}. * * `run` is injected so the release and tracker paths can be exercised without * a live repository; production callers never pass it. */ export function githubVerbActions( project: ProjectConfig, run: CommandRunner = spawnCommand, prepareMirror: MirrorPreparer = ensureMirror, graphqlBreaker?: GraphqlBreaker, ): VerbActions { const env = (): Record => credentialedEnv(); const runGh = async (argv: string[], cwd?: string): Promise<{ argv: string[]; result: CommandRun }> => { const full = ["gh", ...argv]; // The mutation surface rides the same GraphQL endpoint the tracker's // parent/closer checks ride in the pinned gh (2.86): `pr merge`, // `pr update-branch`, `pr edit`, `pr create`. When a transient 5xx has // already proven that surface unavailable — observed by admission or by a // sibling mutation this pass — a mutation must fast-fail WITHOUT spawning // `gh`, exactly like the tracker's candidate checks (#642 comment 4): // attempting a mutation into a known-broken surface is how one provider // outage defeats queue locking. REST-backed commands (`gh api repos/...`, // `gh release create`) keep running: the outage is GraphQL-only. if (graphqlBreaker !== undefined && isGraphqlSurface(argv) && graphqlBreaker.refused()) { return { argv: full, result: { ok: false, stdout: "", stderr: graphqlBreakerRefusal(graphqlBreaker.until) }, }; } const result = await run(full, { env: env(), ...(cwd === undefined ? {} : { cwd }) }); if (!result.ok && graphqlBreaker !== undefined && isGraphqlSurface(argv) && isTransientServer5xx(result.stderr)) { graphqlBreaker.open(); } return { argv: full, result }; }; const gh = async (argv: string[], cwd?: string): Promise => { const result = await runGh(argv, cwd); return result.result.ok ? { ok: true, detail: result.result.stdout.trim() || undefined } : failed(result.result, result.argv); }; const ghJson = async (argv: string[], fact: string): Promise> => { const result = await runGh(argv); if (!result.result.ok) return failed(result.result, result.argv); try { return { ok: true, value: JSON.parse(result.result.stdout) as T }; } catch { return { ok: false, stderr: `${fact} returned invalid JSON` }; } }; const readVersionDocument = async ( slug: string, path: string, ref: string, ): Promise }>> => { const encodedPath = path.split("/").map(encodeURIComponent).join("/"); const file = await ghJson<{ sha?: unknown; content?: unknown }>( ["api", `repos/${slug}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`], `${path} at ${ref}`, ); if (!file.ok) return file; if (typeof file.value.sha !== "string" || typeof file.value.content !== "string") { return { ok: false, stderr: `${path} at ${ref} returned no file SHA or content` }; } const document = objectFromJson(Buffer.from(file.value.content.replace(/\s/g, ""), "base64").toString("utf8")); return document === undefined ? { ok: false, stderr: `${path} at ${ref} is not a JSON object` } : { ok: true, value: { sha: file.value.sha, document } }; }; const git = async (mirror: string, args: string[]): Promise<{ argv: string[]; result: CommandRun }> => { const argv = ["git", "-C", mirror, ...args]; return { argv, result: await run(argv, { env: env() }) }; }; const readCommit = async (mirror: string, args: string[], fact: string): Promise => { const { argv, result } = await git(mirror, args); if (!result.ok) return failed(result, argv); const sha = commitFrom(result.stdout); return sha === undefined ? { ok: false, stderr: `${fact} returned no full commit SHA` } : { ok: true, sha }; }; const liveDefaultHead = (mirror: string, repo: RepoTarget): Promise => readCommit( mirror, ["ls-remote", "origin", `refs/heads/${repo.defaultBranch}`], `live ${repo.defaultBranch} lookup`, ); const remoteTagCommit = async ( mirror: string, tag: string, ): Promise<{ ok: true; sha: string | undefined } | { ok: false; stderr: string }> => { const directRef = `refs/tags/${tag}`; const peeledRef = `${directRef}^{}`; const { argv, result } = await git(mirror, ["ls-remote", "origin", directRef, peeledRef]); if (!result.ok) return failed(result, argv); if (result.stdout.trim() === "") return { ok: true, sha: undefined }; const refs = new Map( result.stdout .trim() .split("\n") .map((line) => line.trim().split(/\s+/, 2)) .filter((entry): entry is [string, string] => entry.length === 2) .map(([sha, ref]) => [ref, sha] as const), ); const sha = commitFrom(refs.get(peeledRef) ?? refs.get(directRef) ?? ""); return sha === undefined ? { ok: false, stderr: `remote tag ${tag} lookup returned no full commit SHA` } : { ok: true, sha }; }; /** * Resolve a caller-pinned release commit and prove it belongs on the released * branch (#695). * * Two questions, both fail-closed. `rev-parse ^{commit}` answers "does * this commit exist in the released repository at all" — an abbreviation is * expanded here, so what the tag receives is always a full id. `merge-base * --is-ancestor` answers the one that matters: a tag pointing at a commit * that is not on the released branch is a release of code nobody merged, and * that is the failure a decorative `sha` argument would create. The ancestor * check accepts the branch tip itself, which is the ordinary case. */ const pinnedReleaseCommit = async ( mirror: string, repo: RepoTarget, sha: string, ): Promise => { const resolved = await readCommit(mirror, ["rev-parse", `${sha}^{commit}`], `pinned commit ${sha}`); if (!resolved.ok) { return { ok: false, stderr: `refusing release: ${sha} is not a commit in ${repo.name}. ` + `Pass a commit that exists on ${repo.defaultBranch}.\n${resolved.stderr}`, }; } const branchRef = `refs/remotes/origin/${repo.defaultBranch}`; const reachable = await git(mirror, ["merge-base", "--is-ancestor", resolved.sha, branchRef]); if (!reachable.result.ok) { return { ok: false, stderr: `refusing release: ${resolved.sha} is not reachable from ${repo.name}'s ${repo.defaultBranch}. ` + "A tag must never point at a commit that is not on the released branch — check the sha, or " + "wait for the commit to land.", }; } return resolved; }; const releaseTargetMoved = (repo: RepoTarget, target: string, live: string): { ok: false; stderr: string } => ({ ok: false, stderr: `refusing release: target ${target} is behind live ${repo.defaultBranch} ${live}. ` + "Refresh the release status and retry so the tag contains every merged change.", }); const releaseMirror = async (repo: RepoTarget): Promise<{ ok: true; path: string } | { ok: false; stderr: string }> => { try { return { ok: true, path: await prepareMirror(repo, project.mirrorRoot) }; } catch (err) { return { ok: false, stderr: `could not refresh ${repo.name}'s release mirror: ${err instanceof Error ? err.message : String(err)}`, }; } }; const releaseVersionRefusal = async ( mirror: string, repo: RepoTarget, ref: string, tag: string, ): Promise<{ ok: true } | { ok: false; stderr: string }> => { const versionFile = repo.release?.versionFile; if (versionFile === undefined) return { ok: true }; const version = versionFromTag(tag); if (version === undefined) { return { ok: false, stderr: `${repo.name} requires a v tag because it declares ${versionFile}` }; } const shown = await git(mirror, ["show", `${ref}:${versionFile}`]); if (!shown.result.ok) return failed(shown.result, shown.argv); const document = objectFromJson(shown.result.stdout); if (document === undefined || typeof document.version !== "string") { return { ok: false, stderr: `${versionFile} at ${ref} has no top-level string version` }; } return document.version === version ? { ok: true } : { ok: false, stderr: `refusing ${tag}: live ${versionFile} declares ${document.version}, not ${version}. ` + "Land the mediated version-bump-pr first.", }; }; const validateVersionPr = async ( repo: RepoTarget, version: string, branch: string, raw: unknown, ): Promise> => { const pr = versionPullRequest(raw); const versionFile = repo.release?.versionFile; if (pr === undefined || versionFile === undefined) { return { ok: false, stderr: "release preparation lookup returned an invalid pull request" }; } if ( pr.headRefName !== branch || pr.baseRefName !== repo.defaultBranch || pr.files.length !== 1 || pr.files[0]?.path !== versionFile ) { return { ok: false, stderr: `refusing release preparation ${pr.url}: expected ${branch} -> ${repo.defaultBranch} ` + `with only ${versionFile} changed`, }; } const slug = repoSlugFor(repo); const [base, head] = await Promise.all([ readVersionDocument(slug, versionFile, pr.baseRefOid), readVersionDocument(slug, versionFile, pr.headRefOid), ]); if (!base.ok) return base; if (!head.ok) return head; if (!exactVersionChange(base.value.document, head.value.document, version)) { return { ok: false, stderr: `refusing release preparation ${pr.url}: ${versionFile} is not an exact version-only change to ${version}`, }; } return { ok: true, value: pr }; }; const prepareVersionBump = async (execution: ReleaseExecution, tag: string): Promise => { const versionFile = execution.repo.release?.versionFile; if (versionFile === undefined) { return { ok: false, stderr: `${execution.repo.name} declares no release.versionFile, so no version-bump-pr can be prepared`, }; } const version = versionFromTag(tag); if (version === undefined) { return { ok: false, stderr: `version-bump-pr needs a v tag, found ${JSON.stringify(tag)}` }; } const repo = execution.repo; const slug = repoSlugFor(repo); const branch = `conductor/release-${version}`; const refPath = `repos/${slug}/git/ref/heads/${encodeURIComponent(branch)}`; const live = await ghJson<{ object?: { sha?: unknown } }>( ["api", `repos/${slug}/git/ref/heads/${encodeURIComponent(repo.defaultBranch)}`], `live ${repo.defaultBranch}`, ); const liveSha = live.ok ? commitFrom(String(live.value.object?.sha ?? "")) : undefined; if (!live.ok) return live; if (liveSha === undefined) { return { ok: false, stderr: `live ${repo.defaultBranch} returned no full commit SHA` }; } const liveFile = await readVersionDocument(slug, versionFile, liveSha); if (!liveFile.ok) return liveFile; if (liveFile.value.document.version === version) { return { ok: true, sha: liveSha, detail: `${versionFile} already declares ${version} on ${repo.defaultBranch}`, }; } if (typeof liveFile.value.document.version !== "string") { return { ok: false, stderr: `${versionFile} has no top-level string version` }; } const order = compareSemver(version, liveFile.value.document.version); if (order === undefined) { return { ok: false, stderr: `${versionFile} declares non-semantic version ${JSON.stringify(liveFile.value.document.version)}`, }; } if (order <= 0) { return { ok: false, stderr: `refusing release preparation ${version}: it is not newer than live ` + `${versionFile} version ${liveFile.value.document.version}`, }; } const listed = await ghJson( [ "pr", "list", "--repo", slug, "--head", branch, "--state", "open", "--limit", "2", "--json", "url,headRefOid,baseRefOid,headRefName,baseRefName,files", ], `open release preparation for ${branch}`, ); if (!listed.ok) return listed; if (!Array.isArray(listed.value) || listed.value.length > 1) { return { ok: false, stderr: `release preparation lookup for ${branch} did not return one unambiguous list` }; } const existing = listed.value[0]; if (existing !== undefined) { const validated = await validateVersionPr(repo, version, branch, existing); return validated.ok ? { ok: true, sha: validated.value.headRefOid, detail: `reused exact version-only pull request ${validated.value.url}`, review: { prUrl: validated.value.url, created: false }, } : validated; } const branchRef = await runGh(["api", refPath, "--jq", ".object.sha"]); let branchSha: string | undefined; if (branchRef.result.ok) { branchSha = commitFrom(branchRef.result.stdout); if (branchSha === undefined) { return { ok: false, stderr: `${branch} returned no full commit SHA` }; } } else if (!/\b404\b|not found/i.test(branchRef.result.stderr)) { return failed(branchRef.result, branchRef.argv); } if (branchSha === undefined) { const created = await gh([ "api", "--method", "POST", `repos/${slug}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${liveSha}`, ]); if (!created.ok) return created; branchSha = liveSha; } const branchFile = await readVersionDocument(slug, versionFile, branchSha); if (!branchFile.ok) return branchFile; if (branchFile.value.document.version !== version) { if (branchSha !== liveSha || JSON.stringify(branchFile.value.document) !== JSON.stringify(liveFile.value.document)) { return { ok: false, stderr: `refusing to reuse ${branch}: it is not the live base and not an exact ${version} version bump`, }; } const content = Buffer.from( `${JSON.stringify({ ...liveFile.value.document, version }, null, 2)}\n`, "utf8", ).toString("base64"); const updated = await gh([ "api", "--method", "PUT", `repos/${slug}/contents/${versionFile.split("/").map(encodeURIComponent).join("/")}`, "-f", `message=chore: release ${repo.name} ${version}`, "-f", `content=${content}`, "-f", `sha=${branchFile.value.sha}`, "-f", `branch=${branch}`, ]); if (!updated.ok) return updated; } else if (!exactVersionChange(liveFile.value.document, branchFile.value.document, version)) { return { ok: false, stderr: `refusing to reuse ${branch}: ${versionFile} contains changes besides version ${version}` }; } const opened = await runGh([ "pr", "create", "--repo", slug, "--head", branch, "--base", repo.defaultBranch, "--title", `chore: release ${repo.name} ${version}`, "--body", `Prepare ${tag}. This PR changes only ${versionFile}; release remains blocked until it lands.`, ]); if (!opened.result.ok) return failed(opened.result, opened.argv); const prUrl = opened.result.stdout.trim(); if (!/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/.test(prUrl)) { return { ok: false, stderr: `gh pr create returned no pull request URL` }; } const head = await ghJson<{ object?: { sha?: unknown } }>(["api", refPath], branch); const headSha = head.ok ? commitFrom(String(head.value.object?.sha ?? "")) : undefined; if (!head.ok) return head; if (headSha === undefined) return { ok: false, stderr: `${branch} returned no full commit SHA after PR creation` }; return { ok: true, sha: headSha, detail: `opened exact version-only pull request ${prUrl}`, review: { prUrl, created: true }, }; }; return { releasableShapes: GITHUB_RELEASABLE_SHAPES, // Both live in `gitops.ts`, not here: they know the run's own repository // layout, and they are the two paths whose fast-forward-only rule must have // exactly one implementation. Re-shelling `git push` here would be a second // one, and the second one is where a `--force` eventually appears. push: (target) => pushRunBranch(project, target), createPr: (target, opts) => openRunPr(project, target, opts), updatePrBranch: async (prUrl) => { // The branch refresh rides the REST update-branch endpoint, not `gh pr // update-branch` (#655): `gh pr update-branch` is a GraphQL mutation, so // a selective GraphQL outage made an otherwise mergeable PR unrefreshable // even while REST answered. Both calls here are core REST, so they keep // running while the GraphQL breaker is open. const parts = prUrlParts(prUrl); if (parts === undefined) { return { ok: false, stderr: `could not parse ${prUrl} as a pull request URL` }; } const pulls = `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`; // The stale-head guard needs the exact head the update is bound to. It is // the last observed head of the branch refresh, so a concurrent push is // refused (422) rather than updating an unreviewed head. const head = await runGh(["api", pulls, "--jq", ".head.sha"]); if (!head.result.ok) return failed(head.result, head.argv); const headSha = commitFrom(head.result.stdout); if (headSha === undefined) { return { ok: false, stderr: `${prUrl} returned no full head commit SHA` }; } const updated = await gh([ "api", "--method", "PUT", `${pulls}/update-branch`, "-f", `expected_head_sha=${headSha}`, ]); if (!updated.ok) return updated; // The endpoint answers 202 Accepted and refreshes the branch // asynchronously, so the head observed for `expected_head_sha` is the // pre-update head. Producing it as `sha` would report a merge at a head // nobody observed; the caller re-reads PR and check state instead. return { ok: true, detail: `requested a base-branch update of ${prUrl} from its observed head ${headSha}; re-read PR and check state before merging`, }; }, // Closing a rejected PR is the one destructive half of an adjudication's // disposition (#876), and it is REST rather than `gh pr close` for the same // reason `updatePrBranch` is: a selective GraphQL outage must not leave a // rejected PR open, permanently blocking the issue's reimplementation. closePr: async (prUrl, comment) => { const parts = prUrlParts(prUrl); if (parts === undefined) { return { ok: false, stderr: `could not parse ${prUrl} as a pull request URL` }; } // The comment first, and only then the close: a closed PR still shows its // comments, but a close that succeeds while the explanation fails leaves a // PR nobody can account for. Ordering the durable explanation before the // irreversible act is the same rule the merge path follows. const said = await gh([ "api", "--method", "POST", `repos/${parts.owner}/${parts.repo}/issues/${parts.number}/comments`, "-f", `body=${comment}`, ]); if (!said.ok) return { ok: false, stderr: said.stderr }; return gh([ "api", "--method", "PATCH", `repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`, "-f", "state=closed", ]); }, updatePr: async (prUrl, fields) => { const argv = ["pr", "edit", prUrl]; if (fields.title !== undefined) argv.push("--title", fields.title); if (fields.body !== undefined) argv.push("--body", fields.body); const outcome = await gh(argv); return outcome.ok ? { ok: true } : { ok: false, stderr: outcome.stderr }; }, // `--match-head-commit` is the server-side half of the exact-head rule: the // daemon already re-read the head and refused a stale one, and this makes // GitHub refuse too if the branch moved in the milliseconds between. Belt // and braces on the one operation that cannot be undone. mergePr: (prUrl, headSha) => gh(["pr", "merge", prUrl, "--squash", "--match-head-commit", headSha]), release: async (execution: ReleaseExecution): Promise => { const slug = repoSlugFor(execution.repo); const tag = execution.tag; if (tag === undefined) { return { ok: false, stderr: `a ${execution.shape} needs a tag; none was given` }; } if (execution.shape === "version-bump-pr") { return prepareVersionBump(execution, tag); } if (execution.shape === "github-release") { if (execution.repo.release === undefined) { return gh(["release", "create", tag, "--repo", slug, "--generate-notes"]); } const prepared = await releaseMirror(execution.repo); if (!prepared.ok) return prepared; const remoteTag = await remoteTagCommit(prepared.path, tag); if (!remoteTag.ok) return remoteTag; if (remoteTag.sha === undefined) { return { ok: false, stderr: `refusing GitHub release ${tag}: push the reviewed tag first` }; } const versionReady = await releaseVersionRefusal(prepared.path, execution.repo, remoteTag.sha, tag); if (!versionReady.ok) return versionReady; return gh(["release", "create", tag, "--repo", slug, "--generate-notes"]); } // The two git shapes act on the project's serialized, freshly fetched // mirror. The live read on either side of tag creation closes the stale // mirror and branch-moved-during-release windows (#258); the push path // repeats the guard because a branch may move between the two verbs. const prepared = await releaseMirror(execution.repo); if (!prepared.ok) return prepared; const mirror = prepared.path; if (execution.shape === "git-tag") { // A pinned commit (#695) is the whole point of the argument: the tag // points where the caller says, so the branch moving afterwards — which // is what reaching a quiet release window requires — cannot strand it. // The moved-branch guards below exist to catch main shifting under an // implicit "current main" target; for an explicit commit they would // refuse exactly the case the pin was asked for, so they are skipped // and the reachability check is the invariant instead. const pinned = execution.sha === undefined ? undefined : await pinnedReleaseCommit(mirror, execution.repo, execution.sha); if (pinned !== undefined && !pinned.ok) return pinned; const target = pinned ?? (await readCommit( mirror, ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`], `refreshed ${execution.repo.defaultBranch}`, )); if (!target.ok) return target; if (pinned === undefined) { const liveBefore = await liveDefaultHead(mirror, execution.repo); if (!liveBefore.ok) return liveBefore; if (target.sha !== liveBefore.sha) return releaseTargetMoved(execution.repo, target.sha, liveBefore.sha); } const versionReady = await releaseVersionRefusal(mirror, execution.repo, target.sha, tag); if (!versionReady.ok) return versionReady; const localTag = await git(mirror, ["rev-parse", "-q", "--verify", `refs/tags/${tag}^{commit}`]); let previousSha: string | undefined; if (localTag.result.ok) { previousSha = commitFrom(localTag.result.stdout); if (previousSha === undefined) { return { ok: false, stderr: `tag ${tag} returned no full commit SHA` }; } const remoteTag = await remoteTagCommit(mirror, tag); if (!remoteTag.ok) return remoteTag; if (remoteTag.sha === target.sha) { return { ok: true, sha: target.sha, detail: `tag ${tag} already exists at ${target.sha} and is already on origin`, }; } if (remoteTag.sha !== undefined) { return { ok: false, stderr: `refusing release: ${tag} already exists on origin at ${remoteTag.sha}. ` + "A published tag is never re-cut or moved; choose a new tag name.", }; } } const repointed = previousSha !== undefined; // An unattended daemon cannot depend on a host-level Git identity or a // signing key with an interactive passphrase. const tagged = await git(mirror, [ ...RELEASE_TAG_CONFIG, "tag", ...(repointed ? ["-f"] : []), "-a", tag, "-m", `release ${tag}`, target.sha, ]); if (!tagged.result.ok) return failed(tagged.result, tagged.argv); // Only meaningful for an implicit target: a pinned tag is *expected* to // sit behind a branch that has moved on. const liveAfter = pinned === undefined ? await liveDefaultHead(mirror, execution.repo) : ({ ok: true, sha: target.sha } as const); if (!liveAfter.ok || liveAfter.sha !== target.sha) { const rollback = previousSha !== undefined ? await git(mirror, [ ...RELEASE_TAG_CONFIG, "tag", "-f", "-a", tag, "-m", `release ${tag}`, previousSha, ]) : await git(mirror, ["tag", "-d", tag]); const reason = liveAfter.ok ? releaseTargetMoved(execution.repo, target.sha, liveAfter.sha).stderr : `${repointed ? "re-pointed" : "created"} ${tag} at ${target.sha}, but could not verify the live default branch: ${liveAfter.stderr}`; const rollbackDetail = repointed ? rollback.result.ok ? ` The tag was restored to ${previousSha}.` : ` WARNING: the tag could not be restored to ${previousSha}: ${failed(rollback.result, rollback.argv).stderr}` : rollback.result.ok ? " The unpushed local tag was deleted." : ` WARNING: the unpushed local tag could not be deleted: ${failed(rollback.result, rollback.argv).stderr}`; return { ok: false, stderr: reason + rollbackDetail }; } return { ok: true, sha: target.sha, detail: repointed ? `re-pointed unpushed ${tag} from ${previousSha} to ${target.sha}` : `tagged ${tag} at ${target.sha}`, }; } const target = await readCommit(mirror, ["rev-parse", `refs/tags/${tag}^{commit}`], `tag ${tag}`); if (!target.ok) return target; let tagSha = target.sha; // A pinned push publishes the tag EXACTLY as it was cut (#695). Without // this the retarget-to-live-main path below would quietly move a pinned // tag onto whatever merged since — which is the same stranding, inverted: // the caller would get a published tag pointing somewhere it never asked // for. The pin is re-proven against the branch and against the local tag, // so a mismatch is a refusal rather than a silent move. const pinnedPush = execution.sha === undefined ? undefined : await pinnedReleaseCommit(mirror, execution.repo, execution.sha); if (pinnedPush !== undefined) { if (!pinnedPush.ok) return pinnedPush; if (pinnedPush.sha !== tagSha) { return { ok: false, stderr: `refusing release: ${tag} is at ${tagSha} locally, and you asked to publish it at ` + `${pinnedPush.sha}. A published tag is never force-moved; cut the tag you mean to push.`, }; } } const live = await liveDefaultHead(mirror, execution.repo); if (!live.ok) return live; const remoteTag = await remoteTagCommit(mirror, tag); if (!remoteTag.ok) return remoteTag; if (remoteTag.sha === tagSha) { return { ok: true, sha: tagSha, detail: `refs/tags/${tag} already on origin at ${tagSha}` }; } if (remoteTag.sha !== undefined) { return { ok: false, stderr: `refusing release: origin already has ${tag} at ${remoteTag.sha}, ` + `which differs from the local tag at ${tagSha}. ` + "A published tag is never force-moved; cut a new tag instead.", }; } const versionReady = await releaseVersionRefusal(mirror, execution.repo, live.sha, tag); if (!versionReady.ok) return versionReady; let oldSha: string | undefined; if (pinnedPush === undefined && tagSha !== live.sha) { const fresh = await readCommit( mirror, ["rev-parse", `refs/remotes/origin/${execution.repo.defaultBranch}^{commit}`], `refreshed ${execution.repo.defaultBranch}`, ); if (!fresh.ok) return fresh; if (fresh.sha !== live.sha) return releaseTargetMoved(execution.repo, fresh.sha, live.sha); oldSha = tagSha; const retagged = await git(mirror, [ ...RELEASE_TAG_CONFIG, "tag", "-f", "-a", tag, "-m", `release ${tag}`, live.sha, ]); if (!retagged.result.ok) return failed(retagged.result, retagged.argv); tagSha = live.sha; } const pushed = await git(mirror, ["push", "origin", `refs/tags/${tag}`]); return pushed.result.ok ? { ok: true, sha: tagSha, detail: oldSha === undefined ? `pushed refs/tags/${tag} at ${tagSha}` : `re-pointed unpushed ${tag} from ${oldSha} to ${tagSha}; pushed refs/tags/${tag} at ${tagSha}`, } : failed(pushed.result, pushed.argv); }, // The fleet-installs-itself request (#486). Two gates, then a spawn: // // 1. `npm view` must actually expose the version with a full gitHead — // "install it" may only name what npm can serve. Refused with npm's // own detail so the orchestrator learns why, not that something was // wrong. // 2. A live daemon must exist to verify the install on its first tick // after the restart. A stopped daemon can never come back to do that, // so the request is refused and the operator is pointed at the plain // `upgrade` command, whose own process is its witness. install: async (execution: InstallExecution): Promise => { if (livingDaemon() === undefined) { return { ok: false, stderr: "the dispatch daemon is not running, so nobody can verify this install after the restart " + "it performs. Run `omp-conductor upgrade` from a shell instead.", }; } // One install at a time: the journal's newest request is either closed or // in flight, and a second request while one is in flight would race it // for the same restarts. The refusal names the state so the caller knows // to wait for the verifier's outcome instead of retrying blind. const pending = classifyUpgrade(readUpgradeJournal(stateDir())); if (pending.kind !== "none" && pending.kind !== "closed") { return { ok: false, stderr: `an install is already in flight (journal verdict: ${pending.kind}); ` + "the fleet verifies or rolls it back before accepting another request.", }; } const looked = await run( ["npm", "view", `omp-conductor@${execution.version}`, "version", "gitHead", "--json"], { env: env() }, ); if (!looked.ok) { const detail = looked.stderr.trim() || looked.stdout.trim() || "the npm view command failed"; return { ok: false, stderr: `npm does not expose omp-conductor@${execution.version}: ${detail}. ` + "Only a published release with a full gitHead can be installed; the request was not started.", }; } const metadata = objectFromJson(looked.stdout); const published = metadata === undefined ? undefined : String(metadata["version"] ?? ""); const gitHead = metadata === undefined ? undefined : String(metadata["gitHead"] ?? ""); if (published !== execution.version || !/^[0-9a-f]{40}$/i.test(gitHead ?? "")) { return { ok: false, stderr: `npm does not expose omp-conductor@${execution.version} with a full gitHead ` + `(${published === undefined ? "unreadable metadata" : `saw ${published}`}); the request was not started.`, }; } const launched = await launchTransientUnit( async (argv) => { const ran = await run([...argv] as string[], { env: env() }); return { code: ran.ok ? 0 : 1, stdout: ran.stdout, stderr: ran.stderr }; }, env(), { kind: "install", version: execution.version, holder: execution.holder, projects: execution.projects, }, ); if (!launched.ok) return { ok: false, stderr: launched.stderr }; return { ok: true, detail: launched.unit }; }, }; }