/**
* std/bump/live — the live [`BumpAdapter`] that binds std/bump/runtime's pure
* state machine to real effects: git and the caller's validation entrypoint via
* `std/command`, and GitHub through the `gh` CLI (also via `std/command`).
*
* GitHub access goes through `gh api` rather than `std/connectors/github`: the
* stdlib connector's `connector_call("github", …)` requires an ACTIVE connector
* client, which is only installed in hosted/orchestrator runtimes — in the plain
* `harn run` the reusable bump workflow uses, no github connector is active, so
* every connector call throws `connector 'github' is not active`. `gh` is
* present and works in that runtime, matching how the rest of the fleet
* (`lib/github`) reaches GitHub.
*
* This module is deliberately branch-free. It performs the I/O the orchestrator
* asks for and normalizes the result; ALL control flow lives in
* `std/bump/runtime`, which is exercised by the in-process fixtures. That seam
* is why the fixtures need no network: they replace this adapter wholesale, so
* none of the `gh` calls below run under `harn test`. The live GitHub-mutation
* surface here (ref lease reset, signed `createCommitOnBranch`, PR upsert,
* auto-merge arm/disarm) is first exercised against real GitHub in CI.
*
* Auth is the GitHub App installation token, passed to `gh` via GH_TOKEN (see
* `__gh_spec`). Signed commits use the GraphQL `createCommitOnBranch` mutation
* rather than a local `git commit` + push: commits created through the App token
* are signed by GitHub, satisfying an org `required_signatures` ruleset that an
* unsigned local push would violate.
*/
import { BumpAdapter } from "std/bump/runtime"
import { command_run } from "std/command"
import { merge } from "std/json"
import { poll_until } from "std/poll"
import { is_v_semver } from "std/semver"
/** Everything the live adapter needs to reach git, GitHub, and validation. */
pub type LiveBumpConfig = {
repo: string,
release_repo: string,
token: string,
branch: string,
base: string,
version_file: string,
refresh_command: string,
validation_command: string,
readiness_max_attempts: int,
readiness_interval_ms: int,
readiness_timeout_ms: int,
}
/** Release finalization sidecars that prove a Harn release is fully published. */
const REQUIRED_RELEASE_ASSETS = ["SHA256SUMS", "release-assets.json"]
/**
* Fill a caller's partial config with the canonical defaults used by the
* reusable workflow.
*
* @effects: []
* @errors: []
*/
pub fn live_bump_config(overrides: dict = {}) -> LiveBumpConfig {
const o = overrides ?? {}
return {
repo: to_string(o.repo ?? ""),
// Where the target's releases are published. Defaults to the consumer repo
// itself (same-repo bump semantics); the Harn runtime bump overrides this
// with burin-labs/harn, because readiness must gate on the HARN release,
// not on releases of the repo being bumped.
release_repo: to_string(o.release_repo ?? to_string(o.repo ?? "")),
token: to_string(o.token ?? ""),
branch: to_string(o.branch ?? "automation/bump-harn-runtime"),
base: to_string(o.base ?? "main"),
version_file: to_string(o.version_file ?? ".harn-version"),
refresh_command: to_string(o.refresh_command ?? "harn install --locked || harn install"),
validation_command: to_string(o.validation_command ?? ""),
readiness_max_attempts: to_int(o.readiness_max_attempts ?? 1) ?? 1,
readiness_interval_ms: to_int(o.readiness_interval_ms ?? 20000) ?? 20000,
readiness_timeout_ms: to_int(o.readiness_timeout_ms ?? 20000) ?? 20000,
}
}
/**
* GitHub App installation token used to authenticate the `gh` CLI. Set once by
* the live adapter builder. The bump runs one repo per process, so a
* module-level value needs no per-call threading.
*/
let __bump_gh_token = ""
/**
* Build a command_run spec for a `gh` invocation. GH_TOKEN is patched INTO the
* inherited environment (env_mode "patch") so gh keeps PATH/HOME/its config —
* the default env_mode is "replace", which would wipe them.
*/
fn __gh_spec(argv: list<string>, stdin_body = nil) {
let spec = {argv: argv}
if trim(__bump_gh_token) != "" {
spec = merge(spec, {env: {GH_TOKEN: __bump_gh_token}, env_mode: "patch"})
}
if stdin_body != nil {
spec = merge(spec, {stdin: stdin_body})
}
return spec
}
fn __json_or_nil(text) {
const t = trim(to_string(text ?? ""))
if t == "" {
return nil
}
const parsed = try {
json_parse(t)
}
return if is_err(parsed) {
nil
} else {
parsed
}
}
fn __git(argv: list<string>) {
return command_run(argv)
}
fn __shell(command: string) {
return command_run({mode: "shell", command: command})
}
/**
* One REST call through the `gh` CLI, authenticated by the App token in
* GH_TOKEN. Returns the parsed JSON body, or nil on any non-zero exit —
* preserving the previous adapter's fail-soft contract. Unlike
* std/connectors/github (whose connector client is NOT active in a plain
* `harn run`), `gh` works in the bump workflow's runtime.
*/
fn __rest(method: string, path: string, body = nil) {
const spec = if body == nil {
__gh_spec(["gh", "api", path, "-X", method])
} else {
__gh_spec(["gh", "api", path, "-X", method, "--input", "-"], json_stringify(body))
}
const result = command_run(spec)
return if result.success ?? false {
__json_or_nil(result.stdout ?? "")
} else {
nil
}
}
/** One GraphQL call through `gh api /graphql`; nil on failure. */
fn __graphql(query: string, variables: dict) {
const spec = __gh_spec(
["gh", "api", "/graphql", "-X", "POST", "--input", "-"],
json_stringify({query: query, variables: variables}),
)
const result = command_run(spec)
return if result.success ?? false {
__json_or_nil(result.stdout ?? "")
} else {
nil
}
}
/** Release lookup normalized to the `{ok, ...release}` shape the adapter reads. */
fn __release_view(repo: string, tag: string) {
const release = __rest("GET", "/repos/${repo}/releases/tags/${tag}")
return if release == nil {
{ok: false}
} else {
merge(release, {ok: true})
}
}
/** Close a PR (commenting first when a comment is given); `{ok}` mirrors the old helper. */
fn __close_pr(repo: string, number, comment) {
if trim(to_string(comment ?? "")) != "" {
__rest("POST", "/repos/${repo}/issues/${number}/comments", {body: comment})
}
const closed = __rest("PATCH", "/repos/${repo}/pulls/${number}", {state: "closed"})
return {ok: closed != nil}
}
/** Arm squash auto-merge via GraphQL; `{ok, state, error}` mirrors the old helper. */
fn __enable_auto_merge(repo: string, number) {
const pr = __rest("GET", "/repos/${repo}/pulls/${number}")
const node_id = to_string(pr?.node_id ?? "")
if node_id == "" {
return {ok: false, state: "", error: "could not resolve PR node id"}
}
const mutation =
"mutation($id: ID!) { enablePullRequestAutoMerge(input: {pullRequestId: $id, mergeMethod: SQUASH}) { pullRequest { autoMergeRequest { enabledAt } } } }"
const result = __graphql(mutation, {id: node_id})
const enabled = result?.data?.enablePullRequestAutoMerge?.pullRequest?.autoMergeRequest?.enabledAt
!= nil
return {
ok: enabled,
state: if enabled {
"armed"
} else {
""
},
error: if enabled {
nil
} else {
result?.errors ?? "auto-merge not enabled"
},
}
}
fn __release_finalized(release) -> bool {
const draft = release?.draft ?? true
const prerelease = release?.prerelease ?? true
const asset_names = release?.asset_names ?? (release?.assets ?? []).map({ a -> a.name }).to_list()
const missing = REQUIRED_RELEASE_ASSETS.filter({ name -> !contains(asset_names, name) }).to_list()
return !draft && !prerelease && len(missing) == 0
}
/** Fetch the open bump PR via the REST list endpoint (there is no typed helper). */
fn __find_open_pr(cfg: LiveBumpConfig, owner: string) {
const list = __rest(
"GET",
"/repos/${cfg.repo}/pulls?head=${owner}:${cfg.branch}&base=${cfg.base}&state=open",
)
return (list ?? [])[0]
}
/**
* Build the live BumpAdapter for one repository and token. Pass the result
* straight to `run_bump`.
*
* @effects: [net, process]
* @errors: []
*/
pub fn live_bump_adapter(config: dict = {}) -> BumpAdapter {
const cfg = live_bump_config(config)
const owner = split(cfg.repo, "/")[0] ?? ""
// Authenticate every `gh` CLI call as the GitHub App for this run.
__bump_gh_token = cfg.token
return {
resolve_target_tag: fn(tag) {
const requested = trim(tag ?? "")
if requested != "" {
if !is_v_semver(requested) {
throw "std/bump/live: --tag must look like vX.Y.Z, got '${requested}'"
}
return requested
}
const latest = __rest("GET", "/repos/${cfg.release_repo}/releases/latest")
const resolved = trim(to_string(latest?.tag_name ?? ""))
if !is_v_semver(resolved) {
throw "std/bump/live: could not resolve a semver latest release for ${cfg.release_repo}"
}
return resolved
},
release_ready: fn(target) {
let attempts = 0
let lines = []
const found = poll_until(
{ ->
attempts = attempts + 1
const view = __release_view(cfg.release_repo, target)
const ready = view.ok && __release_finalized(view)
lines = lines + ["attempt ${attempts}: ${target} finalized=${ready}"]
return if ready {
view
} else {
nil
}
},
{
max_attempts: cfg.readiness_max_attempts,
interval_ms: cfg.readiness_interval_ms,
timeout_ms: cfg.readiness_timeout_ms,
},
)
const ready = found != nil
return {
ready: ready,
detail: if ready {
"${target} finalized with required assets present"
} else {
"${target} is not fully published yet"
},
attempts: attempts,
attempt_lines: lines,
}
},
read_current_version: fn() {
const result = __shell("cat ${cfg.version_file} 2>/dev/null || true")
return trim(result.stdout ?? "")
},
base_sha: fn() { return trim(__git(["git", "rev-parse", "HEAD"]).stdout ?? "") },
apply_version: fn(target) {
__shell("printf '%s\\n' '${target}' > ${cfg.version_file}")
const refresh = __shell(cfg.refresh_command)
const changed = !(__git(["git", "diff", "--quiet"]).success ?? false)
const names = trim(__git(["git", "diff", "--name-only"]).stdout ?? "")
const refreshed = if names == "" {
[]
} else {
split(names, "\n").filter({ n -> trim(n) != "" }).to_list()
}
return {
changed: changed,
refreshed: refreshed,
detail: if refresh.success ?? false {
"refresh ok"
} else {
trim(refresh.stderr ?? "")
},
}
},
run_validation: fn() {
if trim(cfg.validation_command) == "" {
return {ok: true, steps: [], detail: "no validation command declared"}
}
const result = __shell(cfg.validation_command)
const ok = result.success ?? false
return {
ok: ok,
steps: [{name: "validation", ok: ok, detail: cfg.validation_command}],
detail: if ok {
"validation passed"
} else {
trim((result.stderr ?? "") + (result.stdout ?? ""))
},
}
},
working_changes: fn() {
const adds = trim(
__git(["git", "diff", "--name-only", "--diff-filter=ACMRTUXB", "HEAD"]).stdout ?? "",
)
const dels = trim(
__git(["git", "diff", "--name-only", "--diff-filter=D", "HEAD"]).stdout ?? "",
)
return {
additions: if adds == "" {
[]
} else {
split(adds, "\n").filter({ n -> trim(n) != "" }).to_list()
},
deletions: if dels == "" {
[]
} else {
split(dels, "\n").filter({ n -> trim(n) != "" }).to_list()
},
}
},
find_bump_pr: fn() {
const first = __find_open_pr(cfg, owner)
if first == nil {
return nil
}
return {
number: first.number,
head_oid: first.head?.sha ?? "",
auto_merge_enabled: first.auto_merge != nil,
url: first.html_url ?? "",
state: first.state ?? "open",
}
},
disable_auto_merge: fn(number) {
const detail = __rest("GET", "/repos/${cfg.repo}/pulls/${number}")
const node_id = detail?.node_id ?? ""
if node_id == "" {
return false
}
const result = __graphql(
"mutation($id: ID!) { disablePullRequestAutoMerge(input: {pullRequestId: $id}) { clientMutationId } }",
{id: node_id},
)
return result != nil && len(result?.errors ?? []) == 0
},
reset_branch: fn(base_sha) {
const ref = "/repos/${cfg.repo}/git/refs/heads/${cfg.branch}"
const existing = __rest("GET", ref)
if existing != nil {
return __rest("PATCH", ref, {sha: base_sha, force: true}) != nil
}
return __rest(
"POST",
"/repos/${cfg.repo}/git/refs",
{ref: "refs/heads/${cfg.branch}", sha: base_sha},
)
!= nil
},
create_signed_commit: fn(request) {
let additions = []
for path in request.additions {
const encoded = __shell("base64 -w0 < '${path}' || base64 < '${path}' | tr -d '\\n'")
additions = additions + [{path: path, contents: trim(encoded.stdout ?? "")}]
}
const deletions = request.deletions.map({ p -> {path: p} }).to_list()
const result = __graphql(
"mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }",
{
input: {
branch: {repositoryNameWithOwner: cfg.repo, branchName: request.branch},
message: {headline: request.headline},
fileChanges: {additions: additions, deletions: deletions},
expectedHeadOid: request.base_sha,
},
},
)
const commit = result?.data?.createCommitOnBranch?.commit
if commit == nil {
throw "std/bump/live: createCommitOnBranch returned no commit; errors="
+ json_stringify(
result?.errors ?? [],
)
}
return {oid: commit.oid, url: commit.url ?? ""}
},
branch_matches_base: fn() {
const branch = __rest("GET", "/repos/${cfg.repo}/commits/${cfg.branch}")
const base = __rest("GET", "/repos/${cfg.repo}/commits/${cfg.base}")
const branch_tree = branch?.commit?.tree?.sha ?? "branch"
const base_tree = base?.commit?.tree?.sha ?? "base"
return branch_tree == base_tree
},
upsert_pr: fn(existing) {
const title = "Bump Harn runtime"
const body =
"Automated Harn runtime bump. Regenerated the lockfile and ran the declared validation entrypoint. Merges through the normal queue.\n\nProduced by the reusable `bump-harn` workflow."
if existing != nil {
__rest("PATCH", "/repos/${cfg.repo}/pulls/${existing.number}", {title: title, body: body})
const detail = __rest("GET", "/repos/${cfg.repo}/pulls/${existing.number}")
return {
number: existing.number,
head_oid: detail?.head?.sha ?? existing.head_oid,
url: detail?.html_url ?? existing.url,
created: false,
}
}
const created = __rest(
"POST",
"/repos/${cfg.repo}/pulls",
{title: title, head: cfg.branch, base: cfg.base, body: body},
)
return {
number: created?.number ?? 0,
head_oid: created?.head?.sha ?? "",
url: created?.html_url ?? "",
created: true,
}
},
close_pr: fn(number, comment) {
const result = __close_pr(cfg.repo, number, comment)
return result.ok
},
enable_auto_merge: fn(number, _head) {
const result = __enable_auto_merge(cfg.repo, number)
const ok = result.ok
return {
enabled: ok,
state: result.state,
detail: if ok {
""
} else {
json_stringify(result.error ?? "auto-merge not enabled")
},
}
},
}
}