/**
* 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 first-party `std/connectors/github`
* connector (typed helpers plus its `api_call` REST/GraphQL escape hatch).
*
* 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 connector 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, by construction.
*
* Auth is the GitHub App installation token, set once via the connector's
* module-level `configure`. Signed commits use the GraphQL `createCommitOnBranch`
* mutation (through the connector's `api_call`) 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.
*
* `std/connectors/github` has no typed helper for creating refs, signed commits,
* pull requests, or disabling auto-merge, so those go through the connector's
* `api_call` (raw REST / GraphQL). Typed helpers cover release lookup, PR edit,
* PR close, and enabling auto-merge.
*/
import { BumpAdapter } from "std/bump/runtime"
import { command_run } from "std/command"
import {
api_call,
close_pr,
configure,
enable_auto_merge,
latest_release,
pr_edit,
release_view,
} from "std/connectors/github"
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,
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 ?? ""),
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,
}
}
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 connector; nil on any connector-reported failure. */
fn __rest(method: string, path: string, body = nil) {
const result = try {
api_call(path, method, body)
}
return if is_err(result) {
nil
} else {
result
}
}
/** One GraphQL call through the connector's api_call escape hatch. */
fn __graphql(query: string, variables: dict) {
const result = try {
api_call("/graphql", "POST", {query: query, variables: variables})
}
return if is_err(result) {
nil
} else {
result
}
}
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
}
/** Connector `api_call` has no typed PR list; fetch the open bump PR raw. */
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 connector call as the GitHub App for this run.
configure({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 = latest_release(cfg.repo)
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.repo}"
}
return resolved
},
release_ready: fn(target) {
let attempts = 0
let lines = []
const found = poll_until(
{ ->
attempts = attempts + 1
const view = release_view(cfg.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 {
pr_edit(cfg.repo, 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, {method: "squash"})
const ok = result.ok
return {
enabled: ok,
state: result.state,
detail: if ok {
""
} else {
json_stringify(result.error ?? "auto-merge not enabled")
},
}
},
}
}