Dagger
Search

github

── WHY THIS MODULE EXISTS ──────────────────────────────────────────────────
All of this lived as bash + `gh` + `jq` heredocs inside `.github/workflows`.
That is the worst place for it: it cannot be run from a laptop, it cannot be
tested without a push, and `jq` filters spanning eight lines were reviewed by
nobody because a YAML diff hides them. Every function here is a straight port
of a block that is currently a `run:` step, and the reasoning in the comments
is load-bearing — each paragraph is a measured failure, not a preference.

── WHAT STAYS IN YAML, AND WHY ─────────────────────────────────────────────
Per `org-gitops/docs/daggerverse-ci-contract.md` §1, a step survives in YAML
only for approval gates, runner topology, the trigger surface (`on:`, `if:`,
`permissions`, `concurrency`) or the runner's own API. So the `if:` guards of
`rerun-on-runner-loss.yml` — `conclusion == 'failure'`, `run_attempt == 1`,
`event == 'push'`, `head_branch == 'main'` — stay there: they are the trigger
surface, and `run_attempt == 1` is what bounds the retry to ONE (the rerun
creates attempt 2, the workflow fires again, and the condition no longer
holds). This module owns the decision, never the trigger.

── EVERY READ IS CACHE-BUSTED, AND THIS IS NOT OPTIONAL ────────────────────
Dagger caches an exec by its command and environment. A network read whose
arguments did not change is therefore served from the previous run's cache:
the container never starts, the API is never called, and the gate reports the
verdict of a run that happened yesterday. A check that is cached is a check
that did not happen. Every function that touches the network takes a
`cacheBust` that MUST vary per run — use the GitHub run id — and it is a
required parameter on purpose, because a default would be a default that
silently disables the gate.

── COMPLEX ARGUMENTS TRAVEL AS JSON STRINGS ────────────────────────────────
Structural types cross a Dagger module boundary poorly and a shape mismatch
fails at call time with an unreadable error. Non-scalars cross as JSON
strings, documented on each function, parsed and validated on entry.

── PARSING HAPPENS IN TYPESCRIPT, NOT IN `jq` ──────────────────────────────
The ported bash piped `gh api` into `jq`. Here the container only runs
`curl` and the JSON is parsed in the SDK. That is not a style choice: it
removes an `apk add jq` from every call, and a shared apt/apk cache mount is
a measured source of concurrent-build failures (the lock lives inside the
cached directory, so parallel containers fight over it). It also lets a
malformed response name itself in the error instead of becoming an empty
`jq` string that reads as "nothing found".

Installation

dagger install github.com/wildbitca/daggerverse/github@v0.1.0

Entrypoint

Return Type
Github
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
func (m *MyModule) Example() *dagger.Github  {
	return dag.
			Github()
}
@function
def example() -> dagger.Github:
	return (
		dag.github()
	)
@func()
example(): Github {
	return dag
		.github()
}

Types

Github 🔗

verifyTagCi() 🔗

Assert that the CODE a tag points at has passed CI on the default branch.

Ported from pacha/app/.github/workflows/pipeline.yml, job release, step «verificar que el código del tag pasó CI en main». Without it any commit could be tagged and shipped to the stores; it is a query, never a rebuild.

THE INVARIANT IS “THE PUBLISHED CODE PASSED CI”, NOT “THIS COMMIT HAS A RUN”. With paths-ignore, a docs-only commit legitimately has no run of its own and a literal check would reject it while nothing is wrong. So: if the tag’s commit is green, done. If not, walk up to maxCommits ancestors for the nearest green one and demand that EVERYTHING changed since then matches ignoredRe. One line of code without CI fails — which is the case the gate exists to catch.

⚠️ “GREEN” IS THE CONCLUSION OF A CHECK-RUN, NEVER OF THE RUN. The earlier version asked gh run list --json conclusion, which is the verdict of the WHOLE run and therefore red as soon as ANY job is. On 2026-09-03 the ios-e2e lane started running on every push, and overnight NO commit in the repo could satisfy this gate — not even through the ancestor path, which read the same list. The code was perfectly validated and tags were impossible. Asking for one named check-run also aligns the release gate with the merge gate: ci is the single context the branch-ci-required ruleset requires, so a new and still unstable lane can go red without blocking releases — which is exactly the property that makes adding lanes possible.

⚠️ FAILING TO READ CHECK-RUNS IS NOT “NOT GREEN”. The ported bash ended its query with || echo 0, so a 404 from a missing checks: read permission counted as zero green runs and rejected the tag for the wrong reason — worse than having no gate, because it looks like a verdict. Here every non-200 throws and names the permission. See the note on the first checkGreen call below for why that also deletes the bash’s preflight probe, and checkGreen itself for how far that claim has been proven.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-

owner/name.

shaString !-

Anything the commits API resolves: a commit sha, a branch, or a TAG NAME. Pass github.ref_name, not github.sha: for an ANNOTATED tag the ref points at a tag object, and the commits API answers 422 «No commit found for SHA» for it (measured 2026-09-05 against wildbitca/pacha v3.8.2, whose ref object is 8eda9c76, not the commit bb08c4af). This is the same reason the bash ran git rev-list -n1.

checkNameString !-

The check-run name that must be green, e.g. ci.

ignoredReString !-

POSIX/JS regex of paths allowed to change without CI, e.g. ^(specs/|docs/)|\.md$. Required, never defaulted: a default here would be a silent policy for repos that never chose it.

cacheBustString !-

MUST vary per run (the run id). See the module header.

maxCommitsInteger !50

How far to walk. 50 is generous: above that the tag does not hang off anything this CI has seen and rejecting is the correct answer.

Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 verify-tag-ci --token env:MYSECRET --repo string --sha string --check-name string --ignored-re string --cache-bust string --max-commits integer
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, sha string, checkName string, ignoredRe string, cacheBust string, maxCommits int) string  {
	return dag.
			Github().
			Verifytagci(ctx, token, repo, sha, checkName, ignoredRe, cacheBust, maxCommits)
}
@function
async def example(token: dagger.Secret, repo: str, sha: str, checkname: str, ignoredre: str, cachebust: str, maxcommits: int) -> str:
	return await (
		dag.github()
		.verifytagci(token, repo, sha, checkname, ignoredre, cachebust, maxcommits)
	)
@func()
async example(token: Secret, repo: string, sha: string, checkName: string, ignoredRe: string, cacheBust: string, maxCommits: number): Promise<string> {
	return dag
		.github()
		.verifyTagCi(token, repo, sha, checkName, ignoredRe, cacheBust, maxCommits)
}

lostRunnerVerdict() 🔗

Decide whether a finished run has the signature of a runner that vanished.

Ported from pacha/app/.github/workflows/rerun-on-runner-loss.yml. The bithome node — the k3s hosting the ARC runners and the shared Dagger engine — restarts its containers with nothing scheduling it. Measured on 2026-09-04: 73 pods terminated at once at 02:28:59 with reason=Unknown and came back at 02:29:30, and the cumulative counters confirm it recurs (local-path-provisioner 56 restarts in 69 days, metrics-server 43, coredns 27, dagger-engine 15). When that happens inside a ~95-minute ci, GitHub marks the job failure, the running step stays in_progress with no conclusion, and the logs are NOT EVEN ARCHIVED (log not found): the run and its evidence are both lost, and the red is indistinguishable from a broken test to anyone glancing at it.

THE DISCRIMINATOR: lost runner → job failure and NOT ONE step with conclusion == failure (the step in flight was left in_progress / null) real failure → the step that broke carries conclusion == failure

Checked against the two real samples that existed: 33822966475 ci failure at 49 min, dagger call ci in_progress → rerun 33827086621 ci failure at 53 s, dagger call ci failure → do not (a compilation error of the module: a legitimate failure)

⚠️ THIS HEURISTIC SURVIVES THE PIPELINE CONSOLIDATION. Fusing steps into one dagger call does not weaken it: a real failure still leaves exactly one step in failure (the fused one), and a lost runner still leaves zero. What would break it is a job whose only step cannot fail — and there is none.

⚠️ EVERY NAMED JOB IS EVALUATED SEPARATELY, and that is not a style detail. The earlier version picked ONE job — the first with conclusion == failure — and looked only at it. With a single lane that worked. After the pipeline was unified there were two, and on 2026-09-04 run 33909173457 showed what that does: the API returned ci (iOS) first (red with 1 step in failure, a REAL failure of the Mac toolchain guard), the selector kept it, concluded “real failure, no rerun” — and NEVER LOOKED at job ci, which was precisely the one that had lost its runner (OOMKilled, 0 steps in failure). While one lane is red for a legitimate reason the other could lose its runner and never be rerun, and that condition is not rare: it is EVERY run while the Mac carries its own red. The question is not “which job”, it is “does ANY job have the signature”.

Returns JSON: {lost, job, stuckStep, reason, diagnostic}. The diagnostic covers ALL named jobs, not just the chosen one — if this ever decides wrong, the log has to show why without calling the API again. It is also written to stderr so it lands in the Dagger log even when the caller drops the return.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
runIdString !-

The workflow run id (github.event.workflow_run.id).

jobNamesString !-

JSON string[] of job names to consider, e.g. ["ci","ci (iOS)"]. REQUIRED and never defaulted to “all jobs”: an unfiltered match would let any job in the run trigger a rerun of the whole thing.

cacheBustString !-

MUST vary per run. See the module header.

Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 lost-runner-verdict --token env:MYSECRET --repo string --run-id string --job-names string --cache-bust string
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, runId string, jobNames string, cacheBust string) string  {
	return dag.
			Github().
			Lostrunnerverdict(ctx, token, repo, runId, jobNames, cacheBust)
}
@function
async def example(token: dagger.Secret, repo: str, runid: str, jobnames: str, cachebust: str) -> str:
	return await (
		dag.github()
		.lostrunnerverdict(token, repo, runid, jobnames, cachebust)
	)
@func()
async example(token: Secret, repo: string, runId: string, jobNames: string, cacheBust: string): Promise<string> {
	return dag
		.github()
		.lostRunnerVerdict(token, repo, runId, jobNames, cacheBust)
}

rerunFailedJobs() 🔗

Rerun the failed jobs of a run — POST /actions/runs/{id}/rerun-failed-jobs, which is what gh run rerun --failed calls.

DELIBERATELY SEPARATE FROM THE VERDICT. Deciding and acting are different privileges: reading jobs needs actions: read, this needs actions: write, and a caller must be able to ask “is this a lost runner?” from a laptop without relaunching anything. Bounding it to ONE retry is the caller’s job and stays in YAML as run_attempt == 1 — this function will happily rerun anything it is pointed at, so a caller that drops that guard builds a loop.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
runIdString !-No description provided
cacheBustString !-

MUST vary per run. A POST exec is cached exactly like a GET one, so without it a second call for the same run returns the first call’s output and no rerun is ever requested.

Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 rerun-failed-jobs --token env:MYSECRET --repo string --run-id string --cache-bust string
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, runId string, cacheBust string) string  {
	return dag.
			Github().
			Rerunfailedjobs(ctx, token, repo, runId, cacheBust)
}
@function
async def example(token: dagger.Secret, repo: str, runid: str, cachebust: str) -> str:
	return await (
		dag.github()
		.rerunfailedjobs(token, repo, runid, cachebust)
	)
@func()
async example(token: Secret, repo: string, runId: string, cacheBust: string): Promise<string> {
	return dag
		.github()
		.rerunFailedJobs(token, repo, runId, cacheBust)
}

releaseExists() 🔗

Whether a release already exists for tag.

Public on purpose rather than folded into createRelease: it is the read-only half of the idempotency check, so it can be exercised against a live repository without creating anything. A 404 is the only “no”; any other non-200 throws, because “the API did not answer” must never be read as “the release is not there” — that is how an idempotent create turns into a duplicate.

⚠️ 404 IS AMBIGUOUS AND CANNOT BE MADE OTHERWISE HERE. GitHub answers 404 both for “no release with that tag” and for “this token cannot see this repository” — it will not confirm a private repo’s existence to a token without access. Measured 2026-09-05: wildbitca/does-not-exist-abc returns false, not an error. That is safe in the only place it is used: createRelease then tries the POST, which fails with its own error naming the repository. It would NOT be safe as the premise of a “skip the release” decision, so do not use it as one.

Return Type
Boolean !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
tagString !-No description provided
cacheBustString !-

MUST vary per run. See the module header.

Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 release-exists --token env:MYSECRET --repo string --tag string --cache-bust string
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, tag string, cacheBust string) bool  {
	return dag.
			Github().
			Releaseexists(ctx, token, repo, tag, cacheBust)
}
@function
async def example(token: dagger.Secret, repo: str, tag: str, cachebust: str) -> bool:
	return await (
		dag.github()
		.releaseexists(token, repo, tag, cachebust)
	)
@func()
async example(token: Secret, repo: string, tag: string, cacheBust: string): Promise<boolean> {
	return dag
		.github()
		.releaseExists(token, repo, tag, cacheBust)
}

createRelease() 🔗

Create the GitHub Release for a tag. Idempotent.

Ported from the release job of pipeline.yml («crear GitHub Release») and from the private githubRelease of app/.github/dagger. Dagger used to create it inside ci when ci ran on tags; once CI was taken off tags that step became unreachable and the workflow redid it in bash. Both copies land here.

IDEMPOTENT BY SKIPPING, NOT BY OVERWRITING. An existing release is left exactly as it is, which is what the live workflow does («el release ya existe, no se recrea»). It matters because the release job can be replayed — GitHub re-dispatches on approval, and the lost-runner mitigation reruns failed jobs — and a release whose notes a human has edited must not be silently rewritten by a replay. updateExisting restores the PATCH behaviour of the private githubRelease for callers that want the notes regenerated; it is off by default because only one of the two copies did it and it is the destructive one.

PRERELEASE COMES FROM THE TAG, by the same semver convention that picks the store channel: a tag with a hyphen (v4.0.0-rc.1) is internal QA and must not appear as a stable release.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
tokenSecret !-No description provided
repoString !-No description provided
tagString !-No description provided
cacheBustString !-

MUST vary per run. A POST is cached like a GET.

notesString !""

Release body. Empty asks GitHub to generate the notes, which is what gh release create --generate-notes did.

changelogFile -

Optional CHANGELOG.md; when notes is empty its section for this tag is used. See changelogNotes.

updateExistingBoolean !falseNo description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 create-release --token env:MYSECRET --repo string --tag string --cache-bust string --notes string --update-existing boolean
func (m *MyModule) Example(ctx context.Context, token *dagger.Secret, repo string, tag string, cacheBust string, notes string, updateExisting bool) string  {
	return dag.
			Github().
			Createrelease(ctx, token, repo, tag, cacheBust, notes, updateExisting)
}
@function
async def example(token: dagger.Secret, repo: str, tag: str, cachebust: str, notes: str, updateexisting: bool) -> str:
	return await (
		dag.github()
		.createrelease(token, repo, tag, cachebust, notes, updateexisting)
	)
@func()
async example(token: Secret, repo: string, tag: string, cacheBust: string, notes: string, updateExisting: boolean): Promise<string> {
	return dag
		.github()
		.createRelease(token, repo, tag, cacheBust, notes, updateExisting)
}

isPrerelease() 🔗

Whether a tag is a prerelease, by the semver convention this organisation releases with: a hyphen means internal QA. Pure.

Public and separate so the rule can be asserted without creating anything — createRelease cannot be exercised live without publishing a release, and an untested rule that decides what appears in the stores is not a rule. It is also the SAME predicate the workflow evaluates in four other places (contains(github.ref_name, '-') picks the job name, the environment:, the Codemagic channel and this flag), and per the contract §3.1 a routing decision belongs in one testable place rather than in four if: strings.

Return Type
Boolean !
Arguments
NameTypeDefault ValueDescription
tagString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 is-prerelease --tag string
func (m *MyModule) Example(ctx context.Context, tag string) bool  {
	return dag.
			Github().
			Isprerelease(ctx, tag)
}
@function
async def example(tag: str) -> bool:
	return await (
		dag.github()
		.isprerelease(tag)
	)
@func()
async example(tag: string): Promise<boolean> {
	return dag
		.github()
		.isPrerelease(tag)
}

changelogNotes() 🔗

Extract the ## [VERSION] section of a CHANGELOG for a tag. Pure.

Ported verbatim from the private githubRelease. The leading v is stripped and the version is regex-escaped before it is used as a pattern: a version is full of dots, and an unescaped 4.0.0 would also match 4x0y0, quietly picking the wrong section.

Returns “” when there is no such section, which is the caller’s signal to let GitHub generate the notes — the same fallback the private helper had.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
changelogFile !-No description provided
tagString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 changelog-notes --changelog file:path --tag string
func (m *MyModule) Example(ctx context.Context, changelog *dagger.File, tag string) string  {
	return dag.
			Github().
			Changelognotes(ctx, changelog, tag)
}
@function
async def example(changelog: dagger.File, tag: str) -> str:
	return await (
		dag.github()
		.changelognotes(changelog, tag)
	)
@func()
async example(changelog: File, tag: string): Promise<string> {
	return dag
		.github()
		.changelogNotes(changelog, tag)
}

annotation() 🔗

Render a workflow command — ::error title=…::message. Pure.

WHY IT LIVES HERE AT ALL: the diagnostic prose in these pipelines is long, carefully worded, and it is the only thing a person reads when a run goes red. Kept in YAML it is unreviewable and untestable; kept here it is a string a test can assert on.

HOW A CALLER EMITS IT: the runner parses workflow commands out of the step log, whatever writes them — this is already relied upon by the existing modules, which emit console.error("::error:: …") from inside a Dagger session and get real annotations. So either let a function print it, or capture the return and echo it:

dagger call annotation –level=warning –message=… > /tmp/a && cat /tmp/a

The level is validated because GitHub does not complain about an unknown one: ::warn:: is printed as ordinary log text and the annotation simply never appears — the exact class of failure where the check silently did not happen.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
levelString !-

warning | error | notice | debug

messageString !-No description provided
titleString !""

Optional annotation title.

fileString !""

Optional path to attach the annotation to.

lineInteger !0

Optional line; 0 means absent.

Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 annotation --level string --message string --title string --file string --line integer
func (m *MyModule) Example(ctx context.Context, level string, message string, title string, file string, line int) string  {
	return dag.
			Github().
			Annotation(ctx, level, message, title, file, line)
}
@function
async def example(level: str, message: str, title: str, file: str, line: int) -> str:
	return await (
		dag.github()
		.annotation(level, message, title, file, line)
	)
@func()
async example(level: string, message: string, title: string, file: string, line: number): Promise<string> {
	return dag
		.github()
		.annotation(level, message, title, file, line)
}

summarySection() 🔗

Render a $GITHUB_STEP_SUMMARY section. Pure.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
headingString !-No description provided
bulletsString !""

JSON string[]; each becomes a - item. Empty for none.

bodyString !""

Free markdown appended after the bullets.

Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 summary-section --heading string --bullets string --body string
func (m *MyModule) Example(ctx context.Context, heading string, bullets string, body string) string  {
	return dag.
			Github().
			Summarysection(ctx, heading, bullets, body)
}
@function
async def example(heading: str, bullets: str, body: str) -> str:
	return await (
		dag.github()
		.summarysection(heading, bullets, body)
	)
@func()
async example(heading: string, bullets: string, body: string): Promise<string> {
	return dag
		.github()
		.summarySection(heading, bullets, body)
}

summaryFile() 🔗

The same markdown as a File, for callers that would rather redirect than quote: dagger call … export --path=s.md && cat s.md >> "$GITHUB_STEP_SUMMARY".

Shell-quoting a multi-line markdown blob through $(…) is where the ported heredocs lost their blank lines, and a summary without blank lines renders as one run-on paragraph.

Return Type
File !
Arguments
NameTypeDefault ValueDescription
markdownString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 summary-file --markdown string
func (m *MyModule) Example(markdown string) *dagger.File  {
	return dag.
			Github().
			Summaryfile(markdown)
}
@function
def example(markdown: str) -> dagger.File:
	return (
		dag.github()
		.summaryfile(markdown)
	)
@func()
example(markdown: string): File {
	return dag
		.github()
		.summaryFile(markdown)
}

lostRunnerWarning() 🔗

The ::warning:: the rerun mitigation emits. Pure.

NOISY ON PURPOSE. This mitigation does not fix the node, and it was chosen knowing the cause is still there: diagnosing it needs journalctl -u k3s on the host, which is not reachable from the cluster. A silent retry would turn an infrastructure problem into a slightly slower CI and nobody would look at it again. Hence a warning naming the suspicion, and warning rather than error: the run being annotated did not fail because of this.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
runIdString !-No description provided
stuckStepString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 lost-runner-warning --run-id string --stuck-step string
func (m *MyModule) Example(ctx context.Context, runId string, stuckStep string) string  {
	return dag.
			Github().
			Lostrunnerwarning(ctx, runId, stuckStep)
}
@function
async def example(runid: str, stuckstep: str) -> str:
	return await (
		dag.github()
		.lostrunnerwarning(runid, stuckstep)
	)
@func()
async example(runId: string, stuckStep: string): Promise<string> {
	return dag
		.github()
		.lostRunnerWarning(runId, stuckStep)
}

lostRunnerSummary() 🔗

The $GITHUB_STEP_SUMMARY block of the rerun mitigation. Pure.

Return Type
String !
Arguments
NameTypeDefault ValueDescription
runIdString !-No description provided
runUrlString !-No description provided
stuckStepString !-No description provided
Example
dagger -m github.com/wildbitca/daggerverse/github@87d4e4b4209bbcdc536a57f654b1e52aa4b88030 call \
 lost-runner-summary --run-id string --run-url string --stuck-step string
func (m *MyModule) Example(ctx context.Context, runId string, runUrl string, stuckStep string) string  {
	return dag.
			Github().
			Lostrunnersummary(ctx, runId, runUrl, stuckStep)
}
@function
async def example(runid: str, runurl: str, stuckstep: str) -> str:
	return await (
		dag.github()
		.lostrunnersummary(runid, runurl, stuckstep)
	)
@func()
async example(runId: string, runUrl: string, stuckStep: string): Promise<string> {
	return dag
		.github()
		.lostRunnerSummary(runId, runUrl, stuckStep)
}