Reusable "bump Harn runtime" workflow
Every Harn package repo pins the Harn runtime it builds against in a
.harn-version file. Keeping that pin current used to mean copying a large
bump-harn.yml state machine into each repo. Harn now publishes one
reusable workflow so a package needs only a small trigger wrapper plus its own
declared refresh and validation commands.
- Workflow:
.github/workflows/bump-harn.yml(workflow_call). - Orchestration:
scripts/bump-driver/bump_harn_runtime.harn→std/bump/runtime(pure state machine) →std/bump/live(local filesystem, git, command, and polling effects) → the lockedharn-github-connectorpackage (all remote GitHub behavior). - Workspace boundary: the caller is checked out at
package/; the exact workflow-owner orchestration checkout is its sibling. Refresh, validation, and local git effects run frompackage/. The source script's nested package manifest activates its locked connector without changing that working directory. - Receipt schema:
harn-bump-runtime-v1(printed to stdout; key fields also land as step outputs and a step-summary block). - Package-test compatibility: before any mutation, the target runtime writes a
compact
package test-inventoryreceipt. The workflow retains it as an artifact and embeds its exact base64 JSON plus SHA-256 in any generated bump pull request, so a repair sees the discovery failure that triggered it.
Minimal caller workflow#
An open bump pull request may contain consumer repair commits. Refresh replays
non-bot repairs onto the new pin before the declared validation command runs.
Signed bump commits retain Harn-Repair-Commit trailers, so later refreshes
can replay those same repairs after the branch was flattened. Repair commits
must remain fetchable by their recorded IDs. A missing commit fails the run.
The GitHub adapter identifies generated commits by the configured publisher's
GitHub login, a valid GitHub signature, and the exact refresh headline
chore: bump Harn runtime to vX.Y.Z. A local Git author name is not identity
evidence. Login and signature alone are not enough: a repair tool holding the
same GitHub App credentials creates commits through the same signed API, and
those commits carry the same evidence. Every other commit is a repair; an
unavailable identity lookup fails the run. A refresh refuses to publish under
any other headline, so its own output stays recognizable. Repair merge commits
are refused.
A patch conflict
returns repair_conflict without publishing or arming auto-merge. Publication
also rechecks the inspected pull-request head and refuses an observed change
before replacing it. The receipt's repair_commits names the repair set, and
repair_conflicts names contested paths.
Drop this into the consuming repo. The only repo-specific parts are the trigger
schedule and, when the default lock refresh is insufficient, the
refresh-command, format-command, finalize-refresh-command, and validate-command.
Set finalize-refresh-command when generated artifacts depend on Harn source files.
It runs after compatibility migrations, repairs, and formatting, before validation.
For example, use pnpm run codegen to regenerate bytecode from the final source tree.
The default is empty. A failed finalizer refuses refresh success.
With the default publish-failure-for-repair: false, the bump stops before validation or publication.
name: Bump Harn Runtime
on:
workflow_dispatch:
inputs:
version:
description: "Optional Harn tag (vX.Y.Z). Defaults to latest release."
required: false
type: string
source_revision:
description: "Optional full Harn commit SHA descended from the selected tag."
required: false
type: string
schedule:
- cron: "43 9 * * *"
permissions:
contents: write
pull-requests: write
jobs:
bump:
uses: burin-labs/harn/.github/workflows/bump-harn.yml@<pinned-sha>
with:
# The fleet registry generates both occurrences from one policy value.
# The reusable workflow rejects non-SHA refs before checkout.
orchestration-sha: <pinned-sha>
version: ${{ inputs.version }}
source-revision: ${{ inputs.source_revision }}
# Optional repository-owned materialization. The target tag is inherited
# as HARN_BUMP_TARGET_TAG. The reusable workflow first applies the target
# runtime's deterministic capability migrations, then runs this refresh,
# then applies the target runtime's formatter before validation. All
# mutations are included in the one signed commit; a non-zero exit blocks
# it.
refresh-command: |
harn install --locked
./scripts/regenerate-derived-sources "$HARN_BUMP_TARGET_TAG"
# Optional repository-owned valid-source boundary. The default is
# `harn fmt .`; repositories that intentionally keep invalid `.harn`
# fixtures must point this at their authoritative formatter command.
# Empty commands fail closed. Formatting always runs after regeneration
# and before refresh success is recorded.
format-command: ./scripts/format-harn-sources
# Optional, when the repository owner commands require Node. The shared
# workflow installs this exact version rather than trusting runner state.
# If package.json declares an exact npm, pnpm, or yarn `packageManager`,
# Corepack activates that exact version before refresh and validation.
node-version: "22"
# Repository-owned verification runs after every mutation. A non-zero
# exit blocks the commit.
validate-command: |
set -euo pipefail
mapfile -t files < <(git ls-files '*.harn')
if (( ${#files[@]} > 0 )); then
harn fmt "${files[@]}"
harn check "${files[@]}"
harn lint "${files[@]}"
fi
[ -d tests ] && harn test tests/ --parallel || true
# Optional controller handoff. A failed refresh or validation still
# fails the workflow, but publishes the exact signed mutation as a PR
# with auto-merge disabled so a separately bounded repair lane has a
# head lease. Ordinary callers should keep the default false.
publish-failure-for-repair: false
# Optional. The shared workflow applies `harn fix --safety
# behavior-preserving` to your sources after your refresh command, so a
# bump can normalize its own fallout. Set false to decline that pass and
# keep the bump limited to the version change plus mandatory compatibility
# migrations and your own commands. The implicit-any compatibility
# census, capability migrations, and deterministic formatting are applied
# either way. Defaults to true.
apply-behavior-preserving-fixes: true
# Optional comma-separated labels added to the bump PR each time it is
# created or refreshed. Blank entries are ignored and duplicates collapse
# case-insensitively. A name longer than 50 characters fails the run
# before refresh. The run also fails if GitHub does not report every
# label applied. Defaults to empty, which makes no labeling request.
labels: ""
secrets:
app-client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
app-private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
Pin @<pinned-sha> to a full commit SHA of burin-labs/harn, and project that
same registry-owned value into orchestration-sha. The reusable workflow
validates exact lowercase 40-hex before checking out its setup action and bump
driver from that commit. The independently resolved release tag selects only
the installed CLI and its embedded std/bump/* modules. This lets a current
adapter carry compatibility for an older target without granting callers an
arbitrary orchestration ref.
Idempotency and concurrency#
- Already current: the pin already matches the resolved target → clean no-op, zero mutation.
- Commit-targeted bumps: when
source-revisionis supplied, the workflow reads.harn-revisionas well as.harn-version. An equal tag advances when the requested commit descends from the current commit. An equal commit is a no-op; an older or divergent commit fails without mutation. The workflow checks the requested commit against the release tag and Harn main in its full checkout before changing either pin. The caller's refresh command must materialize the requested source commit usingHARN_BUMP_SOURCE_REVISION. The installed release CLI owns this state machine, so commit-targeted bumps require a release whose embeddedstd/bumpsupports the input. - Newer release while a bump PR is open: the run refreshes that PR in place.
The branch name and PR number stay the same. The branch is reset to the base
plus one signed refresh commit for the newer target, with every repair
replayed into it and recorded as a
Harn-Repair-Committrailer. The PR is closed only when the refreshed tree already matches the base. Labels from thelabelsinput are added again on each refresh. - Old implicit parameters: before caller regeneration or strict validation,
the target runtime translates each checker-owned omitted annotation to
explicit
any. The printed typed census names scanned, changed, pending, unresolved, and changed-semantics sites; any nonzero pending or failure count stops the bump. - Not yet published: a target whose release is still finalizing is a clean exit
(
outcome: not_ready); the next scheduled run picks it up. - Refresh or validation failure: the default remains fail-before-publish.
Controllers that set
publish-failure-for-repair: truereceive a signed repair PR only when the failed mutation produced a file delta. The receipt preservesrefresh_failedorvalidation_failed, validation is not run after a failed refresh, and the workflow remains failed. This path never arms auto-merge; a separate validator must prove and republish the leased head before merge can be enabled. When a compatible older target predates the typed refresh outcome, the workflow's per-run success sentinel still makes validation fail closed. - Package test discovery: the target runtime inventories selected test files
before changing the repository. A zero-test file or undeclared empty suite
is preserved as migration evidence while the normal validation and repair
policy decides whether a repair pull request may be published. Targets from
before this command existed record
package_test_inventory_unsupported; they never report an unmeasured suite as zero. - Stale heads: an open bump PR with auto-merge armed is disarmed only under its exact PR-head and the freshly observed base-branch head before refresh begins. The PR's historical base snapshot does not identify the current branch head; an unavailable current head prevents disarming. The runtime checks the checkout's exact base against the remote branch before refresh, after validation, and again immediately before arming. The connector derives and publishes a GitHub-signed commit only while the measured lease is current. Stale actors fail closed.
- Disjoint advance after validation: when the base moves after validation and
none of the incoming commits changed a path the refresh authored, the
validated tree is published on its validated base and armed. The pull
request is then an ordinary change behind its base, and the base branch's
merge queue tests the merged result. The receipt sets
published_behind_base: trueand names both base identities. The same rule applies to an advance just before arming. An overlapping advance, or one whose changed paths cannot be read, falls through to adoption. - Advanced base: a refresh takes long enough that the base branch can move
under it, and the refreshed content is a pure function of the base content
and the target release. So a moved base is re-derived, not discarded. Before
refresh, and before publication when the advance is not disjoint, the runtime adopts the observed base head
into the checkout (an authenticated fetch through the provider capability,
exact cleanup of the discarded refresh paths, then a detached checkout of
that head), re-reads both pins through the same monotonicity guard used at
startup, and re-runs apply and validation only if the adopted pins still need
the requested update, holding the disarm lease. Unrelated untracked files survive, and any unreadable comparison or
incomplete cleanup aborts the adoption before the checkout moves. Three
adoptions are allowed per attempt;
base_adoptionsin the receipt reports how many were made. After that, or when the remote head cannot be read at all, the attempt returnsoutcome: base_advancedwith afresh_base_retryrecovery, and the fleet controller's bounded successor run — or a standalone caller's next scheduled tick — starts again from a fresh checkout of the declared base. - Overlapping refresh output: an adoption is refused when the incoming commits
changed a path this bump's refresh also authors. The attempt returns
outcome: base_conflict, names the paths, and requestsfresh_base_retry. A successor run starts from the new base and regenerates the output from that base and the target release. It re-reads both pins before writing: a newer version is analready_currentno-op, and a newer or divergent source revision is arevision_conflictwith no mutation. The obsolete refresh is never merged into the successor checkout. - Overlapping base advance immediately before arming: the PR is already
published, so the attempt stops rather than redoing the refresh. It returns
base_advancedand leaves the PR unarmed; the next run refreshes that PR head and arms it.
Version availability#
The orchestration modules (std/bump/*) are embedded in the Harn CLI, so the
workflow runs the state machine under the target Harn release. The feature
ships in the release identified in changelog.d/5299.added.md; bumping to any
release at or after that version works. (Bumps are always forward to the latest
release, so this holds in practice.)
The driver package declares the runtime floor it needs in
scripts/bump-driver/harn.toml. A caller that repins orchestration-sha ahead
of its runtime gets that floor as a diagnostic rather than a missing-capability
failure part-way through a bump.
Repair preservation also requires the is_generated_commit method in the
target runtime's LiveBumpRemote contract. The GitHub driver checks that
contract before it can refresh or publish a branch. Older runtimes are refused
rather than silently refreshing without authenticated repair preservation.
The opposite skew is a release whose runtime the consumers' driver cannot
drive. Every bump runs the driver from the orchestration commit
harn-bump-fleet pins, so a release never dispatches fleet-owned bumps
directly. Its repin job starts the fleet's
promote-released-orchestration.yml, which moves the pin to the released
commit, or keeps a pin that is already newer. Each consumer adapter then
converges to that pin, and its landing starts that consumer's bump. The release
candidate run type-checks the driver that promotion will select against the
candidate binary (scripts/check_promoted_bump_orchestration.harn) and refuses
the candidate when it fails. It warns when the current, older pin fails, since
a bump dispatched by hand before convergence still runs that driver.
Security boundary#
- Least privilege, renewable credentials. The caller passes its App client
id and private key as
secrets. The driver mints tokens for only the calling repository, withcontents: writeandpull_requests: write. The locked connector retains these restrictions across expiry and 401 renewal. Git fetches and each new refresh callback obtain a current token. A child making GitHub calls beyond its token's lifetime must manage its own renewal. - Signed commits. The bump commit is created through GitHub's
createCommitOnBranchGraphQL mutation under the App identity, so GitHub signs it and an orgrequired_signaturesruleset is satisfied. A localgit commit+ push would land unsigned and be rejected. - Immutable supply chain. Third-party actions are pinned by full commit SHA.
The workflow contract, setup action, and driver use one registry-projected
Harn commit SHA. The installed target release owns embedded runtime modules
and capability migrations. The nested driver installs with
--locked, so the connector source and content hash are fixed.setup-harnverifies the downloaded runtime archive against its published SHA-256 before installing. - Caller package-manager pin. When
node-versionis configured, the shared workflow activates the exact npm, pnpm, or yarn version declared by the caller'spackage.json#packageManager. Undeclared managers remain a Node-only setup; unsupported or non-exact declarations fail before caller commands run. - One package-contract owner. The shared workflow invokes Harn's structural
test-discovery inventory but encodes none of a package's code-generation or
build/test commands. Repositories expose those owner commands through
refresh-command,format-command,finalize-refresh-command, andvalidate-command; consumers copy no orchestration, release-readiness, signing, branch, or PR machinery. - Sandbox posture. The orchestration runs under
harn run --no-sandboxbecause it must reach git, the GitHub API through the connector, and the caller's refresh and validation commands. The workflow removes the raw App key from its environment before starting Harn. A mode-600 runner temporary file hands the key to the driver, which reads and deletes that exact file before any child runs. The driver retains the key in memory; caller refresh commands receive only a scoped token throughGH_TOKEN.