Pre-release Harn is pre-1.0 — the language, standard library, and CLI may change between releases. See the release notes

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 locked harn-github-connector package (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 from package/. 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-inventory receipt. 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-revision is supplied, the workflow reads .harn-revision as 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 using HARN_BUMP_SOURCE_REVISION. The installed release CLI owns this state machine, so commit-targeted bumps require a release whose embedded std/bump supports 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-Commit trailer. The PR is closed only when the refreshed tree already matches the base. Labels from the labels input 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: true receive a signed repair PR only when the failed mutation produced a file delta. The receipt preserves refresh_failed or validation_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: true and 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_adoptions in the receipt reports how many were made. After that, or when the remote head cannot be read at all, the attempt returns outcome: base_advanced with a fresh_base_retry recovery, 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 requests fresh_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 an already_current no-op, and a newer or divergent source revision is a revision_conflict with 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_advanced and 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, with contents: write and pull_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 createCommitOnBranch GraphQL mutation under the App identity, so GitHub signs it and an org required_signatures ruleset is satisfied. A local git 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-harn verifies the downloaded runtime archive against its published SHA-256 before installing.
  • Caller package-manager pin. When node-version is configured, the shared workflow activates the exact npm, pnpm, or yarn version declared by the caller's package.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, and validate-command; consumers copy no orchestration, release-readiness, signing, branch, or PR machinery.
  • Sandbox posture. The orchestration runs under harn run --no-sandbox because 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 through GH_TOKEN.