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.harnstd/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#

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, and validate-command.

name: Bump Harn Runtime
on:
  workflow_dispatch:
    inputs:
      version:
        description: "Optional Harn tag (vX.Y.Z). Defaults to latest release."
        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 }}
      # 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 before 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
    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.
  • 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 base-head leases before refresh begins. 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.
  • 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, 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) and re-runs apply and validation against it, 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.
  • Contested refresh output: an adoption is refused when the incoming commits changed a path this bump's refresh also authors. Adopting would silently decide a contest between two writers of one artifact, so the attempt returns outcome: base_conflict and names the contested paths for a human. This is the only base-race exit a fresh retry cannot clear on its own.
  • 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.

Security boundary#

  • Least privilege, short-lived credentials. The workflow mints a GitHub App installation token scoped to contents: write + pull-requests: write for the run only. The caller passes the App client id and private key as secrets; no long-lived PAT is used.
  • 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, 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. It carries no secret beyond the scoped installation token, which is passed via the environment and never written to the repo.