Skip to content

ci: bump actions/checkout from 4 to 5 - #1

Closed
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/github_actions/actions/checkout-5
Closed

dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/github_actions/actions/checkout-5

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Oct 10, 2025

Copy link
Copy Markdown
Contributor

Bumps actions/checkout from 4 to 5.

Release notes

Sourced from actions/checkout's releases.

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

Full Changelog: actions/checkout@v4...v5.0.0

v4.3.0

What's Changed

New Contributors

Full Changelog: actions/checkout@v4...v4.3.0

v4.2.2

What's Changed

Full Changelog: actions/checkout@v4.2.1...v4.2.2

v4.2.1

What's Changed

New Contributors

Full Changelog: actions/checkout@v4.2.0...v4.2.1

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

V5.0.0

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

v4.1.4

v4.1.3

... (truncated)

Commits

Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot merge will merge this PR after your CI passes on it
  • @dependabot squash and merge will squash and merge this PR after your CI passes on it
  • @dependabot cancel merge will cancel a previously requested merge and block automerging
  • @dependabot reopen will reopen this PR if it is closed
  • @dependabot close will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code labels Oct 10, 2025
@dependabot @github

dependabot Bot commented on behalf of github Nov 25, 2025

Copy link
Copy Markdown
Contributor Author

Superseded by #5.

@dependabot dependabot Bot closed this Nov 25, 2025
@dependabot
dependabot Bot deleted the dependabot/github_actions/actions/checkout-5 branch November 25, 2025 00:05
cabljac added a commit that referenced this pull request Apr 28, 2026
Address three more Copilot comments. All three are real:

#1. Nested redaction blocks were leaking. The previous iterative-regex
    approach matched from the FIRST opening marker to the FIRST closing
    marker, which on properly-nested input (outer wraps inner) ate the
    inner pair and left an orphaned outer close marker plus the
    intended-private content between the inner close and the outer
    close. Replaced with `stripInternalBlocks` — a depth-tracking pass
    that walks all marker positions in document order and emits content
    only when depth=0. Defaults: unmatched close drops the marker and
    keeps surrounding content; unmatched open drops to end-of-input
    (fail-safe — better to drop too much than leak intended-private
    content upstream).
    Exported as `stripInternalBlocks` (with @internal JSDoc) so it can
    be unit-tested directly. 9 tests in tests/redaction.test.ts cover
    sibling, nested, whitespace tolerance, dangling close, unmatched
    open, multi-line, and lastIndex-reset cases.

#2. The fallback compare URL (shown when --pr wasn't set or
    `gh pr create` failed) used `plan.upstreamDefaultBranch` instead
    of `baseBranch`. With `--base develop`, the URL pointed at `main`.
    Now uses the resolved `baseBranch`.

#3. `syncPulledPr` was writing pulledPrs head/lastSyncedAt even when
    the push to origin failed, so the mirror state diverged from the
    recorded linkage. Same fix pattern as the recent
    pullRequestCommand bug: skip the config write when push fails.
    Tests cover the no-write-on-push-fail case.

Plus: replaced `gh issue list --search` with plain `--state all --limit
20` in the Tier 5 e2e and added a 5-attempt retry around it. GitHub's
search index AND list endpoint both have eventual-consistency lag for
freshly-created issues; the retry makes the test deterministic.

Verification
- 239 unit tests pass / 0 fail (was 229; +10 new tests)
- 4 e2e tiers pass / 0 fail across 3 consecutive runs (was 1 flaky run
  in 3 before this fix)
- Lint clean, build OK
cabljac added a commit that referenced this pull request Apr 28, 2026
…/pull (#32)

* refactor(commands): extract planStaging + executeStagingPush from stageCommand

Pre-factor for shipCommand. No behaviour change — all 172 unit tests pass
unchanged. The split:

- planStaging(branch, cwd): pure read. Verifies the branch, resolves public
  and upstream remotes, detects upstream's default branch, checks whether
  scheduled sync is enabled. Returns a StagingPlan that callers display
  before any user confirmation.
- executeStagingPush(plan, cwd, spinner): the actual push. Strips the
  internal workflow commit when scheduled sync is on; otherwise direct
  branch push. Returns the SHA pushed.

stageCommand now reads as: auth → plan → confirm → execute → render PR URL.
shipCommand will reuse plan + execute, slotting an internal-PR lookup and
upstream PR creation between confirm and the post-execute step.

* feat(config): add shippedBranches and pulledPrs to VenforkConfig

Two new branch-keyed maps in `venfork-config`:
- shippedBranches: linkage between an internal review PR and the upstream
  PR it was promoted to (recorded by `venfork stage --pr`).
- pulledPrs: tracking record for upstream PRs pulled into the mirror for
  internal review (recorded by `venfork pull-request`, used by
  `venfork sync <branch>` to refresh).

Patch semantics: per-entry merge with `null` to delete a single entry,
or `null` for the whole field to clear the map. Same shape for both.

Per-entry validation drops malformed records during normalize so a
bad write never wedges the config branch.

No callers yet — just the schema + the patch plumbing.

* feat: add stage --pr, pull-request, and sync <pulled-branch>

Three user-visible additions for 0.6.0, all sharing the same use case:
closing the round-trip between internal review on the private mirror and
upstream PR activity.

stage --pr (opt-in)
- `venfork stage <branch> --pr` runs the existing stage logic, then opens
  the upstream PR via `gh pr create --repo <upstream> --head <fork>:<branch>`.
- The upstream PR body comes from the most recent internal-mirror PR for
  that branch (open first, then any state). Blocks delimited by
  `<!-- venfork:internal -->...<!-- /venfork:internal -->` are stripped
  before posting; a footer linking back to the internal review is appended.
- The translated body is shown in a confirm prompt before any push happens
  so contractors can catch redaction mistakes before they go public.
- The internal/upstream PR pair is recorded in
  `venfork-config.shippedBranches[<branch>]` for follow-up tooling.
- `--draft`, `--title <text>`, `--base <branch>` flags supported. `--draft`
  implies `--pr`. Default `stage` behaviour without `--pr` is unchanged.

pull-request <pr-number-or-url>
- Brings a third-party upstream PR's commits onto a local branch (default
  `upstream-pr/<n>`) and pushes it to the mirror so the team can review
  internally before it lands upstream.
- Reads PR metadata via `gh pr view --json`; renders title/author/state/
  body preview as a `p.note` so the team has context.
- Refuses to clobber an existing local branch unless --branch-name is
  passed (prevents stomping on a previous review).
- `--no-push` skips the mirror push for purely-local inspection.
- Records `pulledPrs[<branch>]` so sync can later refresh.

sync <pulled-branch>
- When the targetBranch matches a `pulledPrs` entry (or the
  `upstream-pr/<n>` naming convention), syncCommand refetches
  `pull/<n>/head` from upstream and force-with-lease pushes to origin
  instead of running the default-branch +1-commit sync flow.
- Falls back to the convention if no config entry exists, so users can
  refresh branches that were created manually.

Tests
- 28 new tests across stage-args, pull-request-args, stageCommand --pr
  flow, pullRequestCommand happy/sad paths, and syncCommand pulled-PR
  routing. Total: 203 pass / 0 fail.

Docs
- README: rewrote `venfork stage` section with the new flags + the
  redaction marker convention; added `venfork pull-request` section and
  a sync-pulled-branch note. Quick Start example updated.
- showHelp() output covers the new commands.

* feat: add venfork issue stage/pull + e2e fixtures for ship/pull/sync

issue stage|pull
- `venfork issue stage <internal-#>` reads an internal mirror issue,
  applies the same `<!-- venfork:internal -->...<!-- /venfork:internal -->`
  redaction as stage --pr, and opens the upstream counterpart.
- `venfork issue pull <upstream-#>` creates an internal triage issue on
  the mirror titled `[upstream #N] <title>` referencing the upstream
  source, so teams can review without leaving the private space.
- Both record one-shot linkage in venfork-config (`shippedIssues` /
  `pulledIssues` maps). No comment sync — these are audit-log records.

Config schema additions
- `ShippedIssue` and `PulledIssue` types alongside ShippedBranch / PulledPr.
- Same per-entry merge + null-clear semantics in updateVenforkConfig.
- Per-entry validators drop malformed records during normalize.

E2E fixtures (Tier 3, 4, 5)
- Tier 3: stage --pr against real GitHub. Creates a feature branch + an
  internal review PR with a redaction block, runs `venfork stage feat
  --pr --draft`, asserts the upstream PR exists with the redacted block
  removed and isDraft=true.
- Tier 4: pull-request + sync upstream-pr. Opens an upstream PR via the
  contents API, runs `venfork pull-request <n>`, asserts mirror branch
  matches local. Pushes another commit upstream, runs `venfork sync
  upstream-pr/<n>`, asserts the mirror branch advanced.
- Tier 5: issue stage + pull round-trip. Creates an internal issue with
  a redaction block, stages it upstream, asserts redaction applied.
  Creates an upstream issue, pulls it into the mirror, asserts the
  internal copy has the [upstream #N] prefix and links back.

Tests
- 12 new unit tests across issue-args (6) + issueCommand (5) + config
  round-trip (1). Total: 215 pass / 0 fail.
- E2E tiers 3-5 add 3 tests gated behind VENFORK_E2E=1; all use the
  same beforeAll setup as Tier 1, sharing the upstream/mirror/fork repos.

Helpers
- `openUpstreamPr` (creates branch + commit + PR via the contents API)
- `pushToUpstreamPrBranch` (adds another commit to an existing PR
  branch — used by the sync test)
- `createIssueOnRepo`, `getIssueMeta`, `getPrMeta` for assertions

Docs
- README: new `venfork issue` section under Commands; quick-start
  example unchanged (issues are an opt-in flow).
- showHelp: includes the new sub-command.

* fix(commands): VENFORK_NONINTERACTIVE bypass for stage --pr / issue prompts

clack's confirm reads keypresses, so piping `y\n` over stdin in non-TTY
mode doesn't reliably resolve the prompt — the e2e Tier 3 (stage --pr)
and Tier 5 (issue stage/pull) tests both saw stdin EOF interpreted as
cancel, exiting cleanly without performing the action.

- New `confirmOrAutoYes` wrapper returns true immediately when
  `VENFORK_NONINTERACTIVE=1` is set; otherwise delegates to `p.confirm`.
- Applied to stageCommand's "push to public fork" prompt and to both
  issueCommand prompts (stage + pull).
- Setup's personal-account safety prompt is intentionally NOT bypassed
  — that one's a guardrail against accidental personal-account creation
  in scripts.
- Tier 3 and Tier 5 e2e tests now set the env var instead of piping
  `y\n`.
- README documents the env var under Environment Variables.

* fix(stage): pass --state value as separate arg in gh pr list lookup

Internal-PR lookup was passing '--state open' as a single execa template interpolation, which gh either rejects or filters wrong — producing zero results and triggering the synthetic-body fallback. Splitting into '--state ${state}' separates the flag from the value.

* test(e2e): tier 3 filters upstream PR by headRefName instead of --head

gh's --head filter on cross-repo PRs is unreliable — listing all PRs and filtering in JS by headRefName + headRepositoryOwner is robust.

* fix: review-found issues for 0.6.0

Self-review surfaced 11 issues; fixing all of them on this branch before
opening the PR keeps the release cohesive.

Stage / PR creation
- Synthetic body (when no internal review PR exists) is now generated
  from `git log --oneline upstream/<default>..<branch>` instead of a
  literal "please add a description" placeholder. The upstream maintainer
  sees real commit subjects.
- Redaction marker stripping iterates until no marker pair remains —
  nested `<!-- venfork:internal -->` blocks now redact correctly.
- `--internal-pr <n>` flag pins a specific internal PR (skips the
  most-recent-open lookup). Useful when a branch has had multiple
  internal PRs and you want to ship from a specific one.
- When `gh pr create` reports "already exists", venfork now follows up
  with `gh pr edit --body-file -` to refresh the upstream PR body from
  the (possibly updated) internal review. `--no-update-existing` opts out.

URL handling
- `resolvePullRequestArg` and `resolveIssueArg` now throw instead of
  warning when the URL's owner/repo doesn't match the upstream remote.
  Pasting a stray URL no longer silently uses upstream as the source.

Pull-request flow
- `pullRequestCommand` only records the `pulledPrs` linkage when the
  push to origin actually succeeded. Previously the entry was written
  even on push failure, leading `venfork sync <branch>` to think the
  mirror had a branch it didn't.

Config branch
- `writeConfigBranch` switches from `git push --force` to an explicit
  `--force-with-lease=venfork-config:<sha>`, where the expected SHA
  comes from a fresh `git ls-remote`. Concurrent venfork commands now
  fail loud (the second push errors) instead of silently overwriting
  each other.

Status
- `venfork status` now reads `venfork-config` and renders four
  best-effort linkage blocks: shippedBranches, pulledPrs, shippedIssues,
  pulledIssues. No live `gh` round-trips per entry.

Tests
- Stage-args: --internal-pr (value + equals form, validation), --no-update-existing.
- Stage command: regression for the `--state open` execa-arg bug (asserts the rendered command has `--state` separated from `open`); --internal-pr override path; auto-update via `gh pr edit`; `--no-update-existing` opt-out; VENFORK_NONINTERACTIVE bypass.
- Total: 215 → 225 pass.

Docs
- README: `--branch-name` caveat under `pull-request`; new "Concurrency"
  subsection under environment variables explaining the
  --force-with-lease retry behaviour.

* fix: apply all reviewer feedback from PR review thread

Agent-Logs-Url: https://github.com/cabljac/venfork/sessions/1ed2e6ce-a0f0-4ce4-a530-8550020fc15d

Co-authored-by: cabljac <32874567+cabljac@users.noreply.github.com>

* style(commands): wrap translateInternalBody call to satisfy biome line-length

* fix: address copilot review round 2

- Tighten issue/PR number validation in normalizers to require
  positive integers (was: any finite number). Hand-edited config with
  garbage numbers (negatives, floats, NaN) is now dropped during
  normalize. New `isPositiveInt` helper de-duplicates the four call sites.

- Fix --no-push semantics in `pullRequestCommand`. Previously initialised
  `pushSucceeded = !push`, which evaluated to `true` when the user passed
  --no-push and let the flow fall through to record a pulledPrs entry.
  A later `venfork sync <branch>` would then push the branch to the mirror
  unexpectedly, defeating --no-push. Renamed to `pushedToMirror` (defaults
  to false) and skip the linkage write whenever the mirror doesn't have
  the branch — covers both the push-fail path and --no-push.

- Strengthen the existing --no-push unit test to assert no pulledPrs
  config write happens, not just no `git push origin`.

E2E smoke green: 4 pass / 2 skip / 0 fail / 85s.

* feat(config): auto-retry venfork-config writes on lease failure

Concurrent venfork commands previously crashed the losing run with a
"stale info" error; the user had to re-run by hand. The losing run
already has all the information needed to recover automatically (its
patch, the read+write helpers, the lease semantics). Wrap the
read-merge-write cycle in a bounded retry loop so the recovery is
invisible.

How it works

- New `fetchConfigContentAndSha` reads the venfork-config branch
  content together with the SHA of the commit it came from, in a
  single fetch. Capturing the read-from SHA is what makes the lease
  correct under concurrency — a separate ls-remote right before the
  push would race with concurrent writers.
- `writeConfigBranch` now accepts an optional `expectedSha`. When set,
  the push leases against that exact SHA (the one we read from). The
  ls-remote fallback stays for first-time writes (`createConfigBranch`).
- `updateVenforkConfig` runs the full read → merge-patch → write cycle
  in a `for` loop bounded at MAX_RETRIES = 3. On a lease failure (git
  stderr containing "stale info" or the rejected-with-stale-info
  pattern) it re-reads, re-applies the same patch on top of the now-
  fresh content, and retries. Auth/network failures are NOT retried.
- Patch application is factored into `applyPatchAndNormalize` so the
  same patch is applied cleanly on each retry without duplication.

Tests (4 new, 11 total in tests/config.test.ts)

- Push uses --force-with-lease against the read sha (not a fresh
  ls-remote).
- Stale-info on first push → re-read → retry succeeds; final push
  leases against the new sha.
- Three consecutive stale-info failures → throws after attempt 3.
- Auth-failure error message → throws immediately, no retry.

Docs

- README "Concurrency" subsection rewritten: explains that concurrent
  runs are normally invisible (auto-retry merges both updates) and
  surfaces the manual-retry expectation only after sustained contention.

* fix: round-3 review (nested redaction, --base URL, sync push fail)

Address three more Copilot comments. All three are real:

#1. Nested redaction blocks were leaking. The previous iterative-regex
    approach matched from the FIRST opening marker to the FIRST closing
    marker, which on properly-nested input (outer wraps inner) ate the
    inner pair and left an orphaned outer close marker plus the
    intended-private content between the inner close and the outer
    close. Replaced with `stripInternalBlocks` — a depth-tracking pass
    that walks all marker positions in document order and emits content
    only when depth=0. Defaults: unmatched close drops the marker and
    keeps surrounding content; unmatched open drops to end-of-input
    (fail-safe — better to drop too much than leak intended-private
    content upstream).
    Exported as `stripInternalBlocks` (with @internal JSDoc) so it can
    be unit-tested directly. 9 tests in tests/redaction.test.ts cover
    sibling, nested, whitespace tolerance, dangling close, unmatched
    open, multi-line, and lastIndex-reset cases.

#2. The fallback compare URL (shown when --pr wasn't set or
    `gh pr create` failed) used `plan.upstreamDefaultBranch` instead
    of `baseBranch`. With `--base develop`, the URL pointed at `main`.
    Now uses the resolved `baseBranch`.

#3. `syncPulledPr` was writing pulledPrs head/lastSyncedAt even when
    the push to origin failed, so the mirror state diverged from the
    recorded linkage. Same fix pattern as the recent
    pullRequestCommand bug: skip the config write when push fails.
    Tests cover the no-write-on-push-fail case.

Plus: replaced `gh issue list --search` with plain `--state all --limit
20` in the Tier 5 e2e and added a 5-attempt retry around it. GitHub's
search index AND list endpoint both have eventual-consistency lag for
freshly-created issues; the retry makes the test deterministic.

Verification
- 239 unit tests pass / 0 fail (was 229; +10 new tests)
- 4 e2e tiers pass / 0 fail across 3 consecutive runs (was 1 flaky run
  in 3 before this fix)
- Lint clean, build OK

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
cabljac added a commit that referenced this pull request Oct 2, 2026
Three gaps in findDeniedText:

- File content: a bare mirror repo name now needs a host before it
  (github.com/, git@host:, ssh://host/, https://host/). A package or
  directory with the same name, such as "acme-ui/widget-private", no
  longer blocks a stage. Commit messages, branch names, PR text and file
  names keep the loose owner/name match.
- The bare-name lookahead rejected any "/" or "." after the name, so
  "https://github.com/other/<name>/pull/3", "other/<name>." at a
  sentence end and "other/<name>.git." slipped through. A hosted match
  now allows any path after the name, and a trailing "." counts only
  when it starts a file extension.
- Every term except "venfork" now ends at a repo-name boundary, so
  "acme/widget-private-public" no longer matches "acme/widget-private"
  while ".git", "/pull/1", "#7" and "?x" suffixes still do.
cabljac added a commit that referenced this pull request Oct 6, 2026
…e to upstream (#83)

* chore(deps): bump execa to ^9.6.1

* fix(stage): detect managed commits from history, not schedule config

`venfork stage` only sanitized the branch when `schedule.enabled` was
set in config. With a `preserve` allowlist and no schedule, sync still
writes the managed "+1 commit" (carrying mirror-only files) onto
origin/<default>, so any branch cut from it pushed that commit raw to
the public fork, or straight to upstream in --no-public mode.

Stage now walks `upstream/<default>..<branch>` and takes the sanitize
path whenever any commit matches `isManagedCommit`. This also covers
branches cut before a schedule was disabled. Branches with no managed
commit push as-is with unchanged SHAs. If the range cannot be inspected
(upstream ref missing locally), stage refuses to push rather than
falling back to a raw push.

* refactor: split commands.ts into per-command modules

Move every command into src/commands/<command>.ts and the helpers shared
between commands into src/shared/ (net, managed-commit, mirror-commit,
divergence, redaction, worktree, confirm, constants). src/commands.ts is
now a barrel, so index.ts and the tests import unchanged.

No behaviour change. Two mechanical extractions beyond plain moves: the
divergence closure inside syncCommand becomes checkDivergence() with
explicit parameters, and the mkdtemp/worktree add/remove pattern used
three times becomes withDetachedWorktree(). Stage now runs the evil-merge
check before creating its temp worktree instead of just after.

* test: add real-git integration harness

Unit tests mock execa with mock.module, which leaks across files in one
bun process, so they move to tests/unit and the new real-git tests live
in tests/integration. `bun run test` runs the two suites as separate
processes; CI calls them as separate steps.

tests/harness/mirror-fixture.ts builds upstream/origin/public bare repos
in a temp dir, wires a working clone the way `venfork setup` does, writes
the venfork-config branch, and isolates git from the developer's global
config. Fixture commits use pinned dates; commands under test use the
real clock unless a test sets one.

Two changes make commands callable from tests without changing CLI
behaviour:
- syncCommand throws SyncDivergenceError (counts and files per remote)
  instead of calling process.exit(1). index.ts prints it and exits 1.
- The gh auth check moves from the command bodies into the index.ts
  dispatcher (ensureGhAuth). `stage` only requires gh with --pr/--draft.

Two sync tests are test.todo: they pin the idempotent, single-push sync
that the next change implements.

* fix(sync): copy preserved files with git checkout

applyMirrorPlusOneCommit read each preserved file with
`git show <tip>:<path>` through execa's `encoding: 'buffer'`. Under the
Bun runtime that option crashes child_process with "Unknown encoding:
buffer", so `venfork sync` with a preserve list failed when run via
`bun run dev` or the `bun build --compile` binary. Node was unaffected.

The managed-commit worktree now runs `git checkout <tip> -- <path>`,
which writes the file and stages it with its recorded mode. That drops
the separate ls-tree/chmod step for the executable bit.

Caught by the new real-git integration tests, which run under Bun.

* test(harness): point HOME at a fixture directory

GIT_CONFIG_GLOBAL already keeps git away from the developer's config. Pointing HOME at an empty directory under the fixture root also stops any other tool from reading the real home during integration tests. The previous value is saved and restored with the other keys.

* fix(config): reject glob and pathspec-magic preserve entries

Preserve entries are passed to git as pathspecs. A glob such as 'src/file-*.txt' made sync silently revert upstream changes to every matching file, and ':!nothing' rolled the whole tree back to the previous mirror tip. normalizePreservePath now rejects '*', '?', '[', ']' and a leading ':', so such entries are refused by 'venfork preserve add' and dropped from config. Preserve entries are single literal files.

* fix(sync): restore preserved files literally and only as single files

Each preserve entry was restored with `git checkout <tip> -- <path>`,
which treats the path as a pathspec and accepts directories. Sync now:
- runs ls-tree and checkout with --literal-pathspecs;
- requires exactly one blob entry (100644, 100755 or 120000) on the
  previous mirror tip, and rejects directories and submodules with an
  error that names the path;
- refuses when a parent directory of the path is now a file upstream
  (for example preserve 'config/x.yml' after upstream adds a file
  'config'); before, the checkout silently deleted upstream's file;
- includes git's stderr when the checkout itself fails.

The test prompt stub gains select/multiselect/group no-ops and a mocked
log.error so tests can assert on error text.

* test(harness): isolate the fixture from every GIT_* variable

With GIT_DIR exported (as inside husky or lefthook hooks) the
integration suite wrote commits into the parent repository, and
GIT_CONFIG_COUNT/KEY_n/VALUE_n bypassed GIT_CONFIG_GLOBAL.
isolateGitEnv now snapshots and hides every GIT_* variable plus HOME,
XDG_CONFIG_HOME, GH_TOKEN and GITHUB_TOKEN, applies the fixture's own
values, and restores the exact snapshot on cleanup. Nested fixtures
restore in reverse order.

createMirrorFixture restores the environment and removes its temp dir
when the build throws, and the integration tests only clean up a
fixture that was actually created.

* test: make bare bun test run only the unit suite

A bare 'bun test' loaded unit, integration and e2e files into one process, where the unit tests' execa mock broke the real-git tests. bunfig.toml sets the test root to tests/unit. 'bun run test' still runs both suites, and the e2e script now passes an explicit ./tests/e2e path because a bare filter no longer reaches outside the root.

* refactor(cli): decide gh auth per command in one tested function

The per-command AuthenticationError tests went away when the gh check moved to the dispatcher, leaving that decision untested. requiresGhAuth(command, args) now holds it: setup, clone, pull-request and issue always need gh; stage needs it only with --pr or --draft; every other command runs without it. index.ts calls it once before dispatch, and a table test covers each command and stage flag.

* ci: pin Bun to 1.2.18

CI used the latest Bun while the suites are developed and verified on 1.2.18. Pin both jobs so a Bun release cannot change test or build behaviour unnoticed.

* fix(sync): make scheduled sync idempotent and push once

Every sync rewrote origin/<default>: the managed commit was a fresh
`git commit --allow-empty` with the wall-clock date, so its SHA changed
on every cron tick and teammates saw a diverged default branch. Sync
also pushed bare upstream to origin first and re-added the managed
commit in a second push, leaving the workflow file off the default
branch in between; a runner dying there stopped cron for good.

Sync now builds the target tip before pushing anything:
- The managed commit is made with `git commit-tree`, bot author and
  committer, both dates set to the upstream tip's committer date, and a
  `Venfork-Managed: 1` trailer. Its SHA depends only on the upstream tip
  and the tree.
- When the managed content adds nothing to the upstream tree, there is
  no managed commit and origin equals upstream (+0).
- Origin gets one push with an explicit
  `--force-with-lease=refs/heads/<d>:<previous tip>`, or none when it
  already points at the target. Public is skipped when it already equals
  upstream.

isManagedCommit checks the trailer first and keeps the subject, legacy
subject and path fallbacks. checkDivergence treats only a missing remote
branch as "first sync"; any other git failure now aborts instead of
being read as zero divergence.

The first sync after upgrading rewrites the managed commit once; later
syncs are stable.

* fix(sync): harden the deterministic tip and re-stamp on schedule changes

Review findings on the idempotent sync:
- `i18n.commitEncoding` added an encoding header to the commit-tree
  object and changed the managed commit's SHA. commit-tree now runs with
  `-c i18n.commitEncoding=UTF-8`.
- `schedule set` / `schedule disable` still made a wall-clock managed
  commit on top of origin, leaving upstream + managed + managed until the
  next sync. They now fetch, refuse on origin divergence, update config
  and re-stamp origin through the same updateOriginTip path as sync, so
  a following sync pushes nothing. updateWorkflowOnOriginDefault is gone.
- upstream/<d> is resolved before the divergence checks, so a missing
  upstream branch gets the friendly error instead of a rev-list failure.
- A stale lease on the origin push now says origin moved since the fetch
  and to re-run sync.
- The sync fetches and pushBranchWithLease go through netExec (no stdin,
  VENFORK_GIT_TIMEOUT); netExec gains a buffered, non-rejecting mode.

Tests: encoding and stale-lease message (red first), schedule set/disable
producing one managed commit that sync keeps (red first), missing
upstream error (red first), plus migration stability for legacy and
trailer-less managed commits and trailer-first detection.

* feat(schedule): pin the venfork version in the generated workflow

The sync workflow ran `npm install -g venfork`, so every run picked up
whatever was latest on npm. A new release could change the generated
YAML or sync behaviour under a mirror without anyone running it locally.

generateSyncWorkflow now takes a version (default: the running CLI,
read from package.json and inlined by `bun build`) and emits
`npm install -g venfork@<version>`. On a runner the running version
equals the pin, so the regenerated YAML matches the default branch and
the managed commit stays stable. Running a newer CLI locally bumps the
pin; that is the upgrade path.

Adds `venfork --version`. scripts/release.ts checks RELEASE_TAG against
package.json and runs the host binary's --version after the build.

* feat(schedule): allow overriding the venfork install spec in the sync workflow

The install step now reads the VENFORK_INSTALL_SPEC repository variable
and falls back to the pinned venfork@<version>. A tarball URL, git spec
or other version can replace the published package, for example to run
unreleased sync code or to bridge the window before npm publish lands.

* feat(doctor): add venfork doctor health checks

Scheduled sync fails quietly: a missing VENFORK_PUSH_TOKEN, a stale or
missing workflow file, a preserved file that vanished, or a cron that
GitHub paused all surface only as red runs nobody looks at.

`venfork doctor` (also `venfork status --check`) runs ten checks: repo,
remotes, mode, invariant, divergence, preserve, workflow, token,
last-run and cron-age. Each returns { id, ok, detail, fix? } with ok as
true, false or 'skipped'. It prints a table, or JSON with --json, and
exits 1 when any check fails. It reads only, apart from fetching the
remotes and the config branch.

The gh-backed checks are skipped when gh is not authenticated or origin
is not a GitHub repo. cron-age flags a last run older than twice the
longest gap between cron fire times, with a one-hour floor because
GitHub routinely delays scheduled runs.

* fix(doctor): run network calls through netExec

The fetch, gh secret list and gh run list calls in doctor used raw execa, so a credential or host-key prompt could hang and there was no timeout. They now use netExec with a new bufferOutput mode (piped, buffered, never rejects), so they get NET_ENV, no stdin and VENFORK_GIT_TIMEOUT. A failed or timed-out gh call still reports its check as skipped, with the reason from netFailureReason.

* feat(sync): report blocked and failed scheduled syncs as a mirror issue

A scheduled sync that aborts on divergence, or fails for any other
reason, was only a red run in the Actions tab. Nobody gets told.

Layer A, in the CLI: `venfork sync --report-issues` (the generated
workflow passes it). On SyncDivergenceError it opens an issue labelled
venfork-sync-blocked on the mirror, or refreshes the body of the open
one, with the divergence report: remotes, changed files, and the exact
`venfork preserve add` / rebase steps. A successful sync comments
"Resolved by <run>" and closes it. gh failures are logged and never mask
the sync result. The divergence report text moves to
formatDivergenceReport so the console note and the issue share it.

Layer B, in the YAML: the job gains `issues: write`, the sync step gets
GH_TOKEN from github.token, and a final `if: failure()` step opens the
same labelled issue, or comments the run URL on it unless the CLI
already wrote that run into the body. This covers npm install failures,
a missing token and timeouts.

* fix(utils): accept only github.com owner/repo in parseRepoPath

parseRepoPath matched "github.com" anywhere in the string and kept
whatever followed. A trailing slash gave "acme/w-private/", ".git/" gave
"acme/w.git/", an ssh URL with a port gave "22/acme/w", and
"https://evilgithub.com/acme/w" was accepted as "acme/w", so gh would
target github.com/acme/w. With `sync --report-issues` taking the repo
from origin, that is a way to post mirror details to the wrong repo.

The host must now be exactly github.com (or www.github.com) after an
optional scheme and user info, an optional port is skipped, trailing
slashes and ".git" are stripped, and the result must be exactly
owner/repo. Anything else returns the existing empty-string sentinel.

* fix(schedule): serialize sync runs and leave an existing label alone

The generated workflow could start overlapping runs (a manual dispatch
during a scheduled one) and had no time cap beyond GitHub's six hours.
It now sets `concurrency: { group: venfork-sync, cancel-in-progress:
false }` and `timeout-minutes: 30` on the job. The failure step creates
the venfork-sync-blocked label only when `gh label list` does not find
it, instead of overwriting it with --force.

* fix(sync): report blocked syncs only to the private mirror, safely

Review findings on --report-issues:
- Target repo: GITHUB_REPOSITORY inside Actions; otherwise origin's
  GitHub repo, and only after `gh repo view --json isPrivate` returns
  true. Anything else logs a warning and skips reporting, so mirror
  details never reach a public repo.
- gh calls go through netExec (no prompts, VENFORK_GIT_TIMEOUT); netExec
  can feed fixed stdin for --body-file -.
- Title and lead line name the remotes that actually diverged
  (origin, public, or both) instead of always origin.
- The file lists are capped at 100 paths with "...and N more" and the
  body stays under 60000 characters.
- `gh issue edit` also sets the title, so an issue opened by the YAML
  failure step gets the divergence title.
- The label is created only when `gh label list` lacks it (no --force).
- A successful sync closes every open labelled issue, not just one.

The commands tests now give each remote its own URL and assert that gh
targets the origin repo.

* fix(stage): lease public pushes explicitly and name preserved paths

Standard-mode stage pushed the sanitized branch with an implicit
`--force-with-lease=refs/heads/<b>`, whose expected value comes from
refs/remotes/public/<b>. When that tracking ref was missing or stale
(staged from another clone, or never fetched) the push was rejected as
"stale info". Both modes now read the remote tip with ls-remote and pass
it as an explicit lease; a branch that does not exist yet is leased as
"must not exist".

When a commit cannot be replayed onto upstream because it changes a
preserved mirror-only path, the error now names the path and says to
move that change to the mirror default branch, instead of a generic
"rebase and retry".

* test(e2e): drop the unimplemented real-cron tier and fix stale subjects

The tier-2 real-cron test only threw "not yet implemented". It is
removed with its env flag and the test:e2e:cron script; real cron timing
is best-effort and would make the test flaky anyway.

The e2e assertions still expected the legacy managed-commit subject.
They now expect "chore: venfork-managed mirror commit", and
listCommitMessages returns subject lines only so the Venfork-Managed
trailer does not shift the indices.

* docs: explain how scheduled sync behaves

Adds a README section on cron being best-effort, GitHub's 60-day idle
rule (which applies to public repositories), why the workflow file must
stay on the default branch, the VENFORK_PUSH_TOKEN requirement, the
failure issue, and the upgrade path. The doctor cron-age hint now says
the idle rule is for public repositories.

* test(e2e): run the dispatched sync with the code under test

Tier 2 installed the published venfork on the runner, which lags the
checkout and rejected new flags such as sync --report-issues. The test
now packs the fresh build, uploads it as a release asset on the public
upstream repo and points the mirror's VENFORK_INSTALL_SPEC variable at
it. Tier 2 also asserts that no venfork-sync-blocked issue is open.

The README documents VENFORK_INSTALL_SPEC and the window after a
release tag before npm publish finishes.

* test: stub process.exit for every test file in a preload

A command that calls process.exit(0) ended the bun process with a passing
status, so a broken command could turn both suites green. The preload
throws instead, and the per-file stubs are gone.

* ci: fail the job when a suite runs fewer tests than its floor

Each suite writes a junit report and scripts/test-count.ts compares the
executed count with tests/expected-counts.json, so a suite that silently
stops loading files cannot pass.

* test(sync): pin that a failing divergence range aborts sync

A mutation that swallowed rev-list failures and reported no divergence
survived the suite. An integration test deletes the upstream ref and a
unit test makes rev-list fail with execa's reject semantics; both now
fail against that mutation. The unit mock passes execa options to
function responses so tests can model reject: false.

* test(harness): isolate GH_* and VENFORK_*, enforce LIFO cleanup

isolateGitEnv now hides every GH_* and VENFORK_* variable and restores
override keys too. Fixtures take a tmpRoot so the failed-build test no
longer lists the shared temp dir, cleanup throws when nested fixtures are
released out of order, and the preload clears the quietPrompts log.error
mock before each test. The harness tests snapshot and restore the env
keys they touch, and the sentinel repo is created without GIT_* set.

* test(sync): prove the managed SHA ignores the wall clock

The idempotency test pinned GIT_COMMITTER_DATE itself, which the managed
commit overrides anyway, and both syncs could land in the same second.
It now waits for the next second between syncs, so it fails if the
managed commit stops taking its dates from upstream.

* ci: pin Bun from one .bun-version file and test before release

ci.yml and release.yml both read .bun-version, so the release no longer
builds with whatever Bun is latest. Both release jobs run the unit suite
before they build.

* chore(release): keep breaking changes on the minor line before 1.0

With bump-minor-pre-major, a breaking change released from 0.x bumps the
minor version instead of jumping to 1.0.0.

* fix: require a Node.js version that execa 9 supports

execa 9 needs ^18.19.0 || >=20.5.0, so engines.node now says so instead
of >=18.0.0.

* test(cli): spawn the built entry point for --version and gh auth

index.ts had no test. The integration suite now builds the CLI, runs
node dist/index.js --version, and runs setup with a gh stub that fails
auth, expecting exit 1 and the AuthenticationError message. The test
count floors are raised to the new totals.

* fix(config)!: fail closed on an unreadable or invalid venfork-config

A config problem used to read as no config, so sync reset origin to bare
upstream and deleted preserved files. readVenforkConfigFromRepo and
fetchVenforkConfig now return null only when git ls-remote --exit-code
says the branch is absent; a fetch, JSON or validation failure throws
ConfigError, which sync propagates. Invalid preserve entries (globs,
pathspec magic, directories) are kept in invalidPreserve instead of being
dropped: sync and schedule refuse to run and name each entry with its
venfork preserve remove fix, remove accepts them, and saves write them
back. schedule.cron is validated with the shared isValidCronExpression;
cron.ts rejects step 0 and macros and searches 800 days. The managed
path heuristic now requires the bot author, and sync lists commits it
replaces on a signal weaker than the trailer. Doctor tells a fetch
failure, a missing branch and invalid content apart.

BREAKING CHANGE: sync and schedule now abort while the preserve list
holds an entry that is not a single literal file path. Remove it with
venfork preserve remove <entry>.

* fix(sync): route remaining network calls through netExec

getDefaultBranch's git remote set-head -a and the pulled-PR fetch now run
with no stdin and the network timeout. The pulled-PR push uses an
explicit lease on the mirror copy of the branch instead of a bare
--force-with-lease, so a teammate's push to that branch is never
overwritten. Config reads and writes already go through netExec.

* test: raise the test count floors

* fix(sync): build the managed tip with plumbing on a temporary index

The managed commit was assembled in a detached worktree, so the user's
sparse-checkout, post-checkout hooks, clean filters, core.quotePath and
case-insensitive filesystem could change its tree or its SHA. buildMirrorTip
now reads the upstream tree into a temporary index (GIT_INDEX_FILE, no
inherited GIT_DIR or GIT_WORK_TREE, empty hooks directory), adds the
workflow blob with hash-object --no-filters, removes blocked workflows by
exact path, and copies preserved files as blobs after checking upstream's
tree rather than the filesystem. Path listings in the divergence check
and the managed-commit heuristic use -z, so non-ASCII names compare by
exact bytes. The README documents that a preserved path upstream later
deletes keeps upstream's last version.

* fix(stage): check out the stage worktree with an empty hooks directory

A post-checkout hook in the clone ran inside stage's temporary worktree
and could stage files into the cherry-picked branch. The worktree add and
each cherry-pick now run with core.hooksPath pointing at an empty
directory.

* style: format the stage test expectations

* fix(redaction): match marker variants and fail closed on leftovers

Markers now match case-insensitively with optional whitespace around the
slash and colon and a note after the keyword. An unmatched close marker,
or any HTML comment mentioning venfork that survives stripping (such as
a misspelled marker), throws RedactionError instead of publishing. PR and
issue titles go through the same redaction as bodies. StageLeakError is
added for the stage boundary check.

* docs(claude): drop env overrides no code reads, document redaction rules

* fix(stage): refuse to publish mirror-only paths or non-upstream branches

Stage could leak the managed workflow or preserved files: a branch cut
from origin before sync, a squash onto upstream, or a cherry-pick carried
them in commits that are not managed, so the raw push sent them out. On
both the raw and the sanitized path, stage now diffs the head against
upstream/<default> before pushing and throws StageLeakError naming any
added or changed sync workflow or preserved path absent upstream. Stage
refuses venfork-config and branches with no merge-base with upstream,
lists every commit it drops as managed with its classification, builds
the synthetic PR body from published commit subjects only (no SHAs, no
managed commit), shows the whole body in the confirm preview, and
requires GitHub remotes for --pr. parseRepoPath accepts ssh.github.com.

* fix(schedule): install venfork before checkout, without lifecycle scripts

The generated workflow installed venfork after checking out the mirror
with the push token, so an install-time script ran with that token in
the git config. Install now runs first with --ignore-scripts (the bin
needs no lifecycle script; checked with a packed tarball). The
concurrency group includes the workflow name.

* fix(sync): trust GITHUB_REPOSITORY only when it names origin

resolveReportRepo filed the blocked-sync issue on whatever repository
GITHUB_REPOSITORY named. It now uses that value only when it equals
origin's GitHub repo, and otherwise falls back to checking that origin
is private.

* fix(sync): refuse to downgrade the pinned venfork version

An older CLI rewrote a workflow that pinned a newer venfork, so local and
runner syncs could keep rewriting each other's managed commit. Before
rebuilding the tip with a schedule, sync and schedule read the pin from
origin's venfork-sync.yml and abort when it is newer than the running
CLI. Doctor's workflow check names both versions.

* test(e2e): pin that the job token cannot push upstream workflow changes

Tier 6 sets up a no-public mirror with no VENFORK_PUSH_TOKEN, pushes an
upstream commit that edits .github/workflows/ci.yml, and dispatches the
sync. GitHub rejects the push with "refusing to allow a GitHub App to
create or update workflow ... without workflows permission"; the tier
asserts that failure, that the mirror did not move, and that the failure
step opened the venfork-sync-blocked issue. The code-under-test tarball
is published once per run and shared with tier 2.

* fix(schedule): require VENFORK_PUSH_TOKEN with workflow scope in both modes

The job's GITHUB_TOKEN cannot push upstream commits that change
.github/workflows (shown by e2e tier 6), so no-public mirrors need the
token too. Doctor's token check now runs in both modes and names the
workflow scope, and the schedule set outro says what the token is for
without mentioning a public fork in no-public mode. The README explains
the token requirement and the pinned-version rules: keep
VENFORK_INSTALL_SPEC unset or equal to the local CLI version, and use
0.11.0 or later.

* fix(doctor): make checks match sync behaviour and say what to run

The invariant check now separates stacked venfork-managed or preserve-only
commits (clean divergence; run venfork sync to fold them) from real
divergence. A failed fetch is one remotes row with a one-line reason and
the git-state checks are skipped; a missing upstream remote gets the exact
add and push-disable commands; a missing config branch, an unreachable
origin and invalid content each get their own fix.

cron-age reads only scheduled runs, fails with `gh workflow enable` when
the workflow is disabled, and no longer recommends a manual dispatch. An
in-progress run reads "in progress", and the outro counts failed, passed
and skipped checks instead of claiming all passed.

* feat(cli): add per-command --help and send CLI errors to stderr

`venfork <command> -h` or `--help` prints that command's usage and exits
0 before any gh auth check; `venfork help` still prints everything, built
from the same per-command sections. Argument errors and unknown commands
go to stderr. The sync help no longer ties the managed commit to an
enabled schedule, preserve help says single files only, and the
environment section lists exactly the variables the CLI reads.

An end-to-end test runs the built CLI's `doctor --json` against a real
fixture in four states (not a repo, healthy, failed fetch, invalid cron)
and parses stdout as JSON each time.

* docs: document preserve, doctor and the e2e tiers accurately

README gains a `venfork preserve` section: single files only, no globs
or directories, upstream wins on collision, what happens when upstream
later deletes a path it had taken over, and how `preserve remove` clears
an invalid entry. The sync heading lists --report-issues, status lists
--check and --json, troubleshooting starts at doctor, and the doctor table
notes the 1 hour cron-age floor, the disabled-workflow check and the
workflow-scope token requirement in both modes. The env section covers
every variable the CLI reads.

tests/e2e/README.md now explains the bunfig test root, names both default
owners, labels the PAT example as a fine-grained token and describes
tiers 3 to 6.

* fix(doctor): say where to re-run setup for a missing config branch

Also drops an em dash from the issue help text.

* fix(redaction): scan every HTML comment and refuse leftover venfork mentions

The marker regexes stopped at the first '>' inside a comment, so a note
such as 'owner->bob' or a '--!>' close let internal content through, and
plain-text 'venfork' was never checked. Redaction now scans each comment
from '<!--' to the first '-->' or '--!>', classifies it as an open or
close marker, refuses any other comment that mentions venfork, and
refuses any venfork mention left after stripping. Text is NFKC-folded
and stripped of format characters first, so zero-width spaces and
full-width colons cannot hide a marker.

Adds a mirror deny-list (origin URL, origin owner/name, 'venfork') and
MirrorReferenceError for the stage gate that follows.

* fix(git): never follow tags on any push

With push.followTags=true in the user's git config, every push venfork
makes could carry local annotated tags along. Every push in src (sync's
leased push, the config branch, pull-request, the seed push) now passes
--no-follow-tags.

* fix(stage)!: always rebuild the branch and gate every commit before pushing

Stage had two push paths. A branch without a managed commit was pushed
raw, so merge commits, mirror URLs in messages, internal markers, bot
authors, and preserved files added then deleted all reached the public
side, and `stage main` or `stage <tag>` pushed whatever the ref held.

Every stage now rebuilds the branch as linear history on
upstream/<default> (merges never ship) and checks each rebuilt commit
before any push: no change to the sync workflow, .venfork/ or a
preserved path (unless it is upstream's exact blob), no blob identical
to mirror-held preserved content at any path, no venfork bot identity,
and no origin URL, origin owner/name or 'venfork' in author, committer
or message. Upstream's default branch, tags and remote refs are refused
up front. The push uses an explicit refspec, a lease and
--no-follow-tags. PR titles and bodies get the same deny-list scan, the
synthetic body drops trailing (#N) references, and the confirm prompt
lists every commit subject.

BREAKING CHANGE: staged commits always get new SHAs, merge commits are
dropped, and branches whose commits mention the mirror or venfork are
refused.

* fix(sync): close integrity gaps in the managed commit, config writes and tip builder

- preserve refuses the sync workflow and anything under .venfork/
- a Venfork-Managed trailer commit is only managed when it changes the
  sync workflow, preserve entries and workflow deletions; anything else
  is divergence
- sync refuses a missing venfork-config branch while origin carries a
  managed commit; createConfigBranch refuses to overwrite an existing one
- schedule set/disable and preserve remove/clear build the tip and run
  the pin-downgrade guard before writing config, then push, and roll the
  config back when the push fails
- the config blob read goes through netExec; VENFORK_GIT_TIMEOUT is read
  per call
- tip builder: D/F and file-ancestor conflicts name both paths; workflow
  filtering only touches top-level workflow files
- remotes are read with git remote get-url (paths with spaces)
- replace tests that could not fail with real property tests

* fix(sync): require managed-only content for every managed kind

A commit with the managed subject, a legacy subject, or the bot
path-heuristic was skipped as venfork-managed whatever it changed, so
sync replaced it and dropped any user work it carried. Every kind now
has to change only the sync workflow, preserve entries and workflow
deletions; anything else is divergence. A bot commit that bundles a
second workflow file is now divergence too.

A trailer commit made and committed by the venfork bot whose only extra
changes add files no longer in preserve (a removal whose re-stamp never
reached origin, or an older venfork) is classified as stale-trailer:
sync warns and re-stamps it without those files, preserve remove works
again, and stage drops it instead of publishing the files. A trailer
commit amended by a user stays divergence.

The prompts harness now records log.warn as a mock.

* fix(preserve): warn when a removed file stays on a diverged origin

preserve remove only looked at origin's tip. When user commits sat on
top of the managed commit it wrote the config silently, and origin kept
the file with no hint. It now walks upstream..origin for a trailer
commit carrying a removed file: at the tip it re-stamps as before;
buried under user commits it writes the config and warns that sync
drops the file once the divergent commits are moved off origin.

* test: assert the refused sync-workflow preserve names the path

* fix(config): lease every config-change write and never roll back blindly

applyConfigChange wrote the config through updateVenforkConfig, which
re-applies the patch on a lease failure, and rolled back a failed origin
push with a second retrying write. A change from another clone between
the read and the write was merged under a tip built from stale config,
and a rollback after another clone's write clobbered that write.

The sequence is now: read the config at commit S, build the tip from
the patched config, write with a lease on S and no retry (a moved branch
aborts with "venfork-config changed on origin ... Re-run the command"),
push origin with its lease. When the push fails, venfork-config is
pointed back at S itself with a lease on the commit just written. When
that lease fails, nothing is written; when the rollback fails for any
reason, the error names the push error, the rollback failure, the
written config SHA and the origin SHA, and says to run `venfork sync`
once origin is reachable.

New config.ts exports: readVenforkConfigFromRepoWithSha,
writeVenforkConfigAt, restoreVenforkConfig. ConfigError gains the
'conflict' reason. applyConfigChange loses its rollback callback because
the rollback restores the exact commit that was read.

* fix(setup): refuse an existing venfork-config that disagrees

Re-running setup against a mirror that already had a venfork-config kept
the recorded config whatever it said, after the local remotes had
already been rewired to the new arguments. Setup now writes or checks
the config branch before touching remotes, and when one exists it reads
it and refuses if mode, upstreamUrl or publicForkUrl differ (URLs compare
by owner/repo), naming each mismatch.

* feat(cron): accept month and weekday names in schedule cron

* fix(status): warn when venfork-config cannot be read

* test: pin invalid-preserve guard before divergence and harden doctor cleanup

* test: gate on exact counts, fail on stale reports, run the gate in release and Node CI

* test: update expected counts after restacking onto slice 15

* fix(test): fail the suite runner on signal exits and truncated junit reports

run-suite treated a signalled child (exitCode null) as success. It now maps
null to 1, deletes the stale report before the run, and drops the mtime
comparison and --since flag. test-count requires the closing
</testsuites> and a <testcase> count equal to the root total.

* test: assert the node smoke run exits 0 with every expected doctor check ok

The smoke script only checked that the output parsed and started with the
repo row. It now also checks the exit code, every ok flag and the ordered id
list. release-npm builds, verifies the shebang and runs the smoke test
before publishing.

* fix(schedule): substitute cron names only in list items and range ends

Names in the step part (*/MON) or as the lone base of a step (MON/2) were
rewritten to numbers and accepted. They are now rejected. Names stay
case-insensitive; the README says they are written to the workflow as typed.

* fix(status): warn on any error reading venfork-config

Only ConfigError produced a warning, so other failures (for example a
failed spawn) were swallowed and status reported as if no config existed.

* test: update expected counts after restacking onto the fixed slice 15

* fix(stage): widen the deny-list and tighten the preview

Add the mirror repo name as a deny term, fold combining marks and ignorable
code points, scan the branch name, strip every #N from the synthetic PR body,
warn about #N in commit messages, list published files in the preview, and
report the real cherry-pick failure. VENFORK_ALLOW_SELF_REFERENCE=1 relaxes
only the bare word venfork.

* fix(stage)!: close the directory-preserve hole and scan published files

preserve add now verifies each entry is a file on origin/<default>, so a
directory or a missing path is refused. The gate matches preserve entries
and mirror-only paths case-insensitively, treats a directory entry from an
old config as covering every file under it, and reads every historical
version of preserved files and of venfork-config:.venfork/config.json.
It also scans added or modified text files and file names for the mirror
URL, owner/name and repo name, and for the shape of a venfork config.
Binary files are skipped; the bare word venfork stays allowed in content.

BREAKING CHANGE: venfork preserve add exits non-zero for a path that is
not yet committed on origin/<default>. Commit the file first.

* test: seed preserve entries directly in integrity tests after restack

Slice 17 made `preserve add` require the file to exist on origin's default
branch first. The integrity tests predate that and added entries before
committing the files, so they now use the harness seeding helper like the
other integration files. Also updates the exact test counts for the
restacked top of the stack.

* perf(stage): batch the mirror blob scan and skip upstream history

collectMirrorBlobs ran one git process per preserve entry per commit, so
10 preserved files and a 300-entry reflog took about 47 s. It now lists
the candidate commits once (ref tips, their reflog entries and the history
of both that upstream/<default> cannot reach), deduplicates their trees and
resolves every <tree>:<entry> pair in a single cat-file --batch-check. The
same fixture now takes under 200 ms; 10 entries across 1000 distinct trees
take about 1.1 s.

Only commits upstream cannot reach are read, and a blob that is any
version of the same path at upstream/<default> or in its history is
dropped, so an old upstream version of a preserved file copied elsewhere
no longer counts as mirror content.

The scan returns warnings, which stage prints, when origin's reflog is
empty or the history cap is hit: mirror-only history older than that
cannot be checked.

* fix(stage): scan UTF-16 and binary files, refuse unreadable blobs

The content scan treated any blob with a NUL in its first 8000 bytes as
binary and skipped it, so a UTF-16 file or text after a leading NUL could
carry the mirror URL to the public fork. Text with a UTF-16LE or UTF-16BE
byte order mark is now decoded, and a binary blob is still byte-searched
for the URL-derived terms (origin's URL and owner/name) as UTF-8 and
UTF-16LE. The bare repo name is not searched in binary data.

A blob over execa's 100 MB buffer used to fail with execa's raw maxBuffer
message; stage now refuses it with a message naming the commit and file.

* fix(stage): match the config signature only on a parsed config object

Any text naming both "upstreamUrl" and "publicForkUrl" was refused, which
blocked ordinary source code (venfork's own config types, or an upstream
that happens to use those names). The signature now requires a JSON
object with a version key, a URL-valued upstreamUrl and, when present, a
URL-valued publicForkUrl, and it is skipped when
VENFORK_ALLOW_SELF_REFERENCE=1. A verbatim copy of the mirror's config is
still caught by the mirror-blob check.

* fix(stage): preview every file the history publishes and warn on PR #N

The preview listed the net diff from upstream to the rebuilt head, so a
file added in one commit and deleted in a later one was not shown, yet the
public history still held it. The gate now returns the union of paths each
commit adds, modifies or retypes, and the preview lists them all, marking
the ones absent at the head as "removed later in the branch, still in
history".

With --pr, the preview also warns when the upstream PR title or body
contains a #N reference, with the same wording as the commit-message
warning.

* fix(stage): keep marker checks under self-reference, match repo names in context

VENFORK_ALLOW_SELF_REFERENCE=1 dropped the leftover check in redaction
entirely, so a malformed marker such as <!- venfork:internal -> or
[venfork:internal] published the text it was meant to hide. With the
variable set, leftover text shaped like a marker (venfork : internal in
any wrapper) is still refused; only the plain word is relaxed.

The bare mirror repo name was matched as a substring anywhere, so a
mirror named acme/backend refused every commit mentioning src/backend/.
It now matches only as the repo of an owner/name or URL (after "/" or ":"
and an owner, optionally with .git, and not followed by more path). The
full URL and owner/name terms are unchanged.

findDeniedText now passes terms through canonicalText as it does the
text, so a term with accents (a local mirror path) still matches.

* fix(stage): strip only standalone #N from the synthetic PR body

syntheticBody removed every #<digits> and every empty "()" pair, which
mangled "init()" into "init", URL fragments, "issue#12" and left
"fix : crash" behind. It now removes only "(#N)" groups (with the space
before them) and #N preceded by the start or whitespace and followed by
the end, whitespace or punctuation. #N inside a word or URL and cross-repo
owner/repo#N references are kept; a numeric colour such as #333 reads the
same as a reference and is still removed.

* fix(stage): refuse while the preserve list holds invalid entries

Stage folded invalid preserve entries (a trailing-slash directory from an
old config, for example) into the list it gated on, and an entry such as
docs/ matched nothing, so an edited file under it shipped. Stage now
refuses up front with the same message as sync, naming the
`venfork preserve remove <entry>` command for each invalid entry.

* test: pin that preserve add refuses a gitlink

* test: seed preserve entries in the stale-managed tests after restack

The round-5 integrity tests on slice 15 added preserve entries with
`preserve add` before committing the files, which slice 17 now refuses.
They use the harness seeding helper like the rest of the integration
suite. Also sets the exact test counts for the restacked top of the
stack.

* fix(stage): byte-search every published blob in UTF-8 and UTF-16

The content scan only ran the raw byte search on blobs it judged binary.
A UTF-16 byte order mark in front of UTF-8 text, a UTF-16LE URL after
8000 ASCII bytes, or UTF-16BE text without a BOM all slipped past it.
Now every blob is byte-searched for each URL-derived term as UTF-8,
UTF-16LE and UTF-16BE, ignoring ASCII case, in addition to the decoded
text scan. The BOM tests now hide the URL behind zero-width spaces so
they pin the decoding path on its own.

* test(stage): pin upstream history and reflog coverage in the mirror blob scan

Adds a stage test where an old managed commit preserved an upstream
version of a file that upstream later changed: copying that old upstream
content elsewhere must still ship. It fails if upstreamVersions stops
reading upstream history.

The 10 x 300 timing test reused one tree for every reflog entry, so it
passed even when the reflog was not read at all. Each entry now carries
new versions of the preserved files, and the test asserts every version
is collected.

* fix(stage): tighten repo-name matching in the deny list

Three gaps in findDeniedText:

- File content: a bare mirror repo name now needs a host before it
  (github.com/, git@host:, ssh://host/, https://host/). A package or
  directory with the same name, such as "acme-ui/widget-private", no
  longer blocks a stage. Commit messages, branch names, PR text and file
  names keep the loose owner/name match.
- The bare-name lookahead rejected any "/" or "." after the name, so
  "https://github.com/other/<name>/pull/3", "other/<name>." at a
  sentence end and "other/<name>.git." slipped through. A hosted match
  now allows any path after the name, and a trailing "." counts only
  when it starts a file extension.
- Every term except "venfork" now ends at a repo-name boundary, so
  "acme/widget-private-public" no longer matches "acme/widget-private"
  while ".git", "/pull/1", "#7" and "?x" suffixes still do.

* fix(stage): match the venfork config by its venfork-only keys

The config signature needed a "version" key and a URL-valued
upstreamUrl, so an ordinary package-like JSON with both was refused,
while a config without "version" shipped. It now needs a URL-valued
upstreamUrl plus a URL-valued publicForkUrl, "mode": "no-public", or
any of preserve, schedule, shippedBranches, pulledPrs, shippedIssues
and pulledIssues.

VENFORK_ALLOW_SELF_REFERENCE=1 used to skip the check entirely, so this
mirror's own config could be re-serialised and published. Under
self-reference a config-shaped file is still refused when its
upstreamUrl or publicForkUrl names this mirror's upstream or public fork
(by owner/repo, from the config and the remotes) or when a link map is
not empty. The error message and README describe the new signature.

* fix(stage): refuse instead of warning when mirror history exceeds the scan cap

The mirror blob scan read at most 2000 reflog entries and commits and
only warned past that, so an old preserved version beyond the cap could
ship. The cap is now 20000 (the scan is cheap since the batch rewrite).
It reads cap + 1 and, when a reflog or the mirror-only history is
strictly longer than the cap, refuses with a message naming the cap and
the way out: stage from a clone with a shorter reflog, or expire old
reflog entries after confirming no mirror-only history is needed. There
is no override. Exactly cap entries are read in full without complaint.

The "no reflog" warning is gone: every fresh clone has an empty reflog
for its remote-tracking refs, so it fired on almost every run.

* fix(preserve): single-quote entries in every preserve remove hint

The hints printed "venfork preserve remove *.md", which a shell expands
before venfork sees it, so copying the hint removed the wrong entries or
failed. preserveRemoveHint single-quotes each entry (escaping embedded
quotes). Sync, stage, preserve status, doctor and the managed-commit
errors all use it.

* fix(stage): treat a directory preserve entry as invalid at stage and sync

normalizePreservePath cannot tell a file from a directory, so an old
config could still hold a directory entry. Stage then expanded it with
one "git ls-tree -r" per distinct subtree in the mirror history, which
took about 25 s for 2000 versions, and sync failed with a divergence
error that did not name the fix.

Stage (origin/<default> and the local default branch), sync
(origin/<default>, before the divergence check) and every origin tip
rebuild (the previous mirror tip) now refuse an entry that is a
directory, with the invalid-entry error and its quoted
"venfork preserve remove '<entry>'" hint. collectMirrorBlobs no longer
expands subtrees: a directory found for a preserve entry anywhere in the
history it reads is refused the same way. The 2000-version case is
refused in well under 2 s.

* fix(redaction): let a plain venfork comment through under self-reference

With VENFORK_ALLOW_SELF_REFERENCE=1, any HTML comment that mentioned
venfork was still refused as a possible misspelled marker, so a body
with "<!-- uses venfork -->" could not be staged. Under self-reference
only a comment shaped like a marker ("venfork:" or "venfork" followed
by "internal") is refused now; without it every venfork comment is
still refused.

* docs(stage): describe the byte search, host-only repo names and its limits

The stage section now says every file is byte-searched for origin's URL
and owner/name as UTF-8, UTF-16LE and UTF-16BE whatever its BOM or NUL
bytes, that compressed archives and UTF-32 text are not decoded, that a
bare repo name in file content needs a host, and which owner/name forms
pass in commit messages.

* feat(doctor)!: fold status into doctor and remove the status command

doctor now prints the shipped branch, pulled PR, shipped issue and pulled
issue links from venfork-config after the checks. doctor --json prints
{ checks, links } instead of a bare array; links is null when the config
cannot be read.

BREAKING CHANGE: venfork status and status --check are removed. Use
venfork doctor. The doctor --json output is now an object with checks and
links.

* feat(cli)!: rename pull-request and issue commands to pull pr, pull issue and stage issue

Commands now follow direction: `pull` brings upstream content in, `stage`
pushes mirror content out.

BREAKING CHANGE: `venfork pull-request` is now `venfork pull pr`,
`venfork issue pull` is `venfork pull issue`, and `venfork issue stage`
is `venfork stage issue`. The old names are removed with no aliases.
`venfork stage branch <name>` is the explicit form of `stage <name>`; a
branch named `issue` or `branch` must use it. Config link-map keys are
unchanged.

* fix(stage)!: reject branch-only flags on stage issue

`stage issue` silently ignored `--pr`, `--draft`, `--base`, `--internal-pr`
and `--no-update-existing`. It now refuses them and names the flag, so a
typo cannot look like it worked. Also sets the exact test counts for the
restacked top of the stack.

* fix(stage): run the deny list on staged issue titles and bodies

stage issue only stripped internal markers before opening the upstream
issue, so a body that named the private mirror by owner/name or URL was
published verbatim. stage --pr already refused the same text. Both paths
now run assertNoMirrorReference over the translated title and body before
the confirm prompt.

* fix(sync): check divergence, build and lease from one resolved origin tip

checkDivergence took ref names and re-read origin/<default> itself, and
sync resolved the same name again later for the lease and the preserved
blobs. A fetch between those reads (an IDE autofetch is enough) moved the
ref, the lease matched the new tip, and the push dropped a commit the
check never saw. Sync now resolves refs/remotes/<remote>/<default> to a
commit id once and passes that id to checkDivergence, buildOriginTip and
the push; config-change, doctor and preserve do the same.

Sync also read venfork-config before fetching origin. Config writers
write the config before they push the tip, so the old order could pair a
new tip with a stale preserve list and classify a fresh bot commit as
stale-trailer, dropping the only copy of a newly preserved file. The
config is now read after origin is fetched.

A preserved path that upstream also has is upstream's content. A mirror
commit that changed it was still skipped as preserved-only, then the
rebuilt tip took upstream's version and the change was lost with no
error. Such a commit now counts as divergence when the tip's blob differs
from upstream's.

The invalid-preserve guard test no longer claims the guard runs before
any fetch; it pins that the guard wins over divergence and that neither
remote is pushed.

* fix(stage): anchor the gate on the resolved upstream tip, not the short ref name

planStaging, the rebuild, the evil-merge check and the publish gate all
named the trust anchor as upstream/<default>. Git resolves that short name
as a tag or local branch before the remote-tracking ref, so a ref of that
name pointing at the mirror tip moved the anchor onto the managed commit:
the rebuild sat on top of it, base..head left it out, and the push shipped
the preserved files and the bot commit. The plan now resolves
refs/remotes/upstream/<default> to a commit id once and every step uses
that id.

* fix(preserve): write add and remove as deltas on the list as read at write time

preserve add and remove computed the full new list from an early read and
wrote it under a lease taken on a later read, so a concurrent change
between the two reads was overwritten with no conflict. For remove the
re-stamp then built the tip from that stale list while the divergence
check used the fresh one, and a file a teammate had just preserved was
dropped from origin with no error. Patches now carry preserveAdd and
preserveRemove, applied to the config inside the leased write, and
applyConfigChange allows preserved-only commits by the patched list.

preserve remove also refuses an entry that is neither listed nor carried
by origin's managed commit, instead of reporting success and writing a
config commit.

* test(unit): mock remote tips as stable ids now that sync resolves full refs

The string-mocked execa dispatcher answered rev-parse on refs/remotes/*
with the generic '.git' stub, so every sync and stage unit test looked up
rev-list ranges that no longer occur. Remote tips now resolve to
<remote>0tip and the test keys use those ids.

* fix(doctor): judge the preserve list with the tip builder sync runs

Doctor only checked that each preserved path existed on origin's tip, so
it passed a directory entry, an upstream directory at a preserved path and
an upstream file at an ancestor path while sync refused all three, and it
failed a path missing on origin that upstream now has while sync passed.
The preserve check now calls buildOriginTip, which pushes nothing, and
reports whatever it throws. A pin-downgrade refusal is a typed
PinDowngradeError, so doctor can skip the check and leave the workflow
check to report it.

* docs(schedule): recommend a scoped push token and warn while upstream workflows can read it

schedule set, doctor's fix text and the README all told users to store
$(gh auth token) as VENFORK_PUSH_TOKEN. That is the whole account, and a
repository secret is readable by every workflow on the mirror's default
branch, which by default includes every workflow upstream ships. All three
now describe a fine-grained token limited to the mirror and the public
fork with Contents and Workflows write, and schedule set and doctor warn
while both workflow lists are empty.

* test: raise the count gate for the review-blocker tests

* fix(stage): refuse internal markers in published file content and commit metadata

Marker-shaped text (venfork:internal, canonical-folded) shipped in file
content in every mode and in commit messages under
VENFORK_ALLOW_SELF_REFERENCE=1. Refuse it in file content and in the
author, committer and message fields whether or not self-reference is set.

* fix(stage): refuse near copies of preserved text

Blob identity let a preserved file out after a one-letter edit, a prepended
line, CRLF, a BOM or UTF-16. collectMirrorBlobs now also returns a
normalized fingerprint and the long-line set of each mirror text blob
(lines upstream's own version of the path has are left out). The gate
refuses a published text with the same fingerprint or sharing more than
half of those lines (at least three). gzip, base64 and UTF-32 copies stay
undetected and are documented in the README.

* fix(stage): refuse the bare mirror name, encoded paths and Pages URLs in published text

A bare repo name (widget-private, widget-private#12) passed in messages,
titles, bodies and branch names because the name needed an owner/ prefix.
It now matches as a whole word there. File names keep the owner/ rule
(nameMatch 'owner') and file content keeps the host rule (nameMatch
'host'), replacing hostOnlyNames. The unit tests that pinned a plain word
and a src/<name>/ directory passing in messages now pin them for file names.

Text and terms are percent-decoded (up to three layers, malformed
sequences tolerated) before matching, and <owner>.github.io/<name> is a
term. owner/name is also read from non-github.com origins such as GitHub
Enterprise with a local helper; parseRepoPath is unchanged. Decoding lives
in the matcher, not canonicalText, so JSON config detection still parses
strings that contain %22.

* fix(sync): read commit metadata without log.showSignature output

* fix(sync): apply workflow allow/block lists whenever the managed commit is built

* fix(sync): treat a workflow deletion the policy does not make as a user change

* fix(sync): word the stale-trailer warning per kind and list the paths it discards

* fix(workflow): fail fast when VENFORK_PUSH_TOKEN is unset and cover more SSH forms

Drop the github.token fallback for checkout, add a first step that fails
with an error annotation when the secret is empty, and name the cause in
the blocked-sync issue. Also rewrite ssh://git@github.com:22/,
ssh://git@ssh.github.com:443/ and git@GitHub.com: to HTTPS.

Snapshot updated after reviewing the diff: only these YAML changes.

* fix(workflow): avoid SIGPIPE when checking the blocked-sync issue body

Capture the issue body before grep -q so bodies over 64 KiB no longer
fail the pipeline under pipefail and trigger a duplicate comment.

Snapshot updated after reviewing the diff: only the issue-view block changed.

* fix(workflow): validate VENFORK_INSTALL_SPEC and drop the git spec claim

Accept only venfork@<semver> or an https tarball URL ending in .tgz. A git
install has no dist/ and no venfork binary, so it passed the install step
and failed at sync. Also update the README for the token check.

Snapshot updated after reviewing the diff: only the install step changed.

* docs(schedule): add the 0.10 upgrade path and an unpinned-workflow helper

Document installing the new CLI, setting VENFORK_PUSH_TOKEN and running
venfork sync once before the release reaches npm. Add isUnpinnedWorkflow so
sync can detect an unmigrated mirror when running inside Actions.

* fix(cli): name the replacement when a renamed command is run

* fix(cli): reject unknown options and extra arguments in every command parser

Shares consumeValue and a scanArgs walker in shared/args. pull accepts flags in any position. Numeric refs and --internal-pr take plain positive integers; PR and issue URLs are anchored and compared case-insensitively.

* fix(cli): report missing-argument usage errors on stderr from the parsers

Adds schedule-args and moves the missing branch and missing ref checks for stage, pull pr and pull issue into their parsers.

* fix(cli): route help <cmd> to per-command help and parse pull before the gh check

* fix(sync): refuse to rewrite an unpinned workflow from a scheduled run

A runner that installs the latest venfork on a mirror whose workflow was
written before 0.11 would rewrite the managed commit from a job that may
lack the push token, failing silently because the old YAML has no report
step. Under GITHUB_ACTIONS sync now refuses with a typed error that names
the local migration step; a local sync still migrates the mirror.

* test: raise the count gate for the follow-up slices

* fix(release): drop bun-windows-arm64 and report every failed compile target

Pinned Bun 1.2.18 cannot compile bun-windows-arm64 (Unsupported compile target), which failed release-cli before any upload. Bun 1.4.2 supports it, but bumping the toolchain pin this late is riskier than dropping a target, so option (b): remove it. Targets move to scripts/release-targets.ts and build with Promise.allSettled so each failing target is named. release.yml uploads dist/targets/venfork-* by glob, so no asset name changes.

* fix(net): keep the user's ssh command when forcing BatchMode

GIT_SSH_COMMAND set by NET_ENV beat core.sshCommand and overwrote a user-set GIT_SSH_COMMAND, breaking custom keys, ProxyCommand and agent paths. netExec now builds the value per call from GIT_SSH_COMMAND, else core.sshCommand of the cwd, else plain ssh, and appends the fail-fast options.

* fix(net): retry seed pushes on slow, reset, DNS and 429 failures

isTransientPushError missed the abort its own http.lowSpeedTime produces (Operation too slow), macOS wording (Connection was reset), Could not resolve host and HTTP 429. It also threw a TypeError for undefined input.

* fix(cli): exit 130 when a prompt is cancelled

Cancelling a prompt (Ctrl-C or EOF) in setup, stage and issue exited 0, so venfork setup </dev/null read as success in CI. Cancel now exits 130; an explicit No still exits 0. In stage and issue the exit moves out of the try so the generic catch cannot turn it into 1.

* test: raise the count gate for the pre-merge fixes

* test(e2e): assert tier 1 against the validated install step and token preflight

* test(e2e): describe tier 2 against the token preflight with no checkout fallback

* test(e2e): pin tier 6 to the VENFORK_PUSH_TOKEN preflight failure

* fix(stage): match the mirror name with a - or _ suffix in text bound for upstream

In word mode (branch names, PR titles and bodies, commit metadata) the
mirror name followed by '-' or '_' counted as a longer repo name, so
fix/<mirror>-sync and <mirror>-staging passed the deny list and reached
the public fork and the upstream PR. Word mode now only treats a following
letter or digit as part of a longer word. Owner and host modes keep the
old boundary so file paths like src/<name>-ui/ still publish.

* fix(sync): list sync-blocked issues through REST and report a failed run once

gh issue list reads GitHub's search index, which lags a just-opened issue,
so the workflow failure step missed the issue sync --report-issues had
opened seconds earlier and opened a duplicate. Both the CLI and the
generated workflow now list open labelled issues through the REST issues
endpoint, skipping pull requests it also returns. The sync step gets
id: sync and venfork sync --report-issues writes reported=true to
GITHUB_OUTPUT after it opens or updates the issue; the failure step is
skipped when that output is set. The e2e helper lists issues the same way.

Workflow snapshot updated with bun test -u after reading the diff: the
sync step id, the failure step guard and the REST listing line.

* fix(doctor): read the sync workflow state through the REST API

gh workflow view has no --json flag, so on a real mirror the cron-age check
always skipped with 'unknown flag: --json'; the unit mocks answered the
non-existent command and hid it. Doctor now asks
gh api repos/<mirror>/actions/workflows/venfork-sync.yml --jq .state, and a
disabled_manually or disabled_inactivity state fails the check with a fix
that names the Actions tab and gh workflow enable.

* fix(workflows): add to the allow and block lists instead of replacing them

workflows block other.yml replaced the whole block list, so it silently
unblocked ci.yml and exposed VENFORK_PUSH_TOKEN to it again. allow and
block now add basenames to their list through new delta patches
(enabledWorkflowsAdd/Remove, disabledWorkflowsAdd/Remove) applied to the
config as read at write time, like preserveAdd/preserveRemove. New
unallow and unblock actions remove files from one list; clear still
empties both. Separate verbs were chosen over a single 'remove' so the
command cannot be read as removing a workflow from the mirror.

* fix(cli): exit 130 when input ends at a prompt

With @clack/prompts 0.11 a prompt whose stdin hits EOF never resolves. The
event loop drained with the command still pending and Node exited 0, so
'venfork setup owner/repo --org x </dev/null' looked like success with
nothing created. index.ts now registers a beforeExit handler before
dispatch that, while the command promise is unsettled, prints
'Cancelled: input ended at a prompt' to stderr and exits 130; the handler
is removed once the command settles.

* fix(git): stop guessing main when a remote's default branch is unknown

getDefaultBranch ignored the exit code of git remote set-head -a and
returned 'main' whenever refs/remotes/<remote>/HEAD was missing, so
venfork schedule status on a fresh clone of a master mirror printed
'Branch: main'. It now throws a GitError naming the remote and the
set-head reason when neither set-head -a nor an existing remote HEAD
yields a branch. schedule fetches upstream before asking (status fetches
itself; set and disable ask after applyConfigChange has fetched), and
doctor reports the error on its git-state checks instead of crashing.

* fix(clone): add public and upstream over the transport gh uses

clone copied upstreamUrl and publicForkUrl from venfork-config verbatim,
so with gh git_protocol=https origin was https while public and upstream
stayed SSH, and an HTTPS-only user could not sync. clone now rewrites
github.com URLs to https://github.com/<owner>/<repo>.git or
git@github.com:<owner>/<repo>.git to match gh config get git_protocol;
other hosts and local paths are untouched, and nothing is rewritten when
gh reports no protocol. setup records and adds its remotes the same way,
and re-points an existing remote whose URL differs.

* docs: correct the 0.10 upgrade path and the messages that point at it

The README said the first scheduled run after 0.11 rewrites the managed
commit and told users to install venfork@latest before the release; the
code refuses with UnmigratedMirrorError instead, and @lates…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants