Context
Venfork is being used as a tool to spin up sandbox environments for running AI workflows safely. The three-repository pattern gives AI agents an isolated private mirror to work in, with controlled exposure to the upstream via the public fork. A reliable scheduled sync is essential to keep the sandbox current with upstream without requiring manual intervention — stale sandboxes produce worse results.
Background: how venfork works
For a full overview of the commands venfork currently provides, see the README.
Venfork manages a three-repository pattern for vendor/contractor workflows:
| Remote |
Repo |
Purpose |
origin |
owner/project-private |
Private mirror — all internal work happens here |
public |
owner/project |
Public fork — staging area for upstream PRs |
upstream |
original/project |
Read-only source (push is hard-disabled via `DISABLE` URL) |
Current sync command
venfork sync brings the private mirror and public fork up to date with upstream. It:
- Fetches from all three remotes
- Checks for divergent commits on
origin or public that aren't in upstream — aborts with a warning if found (data loss protection)
- Force-pushes
upstream/<default-branch> onto both origin/<default-branch> and public/<default-branch>
The key detail: sync is a destructive force-push. The private mirror's default branch is always made identical to upstream's.
Current config model
Venfork stores configuration on an orphan branch called venfork-config in the private mirror. This branch has no shared history with the main codebase and contains a single file:
{
"version": "0.3.0",
"publicForkUrl": "git@github.com:owner/project.git",
"upstreamUrl": "git@github.com:original/project.git"
}
The orphan branch exists so that venfork clone can bootstrap a new team member's setup by shallow-cloning just that branch (--single-branch --depth 1) without needing the full repo history. See src/config.ts for createConfigBranch and fetchVenforkConfig.
Feature: configurable scheduled sync
Goal
Allow users to configure an automatic sync on a cron schedule, so the private mirror and public fork stay up to date with upstream without manual intervention. The workflow also supports manual dispatch so a sync can be triggered on demand from the GitHub Actions UI without waiting for the next scheduled run.
Mechanism: GitHub Actions in the private mirror
The sync will run as a GitHub Actions workflow (.github/workflows/venfork-sync.yml) committed to the private mirror's default branch. GitHub Actions cron triggers are static — the cron expression must be a literal string in the YAML, it cannot be dynamic. So venfork owns and manages this file: when the user sets or changes a schedule, venfork regenerates the file with the new cron expression baked in.
The workflow will use both schedule and workflow_dispatch triggers:
on:
schedule:
- cron: '0 */6 * * *'
workflow_dispatch:
workflow_dispatch requires no inputs — it runs the same sync either way.
The core problem: sync overwrites the workflow file
Because sync force-pushes upstream/<default-branch> onto the private mirror's default branch, it would delete the workflow file on every sync. The workflow would run once and then destroy itself.
Solution: re-stamp after force-push
After the force-push, sync must re-stamp the workflow file as an additional commit on top. The flow becomes:
- Force-push
upstream/main → origin/main (as today)
- Read the current schedule config from the
venfork-config orphan branch
- If
schedule.enabled is true, generate the workflow YAML and commit it to origin/main
- Push that commit
This means the private mirror's default branch will always be exactly one commit ahead of upstream when a schedule is active (the venfork workflow commit, referred to below as W). This is an intentional and stable invariant.
The schedule config (cron expression, enabled flag) is stored on the venfork-config orphan branch alongside the existing config. This keeps the orphan branch as the single source of truth, and means sync always has access to the correct schedule when re-stamping.
The W commit and stage
Developers branch normally off private/main, so their feature branches include W in their history:
upstream/main: A - B - C
private/main: A - B - C - W
feature-branch: A - B - C - W - F1 - F2
If stage pushed feature-branch to public as-is, W would appear in the PR to upstream — exposing internal venfork infrastructure to the contractee. This must not happen.
stage must transparently strip W before pushing to public, without the developer ever needing to know about it. It does this using a temporary local branch:
git branch venfork-stage-temp feature-branch
git rebase --onto upstream/main private/main venfork-stage-temp
git push public venfork-stage-temp:<branch> --force
git branch -D venfork-stage-temp
The result: public/feature-branch contains only F1' - F2' cleanly based on upstream/main. The developer's local feature-branch is never touched. The contractee sees a clean PR. This is entirely transparent to the developer.
If the rebase produces conflicts (unlikely, since W only touches .github/workflows/venfork-sync.yml which doesn't exist in upstream), stage should abort and surface a clear error.
Implementation plan
1. Extend VenforkConfig in src/config.ts
interface VenforkConfig {
version: string;
publicForkUrl: string;
upstreamUrl: string;
schedule?: {
cron: string;
enabled: boolean;
};
}
Add updateVenforkConfig(repoDir: string, patch: Partial<VenforkConfig>): Promise<void> — reads the current config from the orphan branch, merges the patch, and force-pushes the updated config back.
2. Workflow file generation
Add a pure function in a new src/workflow.ts:
function generateSyncWorkflow(cron: string): string
Returns the YAML string for .github/workflows/venfork-sync.yml. The workflow should:
- Trigger on the cron schedule and
workflow_dispatch
- Check out the repo
- Run
venfork sync using the installed CLI (or invoke the sync logic via gh + git directly if venfork isn't available in CI — discuss with lead before deciding)
- Use
GITHUB_TOKEN for auth (no PAT required since the workflow runs in the private mirror itself)
3. New schedule command in src/commands.ts
venfork schedule set <cron> — set or update the schedule
venfork schedule disable — disable the schedule (removes workflow file, updates config)
venfork schedule status — show current schedule config
set should:
- Validate the cron expression (basic validation — 5 fields, reasonable values)
- Update config on the orphan branch via
updateVenforkConfig
- Generate and commit the workflow file to the default branch
- Push both
disable should:
- Remove
.github/workflows/venfork-sync.yml from the default branch and push
- Update config on orphan branch (
enabled: false)
4. Modify syncCommand in src/commands.ts
After the existing force-push logic, add:
- Read config from orphan branch
- If
schedule.enabled is true, generate the workflow YAML and commit it to the default branch
- Push the extra commit
5. Modify stageCommand in src/commands.ts
Replace the current direct push with the transparent W-stripping approach:
- Fetch upstream
- Create a temp branch
venfork-stage-temp at the tip of <branch>
- Rebase
venfork-stage-temp onto upstream/main, excluding commits since private/main (this drops W)
- Force-push
venfork-stage-temp to public/<branch>
- Delete
venfork-stage-temp
The developer's local <branch> must not be modified. If the rebase fails, delete the temp branch and surface a clear error.
Only apply this logic when a schedule is active (i.e. schedule.enabled is true in config). If no schedule is configured, the existing push behaviour is correct.
6. Route the command in src/index.ts
Add schedule to the command router alongside the existing commands.
Extensibility notes
This feature is intentionally scoped to the default branch only. The config schema and schedule command are designed to be extended later for per-branch or per-feature-branch scheduling. When adding that, the schedule field in config should become an array or map keyed by branch pattern. Do not implement this now — just don't make choices that would make it hard to add.
Files to touch
src/config.ts — extend interface, add updateVenforkConfig
src/commands.ts — new scheduleCommand, modify syncCommand and stageCommand
src/index.ts — route schedule subcommands
src/workflow.ts (new) — workflow YAML generation
tests/commands.test.ts — tests for new and modified command behaviour
tests/config.test.ts (new or extend existing) — tests for updateVenforkConfig
Out of scope
- Syncing non-default branches (future work)
- A venfork GitHub App or hosted service
- Per-branch schedule configuration
Context
Venfork is being used as a tool to spin up sandbox environments for running AI workflows safely. The three-repository pattern gives AI agents an isolated private mirror to work in, with controlled exposure to the upstream via the public fork. A reliable scheduled sync is essential to keep the sandbox current with upstream without requiring manual intervention — stale sandboxes produce worse results.
Background: how venfork works
Venfork manages a three-repository pattern for vendor/contractor workflows:
originowner/project-privatepublicowner/projectupstreamoriginal/projectCurrent
synccommandvenfork syncbrings the private mirror and public fork up to date with upstream. It:originorpublicthat aren't inupstream— aborts with a warning if found (data loss protection)upstream/<default-branch>onto bothorigin/<default-branch>andpublic/<default-branch>The key detail: sync is a destructive force-push. The private mirror's default branch is always made identical to upstream's.
Current config model
Venfork stores configuration on an orphan branch called
venfork-configin the private mirror. This branch has no shared history with the main codebase and contains a single file:{ "version": "0.3.0", "publicForkUrl": "git@github.com:owner/project.git", "upstreamUrl": "git@github.com:original/project.git" }The orphan branch exists so that
venfork clonecan bootstrap a new team member's setup by shallow-cloning just that branch (--single-branch --depth 1) without needing the full repo history. Seesrc/config.tsforcreateConfigBranchandfetchVenforkConfig.Feature: configurable scheduled sync
Goal
Allow users to configure an automatic sync on a cron schedule, so the private mirror and public fork stay up to date with upstream without manual intervention. The workflow also supports manual dispatch so a sync can be triggered on demand from the GitHub Actions UI without waiting for the next scheduled run.
Mechanism: GitHub Actions in the private mirror
The sync will run as a GitHub Actions workflow (
.github/workflows/venfork-sync.yml) committed to the private mirror's default branch. GitHub Actions cron triggers are static — the cron expression must be a literal string in the YAML, it cannot be dynamic. So venfork owns and manages this file: when the user sets or changes a schedule, venfork regenerates the file with the new cron expression baked in.The workflow will use both
scheduleandworkflow_dispatchtriggers:workflow_dispatchrequires no inputs — it runs the same sync either way.The core problem: sync overwrites the workflow file
Because
syncforce-pushesupstream/<default-branch>onto the private mirror's default branch, it would delete the workflow file on every sync. The workflow would run once and then destroy itself.Solution: re-stamp after force-push
After the force-push,
syncmust re-stamp the workflow file as an additional commit on top. The flow becomes:upstream/main→origin/main(as today)venfork-configorphan branchschedule.enabledis true, generate the workflow YAML and commit it toorigin/mainThis means the private mirror's default branch will always be exactly one commit ahead of upstream when a schedule is active (the venfork workflow commit, referred to below as
W). This is an intentional and stable invariant.The schedule config (cron expression, enabled flag) is stored on the
venfork-configorphan branch alongside the existing config. This keeps the orphan branch as the single source of truth, and meanssyncalways has access to the correct schedule when re-stamping.The W commit and
stageDevelopers branch normally off
private/main, so their feature branches includeWin their history:If
stagepushedfeature-branchtopublicas-is,Wwould appear in the PR to upstream — exposing internal venfork infrastructure to the contractee. This must not happen.stagemust transparently stripWbefore pushing topublic, without the developer ever needing to know about it. It does this using a temporary local branch:The result:
public/feature-branchcontains onlyF1' - F2'cleanly based onupstream/main. The developer's localfeature-branchis never touched. The contractee sees a clean PR. This is entirely transparent to the developer.If the rebase produces conflicts (unlikely, since
Wonly touches.github/workflows/venfork-sync.ymlwhich doesn't exist in upstream),stageshould abort and surface a clear error.Implementation plan
1. Extend
VenforkConfiginsrc/config.tsAdd
updateVenforkConfig(repoDir: string, patch: Partial<VenforkConfig>): Promise<void>— reads the current config from the orphan branch, merges the patch, and force-pushes the updated config back.2. Workflow file generation
Add a pure function in a new
src/workflow.ts:Returns the YAML string for
.github/workflows/venfork-sync.yml. The workflow should:workflow_dispatchvenfork syncusing the installed CLI (or invoke the sync logic viagh+gitdirectly if venfork isn't available in CI — discuss with lead before deciding)GITHUB_TOKENfor auth (no PAT required since the workflow runs in the private mirror itself)3. New
schedulecommand insrc/commands.tssetshould:updateVenforkConfigdisableshould:.github/workflows/venfork-sync.ymlfrom the default branch and pushenabled: false)4. Modify
syncCommandinsrc/commands.tsAfter the existing force-push logic, add:
schedule.enabledis true, generate the workflow YAML and commit it to the default branch5. Modify
stageCommandinsrc/commands.tsReplace the current direct push with the transparent W-stripping approach:
venfork-stage-tempat the tip of<branch>venfork-stage-tempontoupstream/main, excluding commits sinceprivate/main(this drops W)venfork-stage-temptopublic/<branch>venfork-stage-tempThe developer's local
<branch>must not be modified. If the rebase fails, delete the temp branch and surface a clear error.Only apply this logic when a schedule is active (i.e.
schedule.enabledis true in config). If no schedule is configured, the existing push behaviour is correct.6. Route the command in
src/index.tsAdd
scheduleto the command router alongside the existing commands.Extensibility notes
This feature is intentionally scoped to the default branch only. The config schema and
schedulecommand are designed to be extended later for per-branch or per-feature-branch scheduling. When adding that, theschedulefield in config should become an array or map keyed by branch pattern. Do not implement this now — just don't make choices that would make it hard to add.Files to touch
src/config.ts— extend interface, addupdateVenforkConfigsrc/commands.ts— newscheduleCommand, modifysyncCommandandstageCommandsrc/index.ts— routeschedulesubcommandssrc/workflow.ts(new) — workflow YAML generationtests/commands.test.ts— tests for new and modified command behaviourtests/config.test.ts(new or extend existing) — tests forupdateVenforkConfigOut of scope