Skip to content

feat: configurable scheduled sync via GitHub Actions #13

@cabljac

Description

@cabljac

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:

  1. Fetches from all three remotes
  2. Checks for divergent commits on origin or public that aren't in upstream — aborts with a warning if found (data loss protection)
  3. 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:

.venfork/config.json
{
  "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:

  1. Force-push upstream/mainorigin/main (as today)
  2. Read the current schedule config from the venfork-config orphan branch
  3. If schedule.enabled is true, generate the workflow YAML and commit it to origin/main
  4. 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:

  1. Validate the cron expression (basic validation — 5 fields, reasonable values)
  2. Update config on the orphan branch via updateVenforkConfig
  3. Generate and commit the workflow file to the default branch
  4. Push both

disable should:

  1. Remove .github/workflows/venfork-sync.yml from the default branch and push
  2. Update config on orphan branch (enabled: false)

4. Modify syncCommand in src/commands.ts

After the existing force-push logic, add:

  1. Read config from orphan branch
  2. If schedule.enabled is true, generate the workflow YAML and commit it to the default branch
  3. Push the extra commit

5. Modify stageCommand in src/commands.ts

Replace the current direct push with the transparent W-stripping approach:

  1. Fetch upstream
  2. Create a temp branch venfork-stage-temp at the tip of <branch>
  3. Rebase venfork-stage-temp onto upstream/main, excluding commits since private/main (this drops W)
  4. Force-push venfork-stage-temp to public/<branch>
  5. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions