Part 2 of 2. Part 1: 134 Packages, 3 Monorepos, Zero Guarantees covers the problem and the integration hub concept.
Part 1 ended with a concept: a dedicated integration hub repo (integration-hub), git submodules as version pins, and a known-good stack pointer that only advances when E2E passes. Good idea. But someone still had to update the submodules, open the PR, watch the tests, and hit merge. That's not a system, that's a chore list.
This is Part 2: making it run itself.
Three Workflows
The automation breaks into three GitHub Actions workflows. Each does one thing.
- Auto-Update: Detects new commits in source repos, proposes a new candidate combination
- Test: Runs the full E2E suite against the candidate
- Auto-Merge: Merges the candidate if tests pass, handles failures if they don't
A change in any source repo flows through all three automatically.
The Rolling PR Pattern
When a source repo pushes to main, the integration hub needs to test the new commit. The naive approach: open a new PR for every update. With three repos shipping independently, that creates a flood of PRs and stale ones that need cleanup.
Instead, we use a single rolling PR. One branch (auto/submodule-update), one PR, continuously updated.
The auto-update workflow runs on a schedule (every 6 hours) and on repository_dispatch events from the source repos. It pulls the latest main from all three submodules, commits the updated pointers, and force-pushes to the rolling branch. If the PR doesn't exist, it creates one. If it does, the push updates it in place.
One PR instead of many. Less noise. Multiple submodule updates batched together. A single place to see whether integration is currently passing.
Cross-Repo Triggers
When a developer merges a PR in any source repo, the integration hub should test immediately, not wait for the next 6-hour cron.
Each source repo has a small notify-e2e.yml workflow that fires on push to main. It sends a repository_dispatch event to the integration hub:
- name: Notify integration-hub
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.GH_SUBMODULE_PAT }}
repository: ${{ github.repository_owner }}/integration-hub
event-type: submodule-updated
client-payload: |
{
"source_repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"ref": "${{ github.ref }}"
}Fire-and-forget. The source repo's workflow finishes immediately. It doesn't wait for E2E to complete. Source repos stay fast and independent.
The integration hub's auto-update workflow picks up the dispatch, updates all submodules, and pushes to the rolling PR. The test workflow runs automatically on the PR.
The PR Lifecycle State Machine
The rolling PR moves through a set of states. The auto-merge workflow handles each one:
stateDiagram-v2
[*] --> PR_Open: Auto-update creates/updates PR
PR_Open --> Testing: Tests triggered on PR
Testing --> Tests_Passed: All E2E pass
Testing --> Tests_Failed: E2E failure
Tests_Passed --> Check_Mergeable: Auto-merge checks PR state
Check_Mergeable --> Merge: Clean, squash merge
Check_Mergeable --> Behind_Main: PR is behind main
Check_Mergeable --> Conflict: Merge conflict
Behind_Main --> Testing: Update branch, re-test
Conflict --> Alert: Slack notification, manual fix needed
Tests_Failed --> Alert: Slack notification
Merge --> [*]: Stack pointer advanced
Alert --> PR_Open: Fix lands, auto-update runs againThis is a state machine, not a linear pipeline. The PR can cycle between states, going from "behind main" back to "testing" multiple times before eventually merging or alerting. In a busy repo, that loop is the normal case, not the edge case.
Failure Modes
Three things can go wrong. Each is handled differently. All Slack notifications originate from the auto-merge workflow (auto-merge-submodules.yml), the single point that evaluates PR check results and acts on them.
- Tests fail. Auto-merge workflow detects the failing checks and sends a Slack message with the PR link, the failing run link, and which repos were in the candidate. Stack pointer doesn't advance. Source repos keep shipping (they aren't blocked). When a fix lands in any source repo, auto-update proposes a new candidate and the cycle restarts.
- PR is behind main. Happens when
mainadvances while tests are running (someone merged something else). Auto-merge calls GitHub's "update branch" API to mergemaininto the PR. This triggers a new test run. If that passes, it merges. Can loop a few times in high-traffic repos. - Merge conflict. PR can't be automatically merged. Auto-merge workflow sends a Slack message with the PR link and conflict details. Manual intervention required, typically rebasing the rolling branch. Rare, since the branch only contains submodule pointer updates.
The Happy Path
When everything works, the full sequence:
sequenceDiagram
participant Source as Source Repo
participant Hub as Integration Hub
participant CI as E2E Tests
participant Main as Hub Main
Source->>Hub: Push to main (repository_dispatch)
Hub->>Hub: Update submodule pointers, push rolling PR
Hub->>CI: PR triggers full E2E suite
CI->>Hub: Tests pass, automerge dispatched
Hub->>Main: Squash merge PR
Note over Main: Stack pointer advancedFrom push in a source repo to merged stack pointer, no manual steps on the happy path.
Practical Notes
The default
GITHUB_TOKENcan't trigger workflows in other repos or access private submodules. You need a PAT withrepo+workflowscope. This is the #1 setup gotcha.
- Secrets: A single PAT (
GH_SUBMODULE_PAT) withrepo+workflowscope, shared across the hub and source repos. The hub uses it for auto-update and auto-merge; source repos use it for the dispatch trigger. - GitHub Free tier: No branch protection rules or native auto-merge. The auto-merge workflow implements merge logic manually via the GitHub API, checking mergeable state, updating branches, and performing squash merges through API calls.
- Race conditions: Two source repos push at nearly the same time. Both trigger dispatches. The second auto-update overwrites the first with a superset of changes (both updates). The rolling PR pattern handles this by design.
- Manual override: The test workflow supports
workflow_dispatchwith ref inputs. You can manually test any combination of branches, tags, or SHAs from the GitHub Actions UI without going through the auto-update flow.
Takeaway
Three workflows. One rolling PR. A state machine that handles success, failure, staleness, and conflicts.
The integration hub doesn't slow down any source repo. It doesn't add required checks to source PRs. It runs in parallel, testing combinations as they appear, advancing the known-good stack pointer when they pass, and alerting when they don't.
We're not claiming this is the only way to do it. But 134 packages across 3 monorepos are now tested automatically, and we stopped finding out about integration failures during demos.
Resources
- Part 1: 134 Packages, 3 Monorepos, Zero Guarantees
- constructive: the backend monorepo
- pgpm.io: custom PostgreSQL package manager
- GitHub Actions:
repository_dispatch - GitHub Actions:
workflow_dispatch