GitHub integration
How BernOps connects to GitHub repositories, what permissions are required, and how exports stay reviewable.
On this page
The GitHub connection links one BernOps project to one repository that is available through a GitHub App installation. The current implementation uses that link to list available repositories, inspect repository tree state, and export BernOps-managed Playwright jobs as draft pull requests.
BernOps connects one repository per project. It does not clone the repository, run repository code, merge pull requests, change repository settings, manage GitHub secrets, or write directly to the default branch.
What the integration does
After a project is connected, BernOps can perform these GitHub actions through the installed GitHub App:
- List repositories that the installation exposes so a project admin can choose one repository for the project.
- Store the selected repository identity, default branch, and export folder path in BernOps.
- Read the selected repository tree when the project requests repository state.
- Read existing files at BernOps export paths to avoid overwriting files that BernOps does not own.
- Create a BernOps branch named like
bernops/update-tests-20260503121000-ab12cd34. - Commit BernOps-managed Playwright job files to that branch with the commit message
Update BernOps tests. - Open or update a draft pull request titled
Update BernOps tests. - Track the pull request state from GitHub webhooks and update BernOps job sync metadata.
BernOps exports only saved jobs where the job metadata says source_kind = bernops and managed_by = platform. The export endpoint does not export arbitrary repository files.
Setup flow
The installation starts inside BernOps, not directly from GitHub App settings. The BernOps-generated install URL carries signed setup state that binds the GitHub flow to one BernOps project, workspace, user, and nonce.
- A signed-in BernOps user with project admin access requests
POST /api/projects/{projectId}/github/install-url. - The backend creates a random setup nonce, stores it in
github_setup_states, signs a setup state payload with HMAC-SHA256, and sets a 10 minute expiry. - The browser is redirected to GitHub's GitHub App OAuth authorization URL with that signed state.
- GitHub redirects back to
GET /api/github/setupwith an OAuthcode. - The backend exchanges the code for a GitHub user access token, encrypts that token with AES-GCM using a key derived from the GitHub webhook secret, stores it temporarily on the setup state row, and redirects to the GitHub App installation URL with the same signed state.
- GitHub redirects back to
GET /api/github/setupwith aninstallation_id. - The backend verifies the signed state, loads the unconsumed setup state row, decrypts the temporary user token, and calls GitHub to verify that the authorizing GitHub user can access the returned installation.
- The backend fetches installation metadata from GitHub and finalizes setup through a service-role database RPC that consumes the setup state and upserts the installation atomically.
- The encrypted GitHub user token is cleared when setup is finalized. Expired setup rows also have the temporary token cleared by the retention cleanup worker.
If the signed state is missing, expired, reused, or does not match the stored project, workspace, and user, setup is rejected and the user is redirected back to a GitHub setup result page.
Required GitHub permissions
BernOps should be configured with the minimum GitHub App permissions needed by the current code. GitHub's own documentation says GitHub Apps start with no permissions and that app permissions determine which APIs and webhooks the app can use. GitHub also recommends choosing the minimum permissions required by the app.
| GitHub App permission | Access | Why BernOps needs it |
|---|---|---|
| Metadata | Read-only | Identify installations and repositories exposed by the installation. |
| Contents | Read and write | Read repository trees and file contents, then create Git trees, commits, and branch refs for BernOps export branches. |
| Pull requests | Read and write | Open or update draft pull requests and process pull request state changes. |
No GitHub organization permissions are required by the current integration. The current code does not use GitHub Actions, Secrets, Administration, Deployments, Packages, Members, Issues, Checks, or Workflows permissions.
GitHub lets an app installation be granted either all repositories or selected repositories. BernOps project actions are stored against one selected repository, but the GitHub installation token is scoped by what the GitHub installation grants. For least privilege, select only the repositories that BernOps should be able to list and connect.
Webhook events
The GitHub App should subscribe to these events:
InstallationInstallation repositoriesPull request
GitHub sends ping deliveries automatically when webhooks are configured or tested. BernOps accepts ping but records it as ignored.
If BernOps cannot process a delivery after recording it, the endpoint records the failure and returns a non-2xx response so GitHub can retry the delivery.
BernOps processes the subscribed events this way:
| Event | Actions BernOps handles |
|---|---|
installation | Deletes local installation records when GitHub deletes the installation, and marks installations suspended or unsuspended when GitHub sends suspend/unsuspend actions. |
installation_repositories | Disconnects BernOps repository records when a repository is removed from the GitHub App installation. |
pull_request | Updates tracked BernOps export pull requests, marks jobs synced when a tracked PR is merged, and resets pending job sync metadata when a tracked PR is closed without merge. |
| Other events | Ignored after signature verification and delivery recording. |
Auth and token handling
GitHub documents three GitHub App authentication modes: authenticating as the app with a JWT, authenticating as an app installation with an installation access token, and authenticating on behalf of a user with a user access token. BernOps uses all three for different purposes.
| Token or credential | Where it is used | BernOps handling |
|---|---|---|
| GitHub App private key | Backend only | Loaded from GITHUB_APP_PRIVATE_KEY. Used to sign short-lived app JWTs with RS256. |
| App JWT | Backend to GitHub | Used to fetch installation metadata and exchange for installation access tokens. |
| Installation access token | Backend to GitHub | Used for repository listing, tree/file reads, Git tree/commit/ref writes, and pull request creation/update. Cached in backend memory and refreshed before expiry. Not stored in the database. |
| User OAuth token during setup | Backend to GitHub | Used only to verify that the installing GitHub user can access the returned installation. Temporarily encrypted in github_setup_states, then cleared when setup finalizes. |
| Webhook secret | Backend only | Loaded from GITHUB_WEBHOOK_SECRET. Used to verify GitHub webhook signatures and to sign setup state. |
If required GitHub configuration is missing, GitHub endpoints return 503 instead of continuing with a partial integration.
Data BernOps stores
BernOps stores GitHub integration metadata needed to keep the project connection and export state consistent.
| Data | Stored fields |
|---|---|
| Installation | Workspace ID, GitHub installation ID, account login, account type, repository selection mode, permissions JSON, installer, suspension timestamp, timestamps. |
| Repository connection | Project ID, installation row ID, GitHub repository ID, full name, owner, name, default branch, export folder path, connector, timestamps. |
| Export pull request | Repository connection ID, branch name, base branch, head commit SHA, PR number, PR URL, status, draft flag, last error, creator, timestamps. |
| Webhook delivery | GitHub delivery ID, event type, action, installation ID, repository ID, processing status, last error, received/processed timestamps. |
| Setup state | Nonce, project ID, workspace ID, user ID, expiry, consumed timestamp, temporary encrypted setup token, OAuth completion timestamp. |
BernOps does not store raw GitHub webhook payload bodies in github_webhook_deliveries. It stores the delivery metadata and processing status needed for deduplication, retries, and auditability.
Export behavior
When a project admin exports tests to GitHub, BernOps does not write to the default branch. It creates or updates a BernOps-owned branch and opens or updates a draft pull request.
The export pipeline is:
- Load the connected repository and installation.
- Acquire a repository-level export lock for up to 30 minutes so two export requests cannot write competing GitHub branches at the same time.
- Load BernOps-managed jobs for the project.
- Build target paths by joining the configured export folder with each job file name.
- Resolve the current base branch SHA and read existing files from that exact commit to detect conflicts.
- Create a Git tree, commit, and branch ref through GitHub's Git APIs, failing if the base branch moved after the conflict check.
- Open a new draft pull request or update the tracked open BernOps pull request.
- Refresh file metadata from GitHub and mark exported jobs as
pending_pr.
The default export folder is bernops/e2e. A custom folder is accepted only if it is a safe relative path. Absolute paths, backslashes, .., and paths that clean outside the repository folder are rejected.
Conflict protection
BernOps skips files instead of overwriting repository content it cannot prove it owns.
| Situation | Result |
|---|---|
| Target file already exists but the BernOps job has no previously recorded GitHub blob SHA | Skipped as remote file already exists and has not been exported by BernOps. |
| Target file changed in GitHub since the last BernOps export | Skipped as remote file changed since the last BernOps export. |
| Target file was removed after a previous BernOps export | Skipped as remote file was removed since the last BernOps export. |
| Job code is empty | Skipped and marked as an error. |
| Two jobs resolve to the same export path | The later duplicate is skipped and marked as an error. |
| Existing BernOps export branch moved unexpectedly | Export fails with a conflict and asks the user to retry after refreshing PR status. |
| Default branch moves during an initial export | Export fails instead of writing files that were not conflict-checked. |
When updating an existing BernOps export branch, the backend passes the previously tracked head SHA and updates the Git ref with force = false. This protects against silently overwriting work added to the export branch outside BernOps.
BernOps access control
BernOps protects GitHub management actions with the same project role checks used elsewhere in the app.
| BernOps action | Required BernOps access |
|---|---|
| Read connection status | Project access. |
| Read selected repository tree | Project access. |
| Read tracked export pull requests | Project access. |
| Create GitHub install URL | Workspace owner/admin access. |
| List installation repositories for selection | Project admin access. |
| Select or replace repository connection | Project admin access. |
| Disconnect repository | Project admin access. |
| Export tests to GitHub | Project admin access. |
GitHub setup and webhook endpoints are unauthenticated HTTP routes because GitHub calls them directly. They are not open trust boundaries: setup requires signed state and stored nonce validation, and webhooks require GitHub's X-Hub-Signature-256 HMAC signature to match the configured webhook secret.
Disconnecting and uninstalling
Disconnecting a repository from BernOps deletes the selected repository connection for the project. The GitHub App installation remains available to the workspace, so a project admin can reconnect this project or connect another project later.
When a repository connection is deleted or replaced, BernOps resets GitHub sync metadata on jobs that referenced the old connection. If an export lock is active, connect and disconnect mutations return a conflict instead of changing the connection while GitHub writes are in progress.
If the GitHub App installation is deleted in GitHub, the installation webhook causes BernOps to delete the local installation row. Because repository connections reference installations with ON DELETE CASCADE, repository connections for that installation are removed as well.
What BernOps does not do
These statements reflect the current code paths for the GitHub integration:
- BernOps does not merge GitHub pull requests.
- BernOps does not write directly to the repository default branch.
- BernOps does not force-update Git refs.
- BernOps does not request GitHub organization permissions for this integration.
- BernOps does not use GitHub Actions, Secrets, Administration, Deployments, Packages, Members, Issues, Checks, or Workflows APIs in the current integration.
- BernOps does not store installation access tokens in the database.
- BernOps does not keep the setup user OAuth token after setup is finalized.
- BernOps does not store raw GitHub webhook payload bodies in the webhook delivery table.
- BernOps does not clone the connected repository or execute code from it during connection or export.
Official references
GitHub's documentation for the relevant platform behavior:
- Choosing permissions for a GitHub App
- About authentication with a GitHub App
- Using webhooks with GitHub Apps
- Validating webhook deliveries
- Git trees REST API
- Git commits REST API
- Git references REST API
- Repository contents REST API
- Pull requests REST API
For exact BernOps REST paths and response shapes, open API Reference and search for GitHub.