Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Commits and pull requests follow the [contribution conventions](../CONTRIBUTING.

## Project guidance

- [Hub documentation](../hub/docs/README.md) covers the Rails application, deployment, previews, and review collaboration.
- [Hub documentation](../hub/docs/README.md) covers the Rails application, deployment, previews, and review collaboration. Its [operator walkthroughs](../hub/docs/deployment.md#manual-operator-walkthroughs) cover VM installation, Azure DNS/HTTPS, GitHub App connection, updates, source builds, patches, and recovery.
- [Starter development](starter-development.md) owns the catalog, shared bundle, assembly contract and checks. [Minimal instructions](../starters/minimal/AGENTS.md) and [CoreAI instructions](../starters/coreai/AGENTS.md) own their apps. The copied [Replica instructions](../starters/shared/REPLICA-AGENTS.md), [workflow guide](../starters/shared/REPLICA.md) and [troubleshooting guide](../starters/shared/SETUP.md) use paths in the assembled repository.
- [Build action setup](../CONTRIBUTING.md#replica-build-action) covers the shared action.
- [Controls documentation](../hub/docs/prototype-controls.md) covers the shared engine and definition schema owned by Hub.
Expand Down
9 changes: 5 additions & 4 deletions docs/prototype-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Before setting up Hub, confirm what is available, what needs approval, and who w

- **A server for Hub.** Confirm that the server runs Linux, supports Docker and Docker Compose, keeps data between restarts, and has backups. It must be allowed to download Replica and connect to GitHub. See the [server requirements](../hub/docs/deployment.md#requirements) and [network requirements](../hub/docs/deployment.md#outbound-network-access).
- **A dedicated domain.** Reserve an entire domain for Hub and its previews, with no unrelated apps using it. Confirm that you can configure DNS and HTTPS for Hub and its wildcard preview addresses. See [why a subdomain on a shared domain is not enough](../hub/docs/deployment.md#choose-an-isolated-domain).
- **GitHub permissions.** Determine whether your organization can install Replica's private [GitHub App](../hub/docs/github-app-setup.md#setup) and run its [build workflow](../hub/docs/github-actions-builds.md). The App must be created under your organization. Check any restrictions on actions and downloads.
- **GitHub permissions.** Determine whether your organization can install Replica's [GitHub App](../hub/docs/github-app-setup.md#manifest-setup) and run its [build workflow](../hub/docs/github-actions-builds.md). Prefer a private organization-owned App for team sign-in. For an independently registered App, review [manual connection and visibility](../hub/docs/github-app-setup.md#manual-creation-and-connection). Check any restrictions on actions and downloads.
- **Approval and ongoing support.** Check whether your organization needs to approve Hub, Copilot, the Replica plugin, or the AI service your team uses. Agree who will look after Hub, its passwords and access keys, and its backups.

Use sample data for prototypes. Do not include passwords or access keys from live systems, or real customer data.
Expand Down Expand Up @@ -219,7 +219,7 @@ Try the interactions and confirm that the prototype uses your team's components.

If Hub is already running, reuse it and continue at step 6. You do not need to deploy another Hub.

Deployment can happen alongside steps 2 through 4. Follow the [Hub deployment guide](../hub/docs/deployment.md) on the Hub server, or share it with the person handling deployment.
Deployment can happen alongside steps 2 through 4. Follow the [Hub deployment guide](../hub/docs/deployment.md) on the Hub server, or share it with the person handling deployment. For an ordered walkthrough, use [manual VM installation](../hub/docs/manual-vm-installation.md), with [Azure DNS/HTTPS](../hub/docs/azure-vm-deployment.md) when applicable.

**Keep the deployment settings safe:**

Expand Down Expand Up @@ -258,7 +258,7 @@ If the reviewed app and approved guidance are already on GitHub's default branch

**Access rules:**

- Keep the App private and organization-owned. An App created under a personal account would only let that person sign in.
- Prefer a private organization-owned App for team sign-in. A private personal App allows only its owner to sign in. Independently registered Apps need the [manual flow's visibility and access review](../hub/docs/github-app-setup.md#manual-creation-and-connection).
- Before connecting a public repository, review [who will be able to access its reviews](../hub/docs/authentication-and-repository-authorization.md#app-ownership-and-repository-visibility).
- Creating the App does not automatically make anyone a Hub administrator.

Expand All @@ -267,7 +267,8 @@ If the reviewed app and approved guidance are already on GitHub's default branch

| Hub setup | Action |
| --- | --- |
| New Hub | Open `/setup` and enter the setup code from step 5. Follow the [GitHub App setup instructions](../hub/docs/github-app-setup.md#setup) to create a private App owned by your organization and install it on the prototype repository. |
| New Hub, new organization App | Open `/setup` and enter the setup code from step 5. Follow the [manifest setup instructions](../hub/docs/github-app-setup.md#manifest-setup) to create a private App owned by your organization and install it on the prototype repository. |
| New Hub, existing App or manual registration | Follow [manual creation and connection](../hub/docs/github-app-setup.md#manual-creation-and-connection) to approve one installation, even when its account differs from the App's registration owner. Repository access checks still apply to each person. |
| Existing Hub | Reuse its GitHub App and include this repository in the App's installation on GitHub. If it is already included, no installation change is needed. Do not repeat initial setup. |

3. If Hub does not have an administrator yet, have the person who will look after it [register as a Hub administrator](../hub/docs/security-audit.md#enroll-an-operator).
Expand Down
4 changes: 2 additions & 2 deletions hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ GitHub stores the source code and decides repository access. The private App let

Replica Hub publishes a public container image at `ghcr.io/github/replica-hub`. The recommended deployment uses the release bundle to run that image with Docker Compose.

The example below selects `hub-v1.0.0`, the stable Hub release published on October 15, 2026. Its public release assets do not require GitHub authentication. See [Deploy Replica Hub](docs/deployment.md#download-the-deployment-bundle) for the complete setup requirements.
Select an explicit `hub-v*` release from [Hub releases](https://github.com/github/replica/releases) and replace `hub-vREPLACE_WITH_VERSION` below. Public release assets do not require GitHub authentication. See [Deploy Replica Hub](docs/deployment.md#download-the-deployment-bundle) for the requirements, or use the walkthroughs for [a Linux VM](docs/manual-vm-installation.md) and [Azure DNS/HTTPS](docs/azure-vm-deployment.md).

On a Linux server with `curl`, `tar`, and `sha256sum`, download and verify the bundle before extracting it:

```sh
HUB_RELEASE=hub-v1.0.0
HUB_RELEASE=hub-vREPLACE_WITH_VERSION
HUB_RELEASE_URL="https://github.com/github/replica/releases/download/${HUB_RELEASE}"
mkdir replica-hub &&
cd replica-hub &&
Expand Down
55 changes: 55 additions & 0 deletions hub/app/controllers/github_app_setup_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class GithubAppSetupController < SuperglueController
before_action -> { enforce_security_rate_limit(:github_app_setup, identity: trusted_request_ip) }, only: :create

rescue_from GithubAppSetup::Error, with: :setup_not_found
rescue_from GithubAppSetup::ImportError, with: :manual_import_failed
rescue_from GithubClient::Error, with: :setup_not_found
rescue_from GithubAppConfiguration::Installation::OwnerMismatch, with: :setup_not_found
rescue_from GithubAppConfiguration::Superseded, ActiveRecord::StaleObjectError, with: :setup_not_found
Expand All @@ -24,6 +25,42 @@ def create
redirect_to setup_path
end

# Shows the manual registration checklist only after bootstrap authorization.
# @route GET /setup/manual (manual_setup)
def manual
return setup_not_found if !setup.authorized? || GithubAppConfiguration.configured?

@registration = { manifest: setup.registration_manifest }
end

# Connects an independently registered App without changing its GitHub settings.
# @route POST /setup/manual (setup_manual)
def import
return setup_not_found if !setup.authorized? || GithubAppConfiguration.configured?

upload = params[:private_key]
limit = GithubAppConfiguration::Administration::PRIVATE_KEY_LIMIT
unless upload.is_a?(ActionDispatch::Http::UploadedFile) && upload.size <= limit
return manual_error(t("github_app_setup.manual.invalid_key"))
end

setup.import! \
app_id: params[:app_id], client_id: params[:client_id],
client_secret: params[:client_secret], private_key: upload.read(limit + 1),
webhook_secret: params[:webhook_secret], installation_id: params[:installation_id],
confirmed: params[:confirmed]
session[:github_app_manual_completed_at] = Time.current.to_i
redirect_to install_setup_path, status: :see_other
rescue GithubAppSetup::ImportError => error
manual_error(t("github_app_setup.manual.errors.#{error.reason}", default: t("github_app_setup.manual.failed")))
rescue GithubAppSetup::InvalidState
manual_error(t("github_app_setup.manual.failed"))
rescue GithubClient::Error
manual_error(t("github_app_setup.manual.errors.github_failed"))
rescue GithubAppConfiguration::Installation::OwnerMismatch
manual_error(t("github_app_setup.manual.errors.github_failed"))
end

# @route GET /setup/callback (callback_setup)
def callback
configuration = setup.exchange!(code: params[:code], state: params[:state])
Expand All @@ -34,6 +71,13 @@ def callback

# @route GET /setup/install (install_setup)
def install
completed_at = session.delete(:github_app_manual_completed_at)
if completed_at && Time.current.to_i - completed_at.to_i <= GithubAppSetup::MAXIMUM_AGE &&
GithubAppConfiguration.configured?
@manual_connection = true
return render :install
end

return setup_not_found if !setup.installation_authorized?

configuration = GithubAppConfiguration.current
Expand All @@ -48,6 +92,7 @@ def security_audit_action
{
"create" => "github_app_setup.authorize",
"callback" => "github_app_setup.exchange",
"import" => "github_app_setup.import",
"install" => "github_app_setup.install"
}[action_name]
end
Expand All @@ -66,4 +111,14 @@ def setup
def setup_not_found(_error = nil)
head :not_found
end

def manual_import_failed(error)
manual_error(t("github_app_setup.manual.errors.#{error.reason}", default: t("github_app_setup.manual.failed")))
end

def manual_error(message)
@setup_error = message
@registration = { manifest: setup.registration_manifest }
render :manual, status: :unprocessable_entity
end
end
27 changes: 27 additions & 0 deletions hub/app/javascript/pages/github-app-page/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@ export default meta
type Story = StoryObj<typeof meta>

export const Configured: Story = {}
export const PinnedInstallationActive: Story = {
args: {
data: {
...data,
identity: "App 123 - octocat (User, identity 1)",
installation: "Approved installation 456 in github: active",
paragraphs: [
...data.paragraphs,
"This connection uses an explicitly approved installation. Webhook configuration and secret rotation affect every installation of this App.",
],
},
},
}
export const PinnedInstallationRemoved: Story = {
args: {
data: {
...data,
identity: "App 123 - octocat (User, identity 1)",
installation: "Approved installation 456 in github: removed",
paragraphs: [
...data.paragraphs,
"The approved installation is unavailable. Hub will not adopt another installation. Restore access to that installation at GitHub, or review the documented local reset and manual reconnection procedure if its ID has changed.",
"This connection uses an explicitly approved installation. Webhook configuration and secret rotation affect every installation of this App.",
],
},
},
}
export const InstallationRequired: Story = {
args: {
data: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,13 @@ export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const ManualConnection: Story = {
args: {
data: {
...meta.args.data,
description:
"The App and approved installation are connected. Enroll a Hub operator at /operator/enrollment, verify GitHub sign-in, and check a supported webhook delivery. Connection does not verify the OAuth client secret or webhook delivery.",
},
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { render, screen } from "@testing-library/react"
import { describe, it } from "vitest"

import { GithubAppSetupManualPage } from "./index"
import type { GithubAppSetupManualPageData } from "./types"

describe("GithubAppSetupManualPage()", () => {
it("posts a bootstrap-protected multipart form with explicit confirmation and an uploaded private key", () => {
const data: GithubAppSetupManualPageData = {
authenticityToken: "csrf-token",
confirmLabel: "Connect only this installation",
description: "Connect an existing GitHub App.",
fields: [
{ name: "app_id", label: "App ID", hint: "From App settings", secret: false },
{ name: "client_id", label: "Client ID", hint: "From App settings", secret: false },
{ name: "client_secret", label: "Client secret", hint: "Keep private", secret: true },
{ name: "webhook_secret", label: "Webhook secret", hint: "Keep private", secret: true },
{
name: "installation_id",
label: "Installation ID",
hint: "Selected installation",
secret: false,
},
],
heading: "Connect an existing App",
instructions: [{ title: "Register the App", body: "Use these settings on GitHub." }],
keyHint: "Upload the PEM file",
keyLabel: "Private key",
pageTitle: "Connect App",
permissions: [{ label: "Contents", value: "Read and write" }],
productName: "Replica Hub",
settings: [{ label: "Callback URL", value: "https://hub.example.com/auth/github/callback" }],
submitLabel: "Connect App",
submitPath: "/setup/manual",
}
const { container, rerender } = render(<GithubAppSetupManualPage data={data} />)
const form = container.querySelector("form")

expect(form).toHaveAttribute("action", "/setup/manual")
expect(form).toHaveAttribute("method", "post")
expect(form).toHaveAttribute("enctype", "multipart/form-data")
expect(container.querySelector("input[name=authenticity_token]")).toHaveValue("csrf-token")
expect(screen.getByLabelText(/Private key/)).toHaveAttribute("type", "file")
expect(screen.getByLabelText(/Private key/)).toBeRequired()
expect(screen.getByLabelText("Private key")).toHaveAccessibleName("Private key")
expect(screen.getByLabelText("Private key")).toHaveAccessibleDescription("Upload the PEM file")
expect(
screen.getByRole("checkbox", { name: "Connect only this installation" }),
).not.toBeChecked()
expect(screen.getByRole("checkbox", { name: "Connect only this installation" })).toBeRequired()
expect(
screen.getByRole("checkbox", { name: "Connect only this installation" }),
).toHaveAttribute("value", "1")
for (const label of [
"App ID",
"Client ID",
"Client secret",
"Webhook secret",
"Installation ID",
]) {
expect(screen.getByLabelText(new RegExp(label))).toBeRequired()
}

rerender(
<GithubAppSetupManualPage
data={{ ...data, error: "GitHub could not verify the connection." }}
/>,
)

expect(screen.getByRole("alert")).toHaveTextContent("GitHub could not verify the connection.")
for (const label of ["Client secret", "Webhook secret"]) {
expect(screen.getByLabelText(new RegExp(label))).toHaveAttribute("type", "password")
expect(screen.getByLabelText(new RegExp(label))).toHaveAttribute(
"autocomplete",
"new-password",
)
expect(screen.getByLabelText(new RegExp(label))).toHaveValue("")
}
expect(screen.getByLabelText(/Private key/)).toHaveValue("")
})
})
85 changes: 85 additions & 0 deletions hub/app/javascript/pages/github-app-setup-manual-page/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Button, FormControl, Heading, Text, TextInput } from "@primer/react-brand"

import { GithubAppSetupHeader } from "@features/github-app-setup-header"
import { GithubAppSetupPage } from "@features/github-app-setup-page"
import type { GithubAppSetupManualPageData } from "./types"

import "./styles.css"

/** Guides an operator through GitHub's settings before importing App credentials. */
export function GithubAppSetupManualPage({ data }: { data: GithubAppSetupManualPageData }) {
return (
<GithubAppSetupPage>
<GithubAppSetupHeader data={data} />

{data.error ? (
<Text as="p" role="alert">
{data.error}
</Text>
) : null}

{data.instructions.map((step, index) => (
<section key={step.title} className="setup-manual__section">
<Heading as="h2" size="6">
{step.title}
</Heading>
<Text as="p">{step.body}</Text>
{index === 0 || index === 1 ? (
<dl className="setup-manual__settings">
{(index === 0 ? data.settings : data.permissions).map((setting) => (
<div key={setting.label}>
<dt>{setting.label}</dt>
<dd>
<code>{setting.value}</code>
</dd>
</div>
))}
</dl>
) : null}
</section>
))}

<form
action={data.submitPath}
method="post"
encType="multipart/form-data"
className="setup-manual__section"
>
<input type="hidden" name="authenticity_token" value={data.authenticityToken} />
{data.fields.map((field) => (
<FormControl key={field.name} required fullWidth>
<FormControl.Label>{field.label}</FormControl.Label>
<TextInput
name={field.name}
type={field.secret ? "password" : "text"}
autoComplete={field.secret ? "new-password" : "off"}
maxLength={field.secret ? 4096 : 255}
fullWidth
/>
<FormControl.Hint>{field.hint}</FormControl.Hint>
</FormControl>
))}
<label className="setup-manual__section">
<span>{data.keyLabel}</span>
<input
type="file"
name="private_key"
accept=".pem"
required
aria-describedby="setup-key-hint"
/>
</label>
<Text as="p" id="setup-key-hint">
{data.keyHint}
</Text>
<label className="setup-manual__confirmation">
<input type="checkbox" name="confirmed" value="1" required />
<span>{data.confirmLabel}</span>
</label>
<Button type="submit" variant="primary" block>
{data.submitLabel}
</Button>
</form>
</GithubAppSetupPage>
)
}
Loading
Loading