From 2df02d019f0b6ede276c40b54048e9916aa19a22 Mon Sep 17 00:00:00 2001 From: Will <32074613+weastler@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:48:34 +0000 Subject: [PATCH 01/12] feat(hub): connect manually configured GitHub Apps Add bootstrap-protected manual setup for one verified installation, independent of App registration ownership. Preserve manifest connections and scope polling, webhooks, diagnostics, and recovery to the approved installation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/prototype-environment.md | 2 + .../github_app_setup_controller.rb | 45 ++++ .../pages/github-app-page/stories.tsx | 14 + .../github-app-setup-install-page/stories.tsx | 10 + .../index.test.tsx | 75 ++++++ .../github-app-setup-manual-page/index.tsx | 83 ++++++ .../github-app-setup-manual-page/stories.tsx | 108 ++++++++ .../github-app-setup-manual-page/styles.css | 30 +++ .../github-app-setup-manual-page/types.ts | 16 ++ .../index.test.tsx | 2 + .../github-app-setup-register-page/index.tsx | 2 + .../stories.tsx | 2 + .../github-app-setup-register-page/types.ts | 2 + .../github-app-setup-show-page/stories.tsx | 3 +- hub/app/models/github_app_configuration.rb | 44 +++- .../github_app_configuration/diagnostics.rb | 10 + .../github_app_configuration/installation.rb | 46 +++- .../github_app_configuration/ownership.rb | 6 +- hub/app/models/github_app_setup.rb | 70 ++++- hub/app/models/github_webhook.rb | 10 + hub/app/models/security_audit_event.rb | 2 +- .../webhook_recovery_checkpoint/scan.rb | 6 +- hub/app/models/webhook_redelivery.rb | 5 + .../views/github_app_setup/install.json.props | 2 +- .../views/github_app_setup/manual.json.props | 23 ++ hub/app/views/github_app_setup/manual.tsx | 7 + .../github_app_setup/register.json.props | 2 + .../github_apps/_configuration.json.props | 13 +- hub/app/views/github_apps/show.json.props | 9 +- hub/config/locales/github_app.en.yml | 3 + hub/config/locales/github_app_setup.en.yml | 60 ++++- hub/config/routes.rb | 2 + ...000_add_pinned_github_app_installations.rb | 18 ++ hub/db/schema.rb | 7 +- hub/docs/architecture.md | 2 +- ...entication-and-repository-authorization.md | 2 +- hub/docs/deployment.md | 2 + hub/docs/github-app-setup.md | 37 ++- hub/docs/security-audit.md | 4 +- hub/docs/security-rate-limits.md | 2 +- .../github_app_manual_setup_test.rb | 142 ++++++++++ .../models/github_app_manual_import_test.rb | 194 ++++++++++++++ .../github_app_pinned_installation_test.rb | 242 ++++++++++++++++++ 43 files changed, 1331 insertions(+), 35 deletions(-) create mode 100644 hub/app/javascript/pages/github-app-setup-manual-page/index.test.tsx create mode 100644 hub/app/javascript/pages/github-app-setup-manual-page/index.tsx create mode 100644 hub/app/javascript/pages/github-app-setup-manual-page/stories.tsx create mode 100644 hub/app/javascript/pages/github-app-setup-manual-page/styles.css create mode 100644 hub/app/javascript/pages/github-app-setup-manual-page/types.ts create mode 100644 hub/app/views/github_app_setup/manual.json.props create mode 100644 hub/app/views/github_app_setup/manual.tsx create mode 100644 hub/db/migrate/20261026200000_add_pinned_github_app_installations.rb create mode 100644 hub/test/integration/github_app_manual_setup_test.rb create mode 100644 hub/test/models/github_app_manual_import_test.rb create mode 100644 hub/test/models/github_app_pinned_installation_test.rb diff --git a/docs/prototype-environment.md b/docs/prototype-environment.md index 4cdde0d3..c01c00e4 100644 --- a/docs/prototype-environment.md +++ b/docs/prototype-environment.md @@ -89,6 +89,8 @@ Arrange consistent backups of the databases and Build files, and a separate prot Open `/setup` on your deployed Hub, enter the setup code printed during initialization, and follow [GitHub App setup](../hub/docs/github-app-setup.md). Create the private App under your GitHub organization and install it on the prototype repository. A private App owned by a personal account allows only that person to sign in. Keep the App private and check the intended review audience before connecting a public repository. +If manifest registration is unavailable or the App already exists, follow [manual creation and connection](../hub/docs/github-app-setup.md#manual-creation-and-connection) instead. This lets you approve one existing installation in another account without changing how Hub checks each person's repository access. + Enroll the person administering the hub through `/operator/enrollment`, following the [operator enrollment instructions](../hub/docs/security-audit.md#enroll-an-operator). Creating the App does not grant that role automatically. Sign in to the hub with a GitHub account that has write access to the prototype repository. Open that repository and choose **Open installation pull request**. Review and merge the proposed `.github/workflows/replica.yml` workflow. See [repository workflow installation](../hub/docs/github-actions-builds.md#install-the-repository-workflow). diff --git a/hub/app/controllers/github_app_setup_controller.rb b/hub/app/controllers/github_app_setup_controller.rb index 8dab14a2..9d46c852 100644 --- a/hub/app/controllers/github_app_setup_controller.rb +++ b/hub/app/controllers/github_app_setup_controller.rb @@ -23,6 +23,37 @@ 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 = setup.registration + 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::Error, GithubClient::Error, + GithubAppConfiguration::Installation::OwnerMismatch + manual_error(t("github_app_setup.manual.failed")) + end + # @route GET /setup/callback (callback_setup) def callback configuration = setup.exchange!(code: params[:code], state: params[:state]) @@ -33,6 +64,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 @@ -47,6 +85,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 @@ -65,4 +104,10 @@ def setup def setup_not_found(_error = nil) head :not_found end + + def manual_error(message) + @setup_error = message + @registration = setup.registration + render :manual, status: :unprocessable_entity + end end diff --git a/hub/app/javascript/pages/github-app-page/stories.tsx b/hub/app/javascript/pages/github-app-page/stories.tsx index 8f820dd7..9e97d5d6 100644 --- a/hub/app/javascript/pages/github-app-page/stories.tsx +++ b/hub/app/javascript/pages/github-app-page/stories.tsx @@ -41,6 +41,20 @@ export default meta type Story = StoryObj export const Configured: Story = {} +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: { diff --git a/hub/app/javascript/pages/github-app-setup-install-page/stories.tsx b/hub/app/javascript/pages/github-app-setup-install-page/stories.tsx index e3fe01b9..98ef74e4 100644 --- a/hub/app/javascript/pages/github-app-setup-install-page/stories.tsx +++ b/hub/app/javascript/pages/github-app-setup-install-page/stories.tsx @@ -20,3 +20,13 @@ export default meta type Story = StoryObj 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.", + }, + }, +} diff --git a/hub/app/javascript/pages/github-app-setup-manual-page/index.test.tsx b/hub/app/javascript/pages/github-app-setup-manual-page/index.test.tsx new file mode 100644 index 00000000..3a057bfc --- /dev/null +++ b/hub/app/javascript/pages/github-app-setup-manual-page/index.test.tsx @@ -0,0 +1,75 @@ +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() + 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.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( + , + ) + + 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))).toHaveValue("") + } + expect(screen.getByLabelText(/Private key/)).toHaveValue("") + }) +}) diff --git a/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx b/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx new file mode 100644 index 00000000..0a4e38e5 --- /dev/null +++ b/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx @@ -0,0 +1,83 @@ +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 ( + + + + {data.error ? ( + + {data.error} + + ) : null} + + {data.instructions.map((step, index) => ( +
+ + {step.title} + + {step.body} + {index === 0 || index === 1 ? ( +
+ {(index === 0 ? data.settings : data.permissions).map((setting) => ( +
+
{setting.label}
+
+ {setting.value} +
+
+ ))} +
+ ) : null} +
+ ))} + +
+ + {data.fields.map((field) => ( + + {field.label} + + {field.hint} + + ))} + + + +
+
+ ) +} diff --git a/hub/app/javascript/pages/github-app-setup-manual-page/stories.tsx b/hub/app/javascript/pages/github-app-setup-manual-page/stories.tsx new file mode 100644 index 00000000..48bae86b --- /dev/null +++ b/hub/app/javascript/pages/github-app-setup-manual-page/stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" + +import { GithubAppSetupManualPage } from "." + +const meta = { + title: "Hub/Pages/GitHub Setup/Manual", + component: GithubAppSetupManualPage, + args: { + data: { + pageTitle: "Connect an existing GitHub App", + productName: "Replica Hub", + heading: "Connect your GitHub App.", + description: + "Configure the App on your GitHub host, install it on the intended account, then enter its credentials here. Hub will not change the App's GitHub settings.", + authenticityToken: "storybook-token", + submitPath: "/setup/manual", + submitLabel: "Verify and connect this installation", + confirmLabel: + "I approve this App and installation for this Hub. I have checked the installation's account and selected repositories, configured the webhook, and understand that App credentials and webhook settings affect every installation of this App.", + keyLabel: "Private keys - PEM file", + keyHint: + "In the App's General settings, choose Generate a private key. Upload the downloaded PEM file (at most 16 KiB). Credentials are never shown again; re-enter them after an error.", + instructions: [ + { + title: "1. General", + body: "On your GitHub host, open your account or organization's Settings, Developer settings, GitHub Apps, then New GitHub App. For an existing App, open its settings instead. Use the values below. Leave Setup URL blank, and leave Request user authorization (OAuth) during installation and Redirect on update unchecked. Leave user authorization token expiration enabled. Return here after installing.", + }, + { + title: "2. Permissions & events", + body: "Set the repository permissions below. Under Subscribe to events, select Push and Workflow run. GitHub also delivers installation and installation-repository changes. Save the settings. For an existing installation, its account administrator must approve any increased permissions; Hub access cannot grant that authority.", + }, + { + title: "3. Webhook and visibility", + body: "Enable Active under Webhook, use the Webhook URL above, generate a long random Webhook secret, and keep SSL verification enabled. Enter the same secret below. GitHub must be able to reach Hub over HTTPS. A private App restricts who can authorize it; cross-account installation and team sign-in may require a public App, subject to GitHub policy. Public visibility does not make repositories public. Do not reuse an App whose webhook serves another integration; its URL and secret are shared across all installations.", + }, + { + title: "4. Install App", + body: "Choose Install App, select the intended account, and choose Only select repositories. Select the prototype repositories for this Hub. If approval is required, an authorized administrator must complete it. Copy the numeric installation ID from the resulting installation settings URL (the number after /installations/). Hub connects only this installation and will not automatically select a replacement.", + }, + { + title: "5. Credentials and confirmation", + body: "From the App's General settings, copy App ID and Client ID, choose Generate a new client secret, and generate a private key. Enter those values below, together with the webhook secret and installation ID. Hub verifies App and installation identity and reads the installation's repositories before saving. This verifies the private key, not the OAuth client secret, webhook delivery, or end-user sign-in; verify those separately after connection. Setup expires after 15 minutes.", + }, + ], + settings: [ + { label: "GitHub App name (choose a unique name)", value: "Replica Hub" }, + { label: "Homepage URL", value: "https://replica.example.com" }, + { label: "Callback URL", value: "https://replica.example.com/auth/github/callback" }, + { label: "Webhook URL", value: "https://replica.example.com/github/webhooks" }, + ], + permissions: [ + { label: "Repository permissions - Administration", value: "Read-only" }, + { label: "Repository permissions - Actions", value: "Read-only" }, + { label: "Repository permissions - Contents", value: "Read and write" }, + { label: "Repository permissions - Pull requests", value: "Read and write" }, + { label: "Repository permissions - Workflows", value: "Read and write" }, + ], + fields: [ + { + name: "app_id", + label: "App ID", + hint: "Copy App ID from the App's General settings, not its installation ID.", + secret: false, + }, + { + name: "client_id", + label: "Client ID", + hint: "Copy Client ID from the same App's General settings.", + secret: false, + }, + { + name: "client_secret", + label: "Client secrets - generated secret", + hint: "Generate a client secret in General settings and copy it here (at most 4 KiB).", + secret: true, + }, + { + name: "webhook_secret", + label: "Webhook secret", + hint: "Enter the same random secret saved in the App's Webhook settings (at most 4 KiB).", + secret: true, + }, + { + name: "installation_id", + label: "Installation ID", + hint: "Copy the numeric ID from the intended account's installation settings URL.", + secret: false, + }, + ], + }, + }, + parameters: { layout: "fullscreen" }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const ConnectionFailed: Story = { + args: { + data: { + ...meta.args.data, + error: + "Hub could not connect the App. Check the credentials, App and installation IDs, active installation status, and GitHub connectivity. No configuration was saved. Re-enter the credentials and try again.", + }, + }, +} diff --git a/hub/app/javascript/pages/github-app-setup-manual-page/styles.css b/hub/app/javascript/pages/github-app-setup-manual-page/styles.css new file mode 100644 index 00000000..5f2f05de --- /dev/null +++ b/hub/app/javascript/pages/github-app-setup-manual-page/styles.css @@ -0,0 +1,30 @@ +.setup-manual__section { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.setup-manual__section p { + margin: 0; +} + +.setup-manual__settings { + display: grid; + gap: 0.75rem; + margin: 0; +} + +.setup-manual__settings dt { + font-weight: 600; +} + +.setup-manual__settings dd { + margin: 0.25rem 0 0; + overflow-wrap: anywhere; +} + +.setup-manual__confirmation { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} diff --git a/hub/app/javascript/pages/github-app-setup-manual-page/types.ts b/hub/app/javascript/pages/github-app-setup-manual-page/types.ts new file mode 100644 index 00000000..11fc2f5d --- /dev/null +++ b/hub/app/javascript/pages/github-app-setup-manual-page/types.ts @@ -0,0 +1,16 @@ +import type { GithubAppSetupHeaderData } from "@features/github-app-setup-header/types" + +export interface GithubAppSetupManualPageData extends GithubAppSetupHeaderData { + pageTitle: string + authenticityToken: string + submitPath: string + submitLabel: string + confirmLabel: string + keyLabel: string + keyHint: string + error?: string | null + instructions: { title: string; body: string }[] + settings: { label: string; value: string }[] + permissions: { label: string; value: string }[] + fields: { name: string; label: string; hint: string; secret: boolean }[] +} diff --git a/hub/app/javascript/pages/github-app-setup-register-page/index.test.tsx b/hub/app/javascript/pages/github-app-setup-register-page/index.test.tsx index 04a2367b..c0b27b4f 100644 --- a/hub/app/javascript/pages/github-app-setup-register-page/index.test.tsx +++ b/hub/app/javascript/pages/github-app-setup-register-page/index.test.tsx @@ -8,6 +8,8 @@ const PAGE_DATA: GithubAppSetupRegisterPageData = { access: "You choose which repositories it can access.", description: "Replica Hub uses a private GitHub App.", heading: "Connect Replica Hub to GitHub.", + manualLabel: "Connect an existing App", + manualPath: "/setup/manual", manifest: '{"name":"Replica Hub"}', pageTitle: "Create App", productName: "Replica Hub", diff --git a/hub/app/javascript/pages/github-app-setup-register-page/index.tsx b/hub/app/javascript/pages/github-app-setup-register-page/index.tsx index 100767bd..d5e16fbe 100644 --- a/hub/app/javascript/pages/github-app-setup-register-page/index.tsx +++ b/hub/app/javascript/pages/github-app-setup-register-page/index.tsx @@ -22,6 +22,8 @@ export function GithubAppSetupRegisterPage({ data }: { data: GithubAppSetupRegis {data.submitLabel} + + {data.manualLabel} ) } diff --git a/hub/app/javascript/pages/github-app-setup-register-page/stories.tsx b/hub/app/javascript/pages/github-app-setup-register-page/stories.tsx index 049ac065..8bba84a7 100644 --- a/hub/app/javascript/pages/github-app-setup-register-page/stories.tsx +++ b/hub/app/javascript/pages/github-app-setup-register-page/stories.tsx @@ -11,6 +11,8 @@ const meta = { description: "Replica Hub uses a private GitHub App to discover and authorize prototypes.", heading: "Create the GitHub App", manifest: '{"name":"Replica Hub"}', + manualLabel: "Create manually or connect an existing App", + manualPath: "/setup/manual", pageTitle: "Create GitHub App", productName: "Replica Hub", submitLabel: "Create the App on GitHub", diff --git a/hub/app/javascript/pages/github-app-setup-register-page/types.ts b/hub/app/javascript/pages/github-app-setup-register-page/types.ts index e664f5d2..c1399c6b 100644 --- a/hub/app/javascript/pages/github-app-setup-register-page/types.ts +++ b/hub/app/javascript/pages/github-app-setup-register-page/types.ts @@ -6,4 +6,6 @@ export interface GithubAppSetupRegisterPageData extends GithubAppSetupHeaderData pageTitle: string submitLabel: string submitPath: string + manualLabel: string + manualPath: string } diff --git a/hub/app/javascript/pages/github-app-setup-show-page/stories.tsx b/hub/app/javascript/pages/github-app-setup-show-page/stories.tsx index cc9bbfb0..63a352f7 100644 --- a/hub/app/javascript/pages/github-app-setup-show-page/stories.tsx +++ b/hub/app/javascript/pages/github-app-setup-show-page/stories.tsx @@ -8,7 +8,8 @@ const meta = { args: { data: { authenticityToken: "storybook-token", - description: "Connect a private GitHub App owned by your organization or personal account.", + description: + "Enter the deployment bootstrap secret to create or connect this Hub's GitHub App.", heading: "Connect GitHub", organizationHint: "Leave blank to create the App for your personal account.", organizationLabel: "Organization", diff --git a/hub/app/models/github_app_configuration.rb b/hub/app/models/github_app_configuration.rb index 097596d0..2150beac 100644 --- a/hub/app/models/github_app_configuration.rb +++ b/hub/app/models/github_app_configuration.rb @@ -7,7 +7,10 @@ # app_slug :string not null # client_secret :text not null # credential_revision :bigint default(1), not null +# installation_account_login :string +# installation_account_type :string # installation_checked_at :datetime +# installation_policy :string default("owner"), not null # installation_state :string default("not_installed"), not null # installed_at :datetime # lock_version :integer default(0), not null @@ -38,8 +41,10 @@ # # github_app_configurations_installation_account ((installation_id IS NULL AND installation_account_github_id IS NULL) OR (installation_id IS NOT NULL AND installation_account_github_id IS NOT NULL AND installation_account_github_id > 0)) # github_app_configurations_installation_identity ((installation_state = 'not_installed' AND installation_id IS NULL) OR (installation_state <> 'not_installed' AND installation_id IS NOT NULL)) +# github_app_configurations_installation_policy (installation_policy IN ('owner', 'pinned')) # github_app_configurations_installation_state (installation_state IN ('not_installed', 'active', 'suspended', 'removed')) # github_app_configurations_owner_type (owner_type IN ('Organization', 'User')) +# github_app_configurations_pinned_installation (installation_policy <> 'pinned' OR (installation_id IS NOT NULL AND installation_account_github_id IS NOT NULL AND installation_account_type IN ('Organization', 'User') AND installation_account_type IS NOT NULL AND installation_account_login IS NOT NULL AND length(trim(installation_account_login)) > 0)) # github_app_configurations_positive_app_id (github_app_id > 0) # github_app_configurations_positive_installation_id (installation_id IS NULL OR installation_id > 0) # github_app_configurations_positive_owner_id (owner_github_id > 0) @@ -52,6 +57,7 @@ class GithubAppConfiguration < ApplicationRecord class Superseded < StandardError; end INSTALLATION_STATES = %w[not_installed active suspended removed].freeze + INSTALLATION_POLICIES = %w[owner pinned].freeze OWNER_TYPES = %w[Organization User].freeze encrypts :client_secret @@ -66,9 +72,12 @@ class Superseded < StandardError; end validates :installation_id, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true validates :installation_account_github_id, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true validates :installation_state, inclusion: { in: INSTALLATION_STATES } + validates :installation_policy, inclusion: { in: INSTALLATION_POLICIES } + validates :installation_account_type, inclusion: { in: OWNER_TYPES }, allow_nil: true validates :owner_type, inclusion: { in: OWNER_TYPES } validates :singleton_key, inclusion: { in: [ 1 ] }, uniqueness: true validate :installation_identity_matches_state + validate :pinned_installation_identity has_object :administration has_object :build_discovery @@ -132,7 +141,18 @@ def credential_snapshot # Binds GitHub results to the owner and installation that requested them. def installation_snapshot credential_snapshot.merge \ - owner_github_id:, owner_type:, installation_id:, installation_account_github_id:, installation_state: + github_app_id:, owner_github_id:, owner_type:, installation_policy:, installation_id:, + installation_account_github_id:, installation_account_type:, installation_state: + end + + # Pinned connections never discover or adopt another account's installation. + def pinned_installation? + installation_policy == "pinned" + end + + # Filters installation metadata before receipts, quotas, or recovery work are recorded. + def accepts_installation?(candidate_id) + !pinned_installation? || (candidate_id.is_a?(Integer) && candidate_id == installation_id) end # Refreshes the installation and its repositories from GitHub. @@ -171,9 +191,11 @@ def github_client # Records persisted setup without retaining the credentials GitHub returned. def audit_setup + context = (Current.security_audit_context || {}).merge(github_app_configuration_id: id) + context[:source] = "bootstrap" if pinned_installation? SecurityAuditEvent.record! \ - action: "github_app_setup.exchange", outcome: "succeeded", - context: (Current.security_audit_context || {}).merge(github_app_configuration_id: id) + action: pinned_installation? ? "github_app_setup.import" : "github_app_setup.exchange", outcome: "succeeded", + context: end # Keeps the installation ID consistent with the installation status. @@ -183,4 +205,20 @@ def installation_identity_matches_state valid_account = installation_id.nil? ? installation_account_github_id.nil? : installation_account_github_id.present? errors.add(:installation_account_github_id, "does not match installation") if !valid_account end + + # The imported connection retains its immutable identity even after removal. + def pinned_installation_identity + return if !pinned_installation? && installation_policy_in_database != "pinned" + + if installation_id.blank? || installation_account_github_id.blank? || + installation_account_login.blank? || !OWNER_TYPES.include?(installation_account_type) + errors.add(:installation_id, "requires a verified installation account") + end + if persisted? && %w[ + installation_policy github_app_id client_id installation_id + installation_account_github_id installation_account_type + ].any? { will_save_change_to_attribute?(_1) } + errors.add(:installation_id, "cannot replace a pinned connection") + end + end end diff --git a/hub/app/models/github_app_configuration/diagnostics.rb b/hub/app/models/github_app_configuration/diagnostics.rb index 099cd861..b66b6886 100644 --- a/hub/app/models/github_app_configuration/diagnostics.rb +++ b/hub/app/models/github_app_configuration/diagnostics.rb @@ -46,7 +46,17 @@ def verify_app!(app) def installation_status(payload) return "not installed" if payload.nil? + return "installation mismatch" if payload.fetch("id") != record.installation_id || + payload.fetch("app_id") != record.github_app_id + account = payload.fetch("account") + if record.pinned_installation? + return "account mismatch" if account.fetch("id") != record.installation_account_github_id || + account.fetch("type") != record.installation_account_type + + return payload["suspended_at"].present? ? "suspended" : "reachable" + end + return "owner mismatch" if account.fetch("id").to_i != record.owner_github_id || account.fetch("id").to_i != record.installation_account_github_id diff --git a/hub/app/models/github_app_configuration/installation.rb b/hub/app/models/github_app_configuration/installation.rb index 1c69532d..5537aaf7 100644 --- a/hub/app/models/github_app_configuration/installation.rb +++ b/hub/app/models/github_app_configuration/installation.rb @@ -1,4 +1,4 @@ -# Records installation status only for this Hub's configured owner. +# Records status for the configured owner or explicitly pinned installation. class GithubAppConfiguration::Installation < ActiveRecord::AssociatedObject class OwnerMismatch < StandardError; end @@ -6,6 +6,16 @@ class OwnerMismatch < StandardError; end def synchronize! credentials = record.credential_snapshot version = record.lock_version + if record.pinned_installation? + payload = begin + remote_installation(record.installation_id) + rescue GithubClient::NotFound + nil + end + return payload ? record_status!(payload, credentials:, expected_version: version) : + record_missing!(credentials:, expected_version: version) + end + payload = if record.installation_id && record.installation_state != "removed" && record.installation_account_github_id == record.owner_github_id begin @@ -25,6 +35,8 @@ def synchronize! # Fetches the current installation from GitHub and saves its status. def reconcile!(installation_id) + raise OwnerMismatch, "Installation does not match the pinned connection" if !record.accepts_installation?(installation_id.to_i) + snapshot = record.credential_snapshot version = record.lock_version GithubAppConfiguration.with_credentials!(**snapshot) { } @@ -36,6 +48,8 @@ def reconcile!(installation_id) # Commits browser setup and its audit only after all GitHub requests finish. def install!(installation_id) + raise OwnerMismatch, "Installation does not match the pinned connection" if !record.accepts_installation?(installation_id.to_i) + credentials = record.credential_snapshot GithubAppConfiguration.with_credentials!(**credentials) { } payload = remote_installation(installation_id) @@ -62,6 +76,7 @@ def install!(installation_id) def record_status!(payload, action: nil, credentials: record.credential_snapshot, expected_version: record.lock_version) verify_app!(payload) + verify_owner!(payload.fetch("account"), installation_id: payload.fetch("id")) if record.pinned_installation? return if stale_removal?(payload, action) state = state_for(payload, action) @@ -69,6 +84,16 @@ def record_status!(payload, action: nil, credentials: record.credential_snapshot state == "active" ? record.repositories.synchronize! : record.repositories.remove_all! end + # Initial import requires a real, active installation, not just an authenticated App. + def verify_import!(payload) + verify_app!(payload) + account = payload.fetch("account") + valid = payload.fetch("id") == record.installation_id && + account["login"].is_a?(String) && account["login"].match?(/\A[A-Za-z0-9][A-Za-z0-9-]*\z/) && + GithubAppConfiguration::OWNER_TYPES.include?(account["type"]) && payload["suspended_at"].nil? + raise GithubClient::InvalidResponse, "GitHub returned an invalid or suspended installation" if !valid + end + private # Fetches the installation without holding a local write transaction. @@ -126,7 +151,7 @@ def save_status!(payload, state:, credentials: record.credential_snapshot, record.installation_account_github_id == account.fetch("id") raise OwnerMismatch, "Removal does not match the saved installation" if !matches else - verify_owner!(account) + verify_owner!(account, installation_id: payload.fetch("id")) end checked_at = Time.current record.assign_attributes \ @@ -135,6 +160,9 @@ def save_status!(payload, state:, credentials: record.credential_snapshot, installation_state: state, installed_at: state == "active" ? record.installed_at || checked_at : record.installed_at, suspended_at: state == "suspended" ? record.suspended_at || checked_at : nil + if record.pinned_installation? + record.installation_account_login = account["login"] || record.installation_account_login + end if record.changed? record.installation_checked_at = checked_at record.save! @@ -157,10 +185,16 @@ def verify_app!(payload) end # Confirms the installation belongs to the configured GitHub owner. - def verify_owner!(account) - matches = account.fetch("id").to_i == record.owner_github_id && - account.fetch("type") == record.owner_type - raise OwnerMismatch, "Installation owner does not match the configured GitHub App owner" if !matches + def verify_owner!(account, installation_id: record.installation_id) + matches = if record.pinned_installation? + installation_id == record.installation_id && + account.fetch("id") == record.installation_account_github_id && + account.fetch("type") == record.installation_account_type + else + account.fetch("id").to_i == record.owner_github_id && + account.fetch("type") == record.owner_type + end + raise OwnerMismatch, "Installation account does not match the configured GitHub App connection" if !matches end # Converts GitHub's installation details into a status Hub stores. diff --git a/hub/app/models/github_app_configuration/ownership.rb b/hub/app/models/github_app_configuration/ownership.rb index 4e0ff9d7..88223ebd 100644 --- a/hub/app/models/github_app_configuration/ownership.rb +++ b/hub/app/models/github_app_configuration/ownership.rb @@ -14,10 +14,10 @@ def refresh! transferred = record.owner_github_id != attributes.fetch(:owner_github_id) || record.owner_type != attributes.fetch(:owner_type) record.assign_attributes(attributes) - record.installation_state = "removed" if transferred && record.installation_id + record.installation_state = "removed" if transferred && record.installation_id && !record.pinned_installation? record.save! if record.changed? if transferred - record.repositories.remove_all! + record.repositories.remove_all! if !record.pinned_installation? SecurityAuditEvent.record! \ action: "github_app.transfer", outcome: "succeeded", actor: nil, context: { source: "command", github_app_configuration_id: record.id } @@ -26,8 +26,6 @@ def refresh! end end - private - # Rejects incomplete identity before it can replace a saved owner. def attributes_for(app) raise KeyError if !app.is_a?(Hash) || !app["id"].is_a?(Integer) || app["id"] != record.github_app_id diff --git a/hub/app/models/github_app_setup.rb b/hub/app/models/github_app_setup.rb index 9687ee65..e377ff81 100644 --- a/hub/app/models/github_app_setup.rb +++ b/hub/app/models/github_app_setup.rb @@ -1,4 +1,4 @@ -# Creates this Hub's GitHub App and saves the credentials GitHub returns. +# Creates or explicitly imports this Hub's verified GitHub App connection. class GithubAppSetup MAXIMUM_AGE = 15.minutes @@ -63,6 +63,74 @@ def exchange!(code:, state:) configuration end + # Imports only an authenticated App and one explicitly confirmed active installation. + def import!(app_id:, client_id:, client_secret:, private_key:, webhook_secret:, installation_id:, confirmed:) + raise InvalidState, "Setup is unavailable in local mode" if LocalDevelopment.enabled? + raise InvalidState, "Setup authorization expired" if !authorized? + + reject_if_configured! + raise InvalidState, "Confirm the GitHub App connection" if confirmed != "1" + + limits = GithubAppConfiguration::Administration + valid = [ app_id, installation_id ].all? do |value| + value.to_s.match?(/\A[0-9]{1,19}\z/) && value.to_i.between?(1, 9_223_372_036_854_775_807) + end + valid &&= \ + client_id.is_a?(String) && client_id.present? && client_id.bytesize <= 255 && + [ client_secret, webhook_secret ].all? do |secret| + secret.is_a?(String) && secret.present? && secret.bytesize <= limits::CLIENT_SECRET_LIMIT + end + valid &&= private_key.is_a?(String) && private_key.present? && private_key.bytesize <= limits::PRIVATE_KEY_LIMIT + raise Error, "Invalid GitHub App credentials" if !valid + + key = OpenSSL::PKey::RSA.new(private_key) + raise Error, "The GitHub App private key must contain a private RSA key" if !key.private? + + candidate = GithubAppConfiguration.new \ + github_app_id: app_id.to_i, client_id:, client_secret:, private_key:, webhook_secret:, + installation_policy: "pinned", installation_id: installation_id.to_i + app = candidate.github_client.apps.get(token: candidate.tokens.app) + owner = candidate.ownership.attributes_for(app) + raise Error, "The GitHub App ID or client ID does not match the supplied credentials" if app["client_id"] != client_id + + candidate.assign_attributes(owner) + payload = candidate.github_client.installations.get(candidate.installation_id, token: candidate.tokens.app) + candidate.installation.verify_import!(payload) + account = payload.fetch("account") + candidate.assign_attributes \ + installation_account_github_id: account.fetch("id"), + installation_account_login: account.fetch("login"), + installation_account_type: account.fetch("type"), + installation_state: "active", installed_at: clock.call, installation_checked_at: clock.call + token = candidate.tokens.for_installation(candidate.installation_id) + repositories = candidate.repositories.snapshot(token:) + configuration = GithubAppConfiguration.transaction do + raise InvalidState, "Setup authorization expired" if !authorized? + + reject_if_configured! + saved = GithubAppConfiguration.install!(candidate.attributes.except("id", "created_at", "updated_at")) + saved.repositories.synchronize!(snapshot: repositories) + saved + end + clear_state! + session.delete(:github_app_setup_authorized) + session.delete(:github_app_setup_organization) + session.delete(:github_app_install_authorized) + configuration + rescue OpenSSL::PKey::RSAError, ArgumentError, TypeError + raise Error, "Invalid GitHub App credentials" + rescue GithubClient::Error => error + message = if error.status.is_a?(Integer) + "GitHub rejected the App connection (HTTP #{error.status}); check the credentials and installation" + else + "GitHub could not verify the App credentials and active installation" + end + + raise Error, message + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique + raise Error, "The GitHub App connection could not be saved; check setup and try again" + end + # Reports whether the browser may finish installing the new App. def installation_authorized? authorized_at = session[:github_app_install_authorized] diff --git a/hub/app/models/github_webhook.rb b/hub/app/models/github_webhook.rb index 573d3c5c..1e76fea1 100644 --- a/hub/app/models/github_webhook.rb +++ b/hub/app/models/github_webhook.rb @@ -49,6 +49,13 @@ def process! else verify_app!(installation) end + return if !configuration.accepts_installation?(installation["id"]) + if configuration.pinned_installation? && event.in?(%w[installation installation_repositories]) + account = installation.fetch("account") + return if account["id"] != installation_snapshot.fetch(:installation_account_github_id) || + account["type"] != installation_snapshot.fetch(:installation_account_type) + end + delivery = find_delivery! if delivery.processed? with_delivery_configuration! do @@ -90,6 +97,9 @@ def process! def with_delivery_configuration!(&block) if event.in?(%w[push workflow_run]) configuration.with_installation!(snapshot: installation_snapshot, &block) + elsif configuration.pinned_installation? + identity = installation_snapshot.except(:installation_state, :owner_github_id, :owner_type) + configuration.with_installation!(snapshot: identity, &block) else GithubAppConfiguration.with_credentials!(**credential_snapshot, &block) end diff --git a/hub/app/models/security_audit_event.rb b/hub/app/models/security_audit_event.rb index 5dbff71f..0f170819 100644 --- a/hub/app/models/security_audit_event.rb +++ b/hub/app/models/security_audit_event.rb @@ -39,7 +39,7 @@ class SecurityAuditEvent < ApplicationRecord session_id canvas_grant_id canvas_pairing_id share_link_id analytics_destination_id github_app_configuration_id target_user_id ].freeze - SOURCES = %w[browser canvas command].freeze + SOURCES = %w[browser canvas command bootstrap].freeze DEVICE_FAMILIES = %w[desktop mobile unknown].freeze BROWSER_FAMILIES = %w[edge firefox chrome safari unknown].freeze diff --git a/hub/app/models/webhook_recovery_checkpoint/scan.rb b/hub/app/models/webhook_recovery_checkpoint/scan.rb index 6c7cdf9a..d2f855db 100644 --- a/hub/app/models/webhook_recovery_checkpoint/scan.rb +++ b/hub/app/models/webhook_recovery_checkpoint/scan.rb @@ -25,7 +25,11 @@ def call(token:) GithubAppConfiguration.with_credentials!(**configuration.credential_snapshot) do return if !record.renew!(token) - page.deliveries.each { WebhookRedelivery.record_remote!(configuration:, metadata: _1) } + page.deliveries.each do |metadata| + next if !configuration.accepts_installation?(metadata["installation_id"]) + + WebhookRedelivery.record_remote!(configuration:, metadata:) + end end pages = record.pages + 1 done = page.cursor.nil? || previously_covered?(page) diff --git a/hub/app/models/webhook_redelivery.rb b/hub/app/models/webhook_redelivery.rb index 76a9f43b..e205dcd7 100644 --- a/hub/app/models/webhook_redelivery.rb +++ b/hub/app/models/webhook_redelivery.rb @@ -65,6 +65,7 @@ def record_throttle!(configuration:, delivery:, installation_id:) # A current App's failed remote attempt requests a new, normally verified delivery. def record_remote!(configuration:, metadata:) return if !GithubWebhook.supported_event?(event: metadata["event"], action: metadata["action"]) + return if !configuration.accepts_installation?(metadata["installation_id"]) return if where(github_delivery_id: metadata["guid"]).where.not(github_app_configuration_id: configuration.id).exists? receipt = WebhookDelivery.find_by(github_delivery_id: metadata["guid"]) @@ -115,6 +116,8 @@ def clear_expired! # Retains original delivery time across GitHub's differently numbered attempts. def record_pending!(configuration:, guid:, event:, installation_id:, delivered_at:, attempt_id: nil, reverified: false) + return if !configuration.accepts_installation?(installation_id) + GithubAppConfiguration.with_credentials!(**configuration.credential_snapshot) do if delivered_at + WINDOW <= Time.current where(github_app_configuration_id: configuration.id, github_delivery_id: guid, state: ACTIVE_STATES) @@ -210,6 +213,8 @@ def stale_installation?(configuration) raise GithubAppConfiguration::Superseded, "GitHub App credentials changed; start again" end + return true if current.pinned_installation? && !current.accepts_installation?(installation_id) + return false if event.in?(%w[installation installation_repositories]) installation_id.nil? || current.installation_state != "active" || diff --git a/hub/app/views/github_app_setup/install.json.props b/hub/app/views/github_app_setup/install.json.props index fd0335fc..2fcd5dda 100644 --- a/hub/app/views/github_app_setup/install.json.props +++ b/hub/app/views/github_app_setup/install.json.props @@ -1,4 +1,4 @@ json.pageTitle t("github_app_setup.install.page_title") json.productName t("application.name") json.heading t("github_app_setup.install.heading") -json.description t("github_app_setup.install.description") +json.description t(@manual_connection ? "github_app_setup.manual.complete" : "github_app_setup.install.description") diff --git a/hub/app/views/github_app_setup/manual.json.props b/hub/app/views/github_app_setup/manual.json.props new file mode 100644 index 00000000..238beacd --- /dev/null +++ b/hub/app/views/github_app_setup/manual.json.props @@ -0,0 +1,23 @@ +json.pageTitle t("github_app_setup.manual.page_title") +json.productName t("application.name") +json.heading t("github_app_setup.manual.heading") +json.description t("github_app_setup.manual.description") +json.error @setup_error +json.submitPath manual_setup_path +json.authenticityToken form_authenticity_token +json.submitLabel t("github_app_setup.manual.submit") +json.confirmLabel t("github_app_setup.manual.confirm") +json.keyLabel t("github_app_setup.manual.key_label") +json.keyHint t("github_app_setup.manual.key_hint") +json.instructions t("github_app_setup.manual.instructions") +json.fields t("github_app_setup.manual.fields") +manifest = JSON.parse(@registration.fetch(:manifest)) +json.settings [ + { label: t("github_app_setup.manual.settings.name"), value: manifest.fetch("name") }, + { label: t("github_app_setup.manual.settings.homepage"), value: manifest.fetch("url") }, + { label: t("github_app_setup.manual.settings.callback"), value: manifest.fetch("callback_urls").first }, + { label: t("github_app_setup.manual.settings.webhook"), value: "#{Rails.configuration.x.hub_origin.delete_suffix("/")}/github/webhooks" } +] +json.permissions manifest.fetch("default_permissions").map { |name, level| + { label: t("github_app_setup.manual.permissions.#{name}"), value: t("github_app_setup.manual.levels.#{level}") } +} diff --git a/hub/app/views/github_app_setup/manual.tsx b/hub/app/views/github_app_setup/manual.tsx new file mode 100644 index 00000000..05c1d8ba --- /dev/null +++ b/hub/app/views/github_app_setup/manual.tsx @@ -0,0 +1,7 @@ +import { usePageData } from "@hooks/use-page-data" +import { GithubAppSetupManualPage } from "@pages/github-app-setup-manual-page" +import type { GithubAppSetupManualPageData } from "@pages/github-app-setup-manual-page/types" + +export default function GithubAppSetupManualView() { + return ()} /> +} diff --git a/hub/app/views/github_app_setup/register.json.props b/hub/app/views/github_app_setup/register.json.props index cae16db2..1cc29b78 100644 --- a/hub/app/views/github_app_setup/register.json.props +++ b/hub/app/views/github_app_setup/register.json.props @@ -6,3 +6,5 @@ json.access t("github_app_setup.register.access") json.submitLabel t("github_app_setup.register.submit") json.submitPath @registration.fetch(:url) json.manifest @registration.fetch(:manifest) +json.manualLabel t("github_app_setup.register.manual") +json.manualPath manual_setup_path diff --git a/hub/app/views/github_apps/_configuration.json.props b/hub/app/views/github_apps/_configuration.json.props index be005b08..80c29205 100644 --- a/hub/app/views/github_apps/_configuration.json.props +++ b/hub/app/views/github_apps/_configuration.json.props @@ -9,9 +9,16 @@ identity = if @configuration owner_id: @configuration.owner_github_id end installation = if @configuration - t \ - "github_app.installation", installation_id: @configuration.installation_id || "-", - state: @configuration.installation_state + if @configuration.pinned_installation? + t \ + "github_app.pinned_installation", installation_id: @configuration.installation_id, + account: @configuration.installation_account_login, + state: @configuration.installation_state + else + t \ + "github_app.installation", installation_id: @configuration.installation_id || "-", + state: @configuration.installation_state + end end json.identity identity json.installation installation diff --git a/hub/app/views/github_apps/show.json.props b/hub/app/views/github_apps/show.json.props index df8d6950..d021589f 100644 --- a/hub/app/views/github_apps/show.json.props +++ b/hub/app/views/github_apps/show.json.props @@ -8,7 +8,14 @@ else [ t("github_app.not_configured") ] end if @configuration&.installation_state.in?(%w[not_installed removed]) - paragraphs << t("github_app.installation_required", owner: @configuration.owner_login) + paragraphs << if @configuration.pinned_installation? + t("github_app.pinned_installation_required") + else + t("github_app.installation_required", owner: @configuration.owner_login) + end +end +if @configuration&.pinned_installation? + paragraphs << t("github_app.pinned_webhook") end json.paragraphs paragraphs json.links \ diff --git a/hub/config/locales/github_app.en.yml b/hub/config/locales/github_app.en.yml index 6208d703..fb27daf9 100644 --- a/hub/config/locales/github_app.en.yml +++ b/hub/config/locales/github_app.en.yml @@ -10,6 +10,9 @@ en: identity: "App %{app_id} - %{owner} (%{owner_type}, identity %{owner_id})" installation: "Installation %{installation_id}: %{state}" installation_required: "Install this App in the %{owner} account and select its repositories. Hub checks GitHub automatically and restores access after installation." + pinned_installation: "Approved installation %{installation_id} in %{account}: %{state}" + pinned_installation_required: 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. + pinned_webhook: This connection uses an explicitly approved installation. Webhook configuration and secret rotation affect every installation of this App. busy: A webhook update is in progress. Wait for it to finish before changing the App. credentials: heading: Replace App credentials diff --git a/hub/config/locales/github_app_setup.en.yml b/hub/config/locales/github_app_setup.en.yml index 33c1a833..71b2a668 100644 --- a/hub/config/locales/github_app_setup.en.yml +++ b/hub/config/locales/github_app_setup.en.yml @@ -3,7 +3,7 @@ en: show: page_title: Set up Replica Hub heading: Connect GitHub. - description: Enter the deployment bootstrap secret to create this Hub's private GitHub App. + description: Enter the deployment bootstrap secret to create or connect this Hub's GitHub App. secret_label: Bootstrap secret organization_label: GitHub organization login (optional) organization_hint: Leave blank to create the App under your personal account. @@ -14,6 +14,64 @@ en: description: Replica Hub uses a private GitHub App to find the prototype branches you want to share, build them, and report their status back to GitHub. access: You choose which repositories it can access. Replica Hub can read their code and workflow results. A repository writer can also ask Hub to open a pull request that installs the Replica workflow. submit: Create the App on GitHub + manual: Create manually or connect an existing App + manual: + page_title: Connect an existing GitHub App + heading: Connect your GitHub App. + description: Configure the App on your GitHub host, install it on the intended account, then enter its credentials here. Hub will not change the App's GitHub settings. + submit: Verify and connect this installation + complete: 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. + confirm: I approve this App and installation for this Hub. I have checked the installation's account and selected repositories, configured the webhook, and understand that App credentials and webhook settings affect every installation of this App. + key_label: Private keys - PEM file + key_hint: In the App's General settings, choose Generate a private key. Upload the downloaded PEM file (at most 16 KiB). Credentials are never shown again; re-enter them after an error. + invalid_key: Upload a PEM private key of at most 16 KiB. + failed: Hub could not connect the App. Check the credentials, App and installation IDs, active installation status, and GitHub connectivity. No configuration was saved. Re-enter the credentials and try again. + settings: + name: GitHub App name (choose a unique name) + homepage: Homepage URL + callback: Callback URL + webhook: Webhook URL + permissions: + administration: Repository permissions - Administration + actions: Repository permissions - Actions + contents: Repository permissions - Contents + pull_requests: Repository permissions - Pull requests + workflows: Repository permissions - Workflows + levels: + read: Read-only + write: Read and write + instructions: + - title: 1. General + body: On your GitHub host, open your account or organization's Settings, Developer settings, GitHub Apps, then New GitHub App. For an existing App, open its settings instead. Use the values below. Leave Setup URL blank, and leave Request user authorization (OAuth) during installation and Redirect on update unchecked. Leave user authorization token expiration enabled. Return here after installing. + - title: 2. Permissions & events + body: Set the repository permissions below. Under Subscribe to events, select Push and Workflow run. GitHub also delivers installation and installation-repository changes. Save the settings. For an existing installation, its account administrator must approve any increased permissions; Hub access cannot grant that authority. + - title: 3. Webhook and visibility + body: Enable Active under Webhook, use the Webhook URL above, generate a long random Webhook secret, and keep SSL verification enabled. Enter the same secret below. GitHub must be able to reach Hub over HTTPS. A private App restricts who can authorize it; cross-account installation and team sign-in may require a public App, subject to GitHub policy. Public visibility does not make repositories public. Do not reuse an App whose webhook serves another integration; its URL and secret are shared across all installations. + - title: 4. Install App + body: Choose Install App, select the intended account, and choose Only select repositories. Select the prototype repositories for this Hub. If approval is required, an authorized administrator must complete it. Copy the numeric installation ID from the resulting installation settings URL (the number after /installations/). Hub connects only this installation and will not automatically select a replacement. + - title: 5. Credentials and confirmation + body: From the App's General settings, copy App ID and Client ID, choose Generate a new client secret, and generate a private key. Enter those values below, together with the webhook secret and installation ID. Hub verifies App and installation identity and reads the installation's repositories before saving. This verifies the private key, not the OAuth client secret, webhook delivery, or end-user sign-in; verify those separately after connection. Setup expires after 15 minutes. + fields: + - name: app_id + label: App ID + hint: Copy App ID from the App's General settings, not its installation ID. + secret: false + - name: client_id + label: Client ID + hint: Copy Client ID from the same App's General settings. + secret: false + - name: client_secret + label: Client secrets - generated secret + hint: Generate a client secret in General settings and copy it here (at most 4 KiB). + secret: true + - name: webhook_secret + label: Webhook secret + hint: Enter the same random secret saved in the App's Webhook settings (at most 4 KiB). + secret: true + - name: installation_id + label: Installation ID + hint: Copy the numeric ID from the intended account's installation settings URL. + secret: false install: page_title: GitHub setup complete heading: GitHub is connected. diff --git a/hub/config/routes.rb b/hub/config/routes.rb index 9588de68..ff91daea 100644 --- a/hub/config/routes.rb +++ b/hub/config/routes.rb @@ -30,6 +30,8 @@ post "auth/github", to: "github_authentications#create", as: :github_authentication post "auth/local", to: "local_authentications#create", as: :local_authentication get "auth/github/callback", to: "github_authentications#callback", as: :github_authentication_callback + get "setup/manual", to: "github_app_setup#manual", as: :manual_setup + post "setup/manual", to: "github_app_setup#import" resource :session, only: :destroy resource :welcome, only: %i[show update], controller: "welcome" resources :notifications, only: %i[index show update] do diff --git a/hub/db/migrate/20261026200000_add_pinned_github_app_installations.rb b/hub/db/migrate/20261026200000_add_pinned_github_app_installations.rb new file mode 100644 index 00000000..409d29c7 --- /dev/null +++ b/hub/db/migrate/20261026200000_add_pinned_github_app_installations.rb @@ -0,0 +1,18 @@ +class AddPinnedGithubAppInstallations < ActiveRecord::Migration[8.1] + def change + add_column :github_app_configurations, :installation_policy, :string, default: "owner", null: false + add_column :github_app_configurations, :installation_account_login, :string + add_column :github_app_configurations, :installation_account_type, :string + add_check_constraint \ + :github_app_configurations, + "installation_policy IN ('owner', 'pinned')", + name: "github_app_configurations_installation_policy" + add_check_constraint \ + :github_app_configurations, + "installation_policy <> 'pinned' OR " \ + "(installation_id IS NOT NULL AND installation_account_github_id IS NOT NULL AND " \ + "installation_account_type IN ('Organization', 'User') AND installation_account_type IS NOT NULL AND " \ + "installation_account_login IS NOT NULL AND length(trim(installation_account_login)) > 0)", + name: "github_app_configurations_pinned_installation" + end +end diff --git a/hub/db/schema.rb b/hub/db/schema.rb index 63689674..7e101cb5 100644 --- a/hub/db/schema.rb +++ b/hub/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_22_160000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_26_200000) do create_table "analytics_deliveries", force: :cascade do |t| t.integer "attempts", default: 0, null: false t.datetime "created_at", null: false @@ -272,6 +272,9 @@ t.string "webhook_request_token" t.datetime "webhook_request_expires_at" t.bigint "installation_account_github_id" + t.string "installation_policy", default: "owner", null: false + t.string "installation_account_login" + t.string "installation_account_type" t.index ["singleton_key"], name: "index_github_app_configurations_on_singleton_key", unique: true t.check_constraint "(installation_id IS NULL AND installation_account_github_id IS NULL) OR (installation_id IS NOT NULL AND installation_account_github_id IS NOT NULL AND installation_account_github_id > 0)", name: "github_app_configurations_installation_account" t.check_constraint "(installation_state = 'not_installed' AND installation_id IS NULL) OR (installation_state <> 'not_installed' AND installation_id IS NOT NULL)", name: "github_app_configurations_installation_identity" @@ -280,6 +283,8 @@ t.check_constraint "credential_revision > 0 AND lock_version >= 0", name: "github_app_configurations_revision" t.check_constraint "github_app_id > 0", name: "github_app_configurations_positive_app_id" t.check_constraint "installation_id IS NULL OR installation_id > 0", name: "github_app_configurations_positive_installation_id" + t.check_constraint "installation_policy <> 'pinned' OR (installation_id IS NOT NULL AND installation_account_github_id IS NOT NULL AND installation_account_type IN ('Organization', 'User') AND installation_account_type IS NOT NULL AND installation_account_login IS NOT NULL AND length(trim(installation_account_login)) > 0)", name: "github_app_configurations_pinned_installation" + t.check_constraint "installation_policy IN ('owner', 'pinned')", name: "github_app_configurations_installation_policy" t.check_constraint "installation_state IN ('not_installed', 'active', 'suspended', 'removed')", name: "github_app_configurations_installation_state" t.check_constraint "owner_github_id > 0", name: "github_app_configurations_positive_owner_id" t.check_constraint "owner_type IN ('Organization', 'User')", name: "github_app_configurations_owner_type" diff --git a/hub/docs/architecture.md b/hub/docs/architecture.md index b46ea545..4fb8c80b 100644 --- a/hub/docs/architecture.md +++ b/hub/docs/architecture.md @@ -2,7 +2,7 @@ ## Purpose -Replica Hub is a self-hosted Rails application where teams review and discuss prototype branches. A GitHub-backed Hub connects to repositories owned by one GitHub organization or person. Development-only local mode reads multiple local Git repositories instead. +Replica Hub is a self-hosted Rails application where teams review and discuss prototype branches. A GitHub-backed Hub connects one GitHub App installation to repositories owned by one GitHub organization or person. Manual setup may connect an installation in a different account from the App's registration owner. Development-only local mode reads multiple local Git repositories instead. ## System map diff --git a/hub/docs/authentication-and-repository-authorization.md b/hub/docs/authentication-and-repository-authorization.md index 5dde56aa..84465d73 100644 --- a/hub/docs/authentication-and-repository-authorization.md +++ b/hub/docs/authentication-and-repository-authorization.md @@ -46,7 +46,7 @@ Anyone on GitHub can read a public repository. Hub uses that permission for revi Making the App public lets people outside the organization sign in through it. If a public repository is connected, any GitHub account could then sign in and read that repository's Hub reviews. -**Keep Hub's App private.** Before changing App or repository visibility, check who the change will allow to read reviews. +**Keep manifest-created Apps private.** A manually connected cross-account App may need public visibility for installation and team authorization. Before connecting it or changing visibility, check who can read each repository's reviews. Hub adds no organization-membership requirement: GitHub's App authorization policy and each user's repository permissions remain authoritative. See [manual connection](github-app-setup.md#manual-creation-and-connection). ## Hub operators diff --git a/hub/docs/deployment.md b/hub/docs/deployment.md index 58034aa0..24d77dc0 100644 --- a/hub/docs/deployment.md +++ b/hub/docs/deployment.md @@ -291,6 +291,8 @@ https://hub.example.com/setup Enter the setup code printed during initialization. Replica Hub then creates a private GitHub App and sends you to GitHub to install it on the prototype repositories Hub may use. See [GitHub App setup](github-app-setup.md) for the requested permissions and credential lifecycle. +If manifest registration is unavailable or you already administer an installed App, choose **Create manually or connect an existing App** instead. Follow [manual creation and connection](github-app-setup.md#manual-creation-and-connection) to configure the GitHub form and approve one installation, even when its account differs from the App's registration owner. + ## Prepare a prototype repository A compatible repository needs: diff --git a/hub/docs/github-app-setup.md b/hub/docs/github-app-setup.md index 396888ea..2beb8e86 100644 --- a/hub/docs/github-app-setup.md +++ b/hub/docs/github-app-setup.md @@ -1,11 +1,11 @@ # GitHub App setup -Each team creates one private GitHub App for its self-hosted Hub. Installing that App on a prototype repository lets Hub discover its branches, follow GitHub Actions, and check each reviewer's access. +Each self-hosted Hub connects one GitHub App and one installation. Create a private organization-owned App through the manifest flow, or manually connect an independently registered App to an explicitly approved installation. Installing the App on prototype repositories lets Hub discover branches, follow GitHub Actions, and check each reviewer's access. ```text team opens /setup on its self-hosted Hub - -> Hub creates a private GitHub App - -> team installs the App on prototype repositories + -> team creates an App through the manifest or configures it manually + -> team installs the App on prototype repositories or approves an existing installation -> Hub verifies the installation -> team members sign in with GitHub ``` @@ -46,7 +46,7 @@ To use a different Action repository, set `REPLICA_BUILD_ACTION` to its `owner/r To develop against GitHub, start Hub with `REPLICA_LOCAL_MODE=0 bin/devcontainer dev`. It uses `http://replica.localhost:19500`, so setup and sign-in work without a public tunnel. -## Setup +## Manifest setup Create the App under your GitHub organization so your team can sign in. A private App under your personal account allows only you to sign in. @@ -76,15 +76,40 @@ An upgraded Hub does not automatically grant these write permissions to an exist Production usually receives GitHub notifications for pushes, workflows, and installation changes. Both environments also poll GitHub so Hub can recover missed changes; local development relies on polling because it has no public tunnel. +## Manual creation and connection + +Use this flow when manifest registration is unavailable or you already administer an App installed in another account. The App registration owner and installation account may differ. You need the deployment bootstrap secret, permission to manage the App's settings and credentials, and an installation approved by someone authorized in the target account. Hub does not require organization-settings access from the person connecting an already approved installation, and it cannot bypass GitHub's installation or authorization policies. + +On an existing deployment, back up its database and retain its `.env`, storage, and encryption secrets before upgrading. The migration preserves configured manifest connections and their ownership-transfer behavior. Manual connection is initial setup only: it does not overwrite an existing Hub App configuration. Do not edit database owner fields or run a separate bootstrap script. + +1. Open `/setup`, enter the bootstrap secret, and choose **Create manually or connect an existing App**. The organization field is used only for manifest creation; manual import verifies the actual owner with GitHub. The manual page provides this deployment's exact URLs, GitHub form labels, permissions, and credential fields. Authorization expires after 15 minutes; keep this page open while configuring GitHub in another tab. +2. On the configured GitHub host, open the intended account's **Settings**, **Developer settings**, **GitHub Apps**, then **New GitHub App**, or open an existing App. Under **General**, choose a unique **GitHub App name**, enter Hub's origin as **Homepage URL**, and enter `/auth/github/callback` as **Callback URL**. Leave **Setup URL** blank and leave **Request user authorization (OAuth) during installation** and **Redirect on update** unchecked. Keep user authorization token expiration enabled. +3. Under **Webhook**, select **Active**, set **Webhook URL** to `/github/webhooks`, and create a long random **Webhook secret** through your approved secret-management tool. Keep SSL verification enabled. Retain the secret for Hub's connection form. GitHub must be able to reach this HTTPS endpoint. +4. Under **Permissions & events**, grant **Administration: Read-only**, **Actions: Read-only**, **Contents: Read and write**, **Pull requests: Read and write**, and **Workflows: Read and write** under repository permissions. Under **Subscribe to events**, select **Push** and **Workflow run**, then save. GitHub also delivers installation lifecycle and installation-repository changes. An existing installation's account administrator must approve increased permissions separately. +5. Check **Where can this GitHub App be installed?** and who may authorize the App. A private personal App does not support team sign-in. Cross-account installation may require public visibility. A public App does not publish private repositories, but it broadens who can authorize it: anyone who can read a connected public repository could read its Hub reviews. Choose repositories with that audience in mind; see [App visibility](authentication-and-repository-authorization.md#public-apps). +6. Choose **Install App** and select the intended account, or use the existing installation. Choose **Only select repositories** and select the prototype repositories for this Hub. If GitHub requires approval, an authorized account administrator must finish that step. Copy the numeric **Installation ID** from the resulting installation settings URL, after `/installations/`. It is not the App ID and is not a credential. Use the actual deployment's ID; no ID is built into Hub. +7. In the App's **General** settings, copy **App ID** and **Client ID**. Under **Client secrets**, choose **Generate a new client secret**. Under **Private keys**, choose **Generate a private key** and download the PEM file. Enter these values, the **Webhook secret**, and **Installation ID** in Hub. The client and webhook secrets are limited to 4 KiB each; the PEM is limited to 16 KiB. Never put secrets in links, Git, chat, or command-line arguments. +8. Confirm the account and repository selection in GitHub, read Hub's approval statement, and choose **Verify and connect this installation**. Hub authenticates the App, verifies its App/client identity and actual owner, verifies that the specified active installation belongs to it, and reads its repositories before saving the connection atomically. If verification fails, nothing is saved and credentials must be re-entered. Hub does not automatically change GitHub settings. +9. Enroll a Hub operator at `/operator/enrollment`, then verify ordinary GitHub sign-in with an intended reviewer. Private-key verification does not validate the OAuth client secret or prove user authorization eligibility. Check that only repositories selected in the installation are discoverable, that read-only users cannot perform write operations, and that a user without repository access is denied. +10. In the App's **Advanced** settings, inspect a recent supported webhook delivery after pushing a test branch. Confirm GitHub can deliver to Hub and Hub discovers the Build. A successful import does not verify the webhook secret or network path. Use `replica:github:diagnose` for App/installation reachability and `replica:github:poll` to synchronize immediately; diagnosis does not prove end-user sign-in or webhook delivery. + +Webhook URL, secret, permissions, and credentials belong to the App, not an installation. Do not reuse an App whose webhook already serves another integration unless you have separately arranged compatible delivery. Hub's normal webhook rotation changes the App-wide webhook configuration and affects every installation; connecting an existing App does not isolate those settings. + +Manual connections pin the installation ID and immutable installation account. Polling never follows the registration owner to a different installation. Events from other installations cannot enroll them or change this connection. Suspension or confirmed removal marks repositories unavailable without deleting Builds or reviews. Reinstallation with a new ID requires a new deliberate connection; there is no automatic replacement or in-place installation-switch operation in this version. Resetting local App configuration to reconnect has the documented session and canvas revocation effects below, so back up and review those effects first. + +For GitHub Enterprise Server, register the App on that same instance and configure `REPLICA_GITHUB_URL=https://HOSTNAME` and `REPLICA_GITHUB_API_URL=https://HOSTNAME/api/v3` before starting Hub. Set `REPLICA_GITHUB_API_VERSION` to a REST API version supported by that instance. GitHub must reach Hub for webhooks, and Hub must reach GitHub for API and OAuth requests. This connection flow does not add enterprise-owned App registrations, multiple GitHub hosts, or multiple installations to one Hub, and does not establish compatibility with every GHES release. + ## App ownership transfers Hub detects an ownership transfer by asking GitHub for the configured App's current owner. This uses the existing App credentials, not organization audit-log access. Hub accepts the response only for the same App ID, updates the owner and App slug, and records the transfer in its security audit. An App transfer can uninstall the App. Hub marks the previous installation and its repositories unavailable without deleting Builds, files, reviews, users, or administrator roles. Existing browser Sessions and canvas grants are retained, but they do not bypass repository checks while the installation is unavailable. Hub does not promise uninterrupted repository access during a transfer. -The new owner installs the same App in their own account and selects its repositories. Hub accepts only an installation in the App owner's account; installing it in another organization is not supported. Hub stores the installation account separately so delayed removal notices for the previous account cannot remove a replacement installation. +For manifest-created connections, the new owner installs the same App in their own account and selects its repositories. These connections retain the requirement that the installation belongs to the App owner's account. Hub stores the installation account separately so delayed removal notices for the previous account cannot remove a replacement installation. + +For manually connected, pinned installations, Hub updates verified App ownership metadata but checks the approved installation independently. It never adopts an installation in the new owner's account. If the pinned installation no longer exists, access remains unavailable until deliberate reconnection. -Installation notifications and the existing five-minute Build checks restore repository access after GitHub confirms the replacement installation. This also recovers a transfer completed before upgrading Hub, or one whose notifications were missed. Run `replica:github:poll` to check immediately. Transient GitHub failures do not count as proof that an installation was removed; failed jobs remain visible in the queue and a later check retries. +For manifest connections, installation notifications and the existing five-minute Build checks restore repository access after GitHub confirms the replacement installation. This also recovers a transfer completed before upgrading Hub, or one whose notifications were missed. Run `replica:github:poll` to check immediately. Transient GitHub failures do not count as proof that an installation was removed; failed jobs remain visible in the queue and a later check retries. Branch and workflow results remain bound to the owner and installation that started the poll or webhook processing. If a transfer replaces that installation before a result is saved, Hub rejects the old work even when the same repository is already available again. diff --git a/hub/docs/security-audit.md b/hub/docs/security-audit.md index fb9125b3..8da6782c 100644 --- a/hub/docs/security-audit.md +++ b/hub/docs/security-audit.md @@ -72,7 +72,7 @@ Open **End all Hub sessions** from the deployment audit or administrator page. T | --- | --- | | Public shares | Creation and deletion from both browser and canvas APIs | | Analytics destinations | Creation, settings or credential changes, pause, resume, and disconnect | -| GitHub App setup | Bootstrap authorization, credential exchange, and installation confirmation | +| GitHub App setup | Bootstrap authorization, credential exchange, manual connection of an approved installation, and installation confirmation | | GitHub App administration | Status/confirmation access, local reset and separate live disconnect failures, credential replacement, webhook update/retry, ending overlap, and rejected/failed operations | | Copilot connection | Page access, connecting or checking the token again, disconnection by an administrator or by removing the role of the person who connected it, Copilot refusing the token Hub tried to use, and rejected or failed attempts | | Learnings | Run requests and accepted, rejected, or retired learnings, with rejected or failed attempts, in the repository's audit | @@ -123,4 +123,4 @@ SecurityAuditEvent.record! \ `repository: nil` means a deployment record, not permission to expose it in repository views. Actions are bounded lowercase operation names. Outcomes are `succeeded`, `denied`, or `failed`. `occurred_at:` defaults to Hub's current time. -Request controllers provide the trusted IP, fixed source (`browser` or `canvas`), and nonsecret device categories through `Current.security_audit_context`. The writer defaults to `command` outside requests and adds authenticated Session or canvas-grant IDs. Optional context identifiers are `session_id`, `canvas_grant_id`, `canvas_pairing_id`, `share_link_id`, `analytics_destination_id`, `github_app_configuration_id`, and `target_user_id`; values must be positive integers. Unknown keys or arbitrary strings are rejected, not silently filtered. Never pass request parameters, exceptions, or external responses to the writer. Add local success records inside the operation's transaction; record denials after authorization fails without changing the product state. +Request controllers provide the trusted IP, fixed source (`browser` or `canvas`), and nonsecret device categories through `Current.security_audit_context`. A successful manual GitHub App import uses `bootstrap` as its source while retaining that request context. The writer defaults to `command` outside requests and adds authenticated Session or canvas-grant IDs. Optional context identifiers are `session_id`, `canvas_grant_id`, `canvas_pairing_id`, `share_link_id`, `analytics_destination_id`, `github_app_configuration_id`, and `target_user_id`; values must be positive integers. Unknown keys or arbitrary strings are rejected, not silently filtered. Never pass request parameters, exceptions, or external responses to the writer. Add local success records inside the operation's transaction; record denials after authorization fails without changing the product state. diff --git a/hub/docs/security-rate-limits.md b/hub/docs/security-rate-limits.md index 4456a73e..e869e2cf 100644 --- a/hub/docs/security-rate-limits.md +++ b/hub/docs/security-rate-limits.md @@ -49,7 +49,7 @@ GitHub retains recoverable deliveries for [three days](https://docs.github.com/e Initial scans and credential re-verification walk the full available history. Ordinary scans follow GitHub's documented date boundary and stop only after a whole page predates the previous completed scan's start. Mixed pages are processed in full. Saving the start, rather than the finish, lets the next scan find deliveries that arrived while earlier pages were being read. -Recovered pushes follow the [Build discovery guard](build-discovery.md#discovery-rules): GitHub must still report that commit at the branch head, with no later local head observation during the lookup. Ordinary live pushes do not make this extra GitHub request. Recovered installation creation must fetch its current GitHub status before changing the installed owner state; an old removed installation cannot replace its active successor. +Recovered pushes follow the [Build discovery guard](build-discovery.md#discovery-rules): GitHub must still report that commit at the branch head, with no later local head observation during the lookup. Ordinary live pushes do not make this extra GitHub request. Recovered installation creation must fetch its current GitHub status before changing the installed owner state; an old removed installation cannot replace its active successor. For manually pinned connections, inbound lifecycle events and recovery metadata must identify the approved installation; another installation of the same App cannot change the connection or create trusted recovery work. Changing credentials for the same App preserves pending recovery, but stops dispatch using its old credential revision. The next scheduler tick clears the old scan cursor, queue claim, and worker leases and starts a current-App-authenticated scan. Each retained delivery waits with `credentials_changed` until that scan observes its exact GUID and matching event and installation. Only then can it use the new credentials. Neither re-verification nor repeated credential changes extend the original earliest-delivery deadline, reset consumed attempts, or shorten backoff. Unobserved deliveries cannot resend and expire visibly at the original three-day cutoff. diff --git a/hub/test/integration/github_app_manual_setup_test.rb b/hub/test/integration/github_app_manual_setup_test.rb new file mode 100644 index 00000000..e58ddbfd --- /dev/null +++ b/hub/test/integration/github_app_manual_setup_test.rb @@ -0,0 +1,142 @@ +require "test_helper" +require "stringio" + +class GithubAppManualSetupTest < ActionDispatch::IntegrationTest + test "manual setup form requires bootstrap authorization" do + with_bootstrap_secret do + get manual_setup_path + + assert_response :not_found + post manual_setup_path, params: manual_parameters + + assert_response :not_found + assert_not GithubAppConfiguration.configured? + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + end + + test "authorized unconfigured bootstrap can view the manual form and instructions" do + with_bootstrap_secret do + post setup_path, params: { bootstrap_secret: github_app_configurations.bootstrap_secret } + + get manual_setup_path, as: :json + + assert_response :success + data = JSON.parse(response.body).fetch("data") + assert_equal manual_setup_path, data.fetch("submitPath") + assert_equal %w[app_id client_id client_secret webhook_secret installation_id], + data.fetch("fields").map { _1.fetch("name") } + assert_predicate data.fetch("keyLabel"), :present? + assert_predicate data.fetch("instructions"), :present? + assert_not_includes response.body, github_app_configurations.bootstrap_secret + end + end + + test "expired bootstrap authorization cannot display manual credentials form" do + with_bootstrap_secret do + post setup_path, params: { bootstrap_secret: github_app_configurations.bootstrap_secret } + + travel GithubAppSetup::MAXIMUM_AGE + 1.second do + get manual_setup_path + assert_response :not_found + end + end + end + + test "a configuration created after bootstrap authorization closes the manual form and submission" do + with_bootstrap_secret do + post setup_path, params: { bootstrap_secret: github_app_configurations.bootstrap_secret } + configuration = github_app_configurations.create + + get manual_setup_path + assert_response :not_found + assert_no_difference("GithubAppConfiguration.count") do + post manual_setup_path, params: manual_parameters + end + assert_response :not_found + assert_equal configuration.id, GithubAppConfiguration.current.id + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + end + + test "multipart manual import completes the selected cross-account installation without another installation callback" do + with_bootstrap_secret do + post setup_path, params: { bootstrap_secret: github_app_configurations.bootstrap_secret } + stub_manual_import + + post manual_setup_path, params: manual_parameters + + assert_redirected_to install_setup_path + configuration = GithubAppConfiguration.current + assert_equal "pinned", configuration.installation_policy + assert_equal 8642, configuration.installation_id + assert_equal 654, configuration.installation_account_github_id + assert_equal github_app_configurations.test_private_key, configuration.private_key + assert_equal [ "customer/selected" ], Repository.active.pluck(:name_with_owner) + follow_redirect! + + assert_response :success + assert_includes response.body, "github_app_setup/install" + assert_no_secrets(response.body) + end + end + + test "failed manual import returns a safe editable form without saving credentials" do + with_bootstrap_secret do + post setup_path, params: { bootstrap_secret: github_app_configurations.bootstrap_secret } + stub_request(:get, "#{GITHUB_API_URL}/app") + .to_return(status: 401, body: JSON.generate(message: "manual-client-secret manual-webhook-secret")) + + post manual_setup_path, params: manual_parameters + + assert_response :unprocessable_entity + assert_includes response.body, "github_app_setup/manual" + assert_not GithubAppConfiguration.configured? + assert_no_secrets(response.body) + assert_no_secrets(flash.to_hash.to_s) + end + end + + private + + def manual_parameters + upload = Rack::Test::UploadedFile.new \ + StringIO.new(github_app_configurations.test_private_key), + "application/x-pem-file", + original_filename: "github-app.pem" + { + app_id: "907", client_id: "Iv1.manual", client_secret: "manual-client-secret", + webhook_secret: "manual-webhook-secret", installation_id: "8642", private_key: upload, confirmed: "1" + } + end + + def stub_manual_import + app = github_app_configurations.manifest_credentials.merge("id" => 907, "client_id" => "Iv1.manual") + stub_request(:get, "#{GITHUB_API_URL}/app") + .to_return(status: 200, body: JSON.generate(app), headers: { "Content-Type" => "application/json" }) + stub_request(:get, "#{GITHUB_API_URL}/app/installations/8642") + .to_return \ + status: 200, + body: JSON.generate( + id: 8642, app_id: 907, account: { id: 654, login: "customer", type: "Organization" }, suspended_at: nil + ), + headers: { "Content-Type" => "application/json" } + stub_github_installation_token(installation_id: 8642) + stub_github_repositories([ repository_payload(9081, "customer/selected") ]) + end + + def assert_no_secrets(text) + [ + "manual-client-secret", "manual-webhook-secret", github_app_configurations.test_private_key, + github_app_configurations.bootstrap_secret + ].each { |secret| assert_not_includes text, secret } + end + + def with_bootstrap_secret + original = ENV["REPLICA_BOOTSTRAP_SECRET"] + ENV["REPLICA_BOOTSTRAP_SECRET"] = github_app_configurations.bootstrap_secret + yield + ensure + ENV["REPLICA_BOOTSTRAP_SECRET"] = original + end +end diff --git a/hub/test/models/github_app_manual_import_test.rb b/hub/test/models/github_app_manual_import_test.rb new file mode 100644 index 00000000..8c7c0e3f --- /dev/null +++ b/hub/test/models/github_app_manual_import_test.rb @@ -0,0 +1,194 @@ +require "test_helper" + +class GithubAppManualImportTest < ActiveSupport::TestCase + test "imports one explicitly approved cross-account installation and its selected repositories" do + setup = authorized_setup + attributes = import_attributes + stub_import(attributes) + stub_github_repositories([ repository_payload(9081, "customer/selected") ]) + unrelated = repositories.create(github_id: 9082, name_with_owner: "customer/unselected") + + configuration = setup.import!(**attributes) + + assert_equal attributes.fetch(:app_id).to_i, configuration.github_app_id + assert_equal attributes.fetch(:installation_id).to_i, configuration.installation_id + assert_equal "pinned", configuration.installation_policy + assert_equal 321, configuration.owner_github_id + assert_equal 654, configuration.installation_account_github_id + assert_equal "customer", configuration.installation_account_login + assert_equal "Organization", configuration.installation_account_type + assert_equal "active", configuration.installation_state + assert_equal [ 9081 ], Repository.active.pluck(:github_id) + assert_equal "removed", unrelated.reload.installation_state + assert_not setup.authorized? + assert_not setup.installation_authorized? + event = SecurityAuditEvent.where(action: "github_app_setup.import").sole + assert_equal "succeeded", event.outcome + assert_equal "bootstrap", event.context.fetch("source") + assert_nil event.user_id + assert_no_secrets(event.attributes.to_s, attributes) + end + + test "rejects missing bootstrap authorization before any GitHub request" do + setup = GithubAppSetup.new(session: {}, bootstrap_secret: github_app_configurations.bootstrap_secret) + + assert_rejected_import(setup) + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + + test "a wrong bootstrap secret cannot authorize manual import" do + setup = GithubAppSetup.new(session: {}, bootstrap_secret: github_app_configurations.bootstrap_secret) + + assert_raises(GithubAppSetup::InvalidState) { setup.authorize!("wrong") } + assert_rejected_import(setup) + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + + test "expired bootstrap authorization cannot import an App" do + setup = authorized_setup + + travel GithubAppSetup::MAXIMUM_AGE + 1.second do + assert_rejected_import(setup) + end + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + + test "an existing configuration cannot be replaced using an earlier setup authorization" do + setup = authorized_setup + configuration = github_app_configurations.create + + assert_raises(GithubAppSetup::InvalidState) { setup.import!(**import_attributes) } + + assert_equal [ configuration.id ], GithubAppConfiguration.pluck(:id) + assert_equal "owner", configuration.reload.installation_policy + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + + test "manual import requires exact explicit confirmation before contacting GitHub" do + setup = authorized_setup + + assert_rejected_import(setup, confirmed: "true") + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + + test "invalid PEM is rejected without persisting or returning supplied secrets" do + setup = authorized_setup + + assert_rejected_import(setup, private_key: "invalid-private-key-material") + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + + test "malformed submitted IDs cannot be coerced into an approved App or installation" do + assert_rejected_import(authorized_setup, installation_id: "8642-other") + + assert_not_requested :get, "#{GITHUB_API_URL}/app" + end + + { + "another App" => { "id" => 908 }, + "another OAuth client" => { "client_id" => "Iv1.other" }, + "a malformed App owner" => { "owner" => { "id" => "321", "login" => "github", "type" => "Organization" } } + }.each do |description, changes| + test "rejects #{description} returned by authenticated App lookup" do + attributes = import_attributes + stub_import(attributes, app: app_payload(attributes).merge(changes)) + + assert_rejected_import(authorized_setup) + assert_not_requested :get, "#{GITHUB_API_URL}/app/installations/#{attributes.fetch(:installation_id)}" + end + end + + { + "another installation ID" => { id: 8643 }, + "another installation App" => { app_id: 908 }, + "a suspended installation" => { suspended_at: "2026-10-26T19:00:00Z" }, + "a malformed installation account" => { account: { id: 654, login: "customer", type: "Enterprise" } } + }.each do |description, changes| + test "rejects #{description} before saving any connection" do + attributes = import_attributes + stub_import(attributes, installation: installation_payload(attributes).merge(changes)) + + assert_rejected_import(authorized_setup) + assert_not_requested :post, "#{GITHUB_API_URL}/app/installations/#{attributes.fetch(:installation_id)}/access_tokens" + end + end + + test "failed App authentication returns a safe error and saves nothing" do + attributes = import_attributes + stub_request(:get, "#{GITHUB_API_URL}/app") + .to_return(status: 401, body: JSON.generate(message: attributes.fetch(:client_secret))) + + assert_rejected_import(authorized_setup) + end + + test "a late repository lookup failure leaves configuration repositories and success audit unchanged" do + attributes = import_attributes + setup = authorized_setup + repository = repositories.create + stub_import(attributes) + stub_request(:get, "#{GITHUB_API_URL}/installation/repositories") + .with(query: { page: "1", per_page: "100" }) + .to_return(status: 500, body: JSON.generate(message: attributes.fetch(:webhook_secret))) + + assert_no_difference("SecurityAuditEvent.where(outcome: 'succeeded').count") do + assert_rejected_import(setup) + end + + assert_equal "active", repository.reload.installation_state + assert setup.authorized? + end + + private + + def authorized_setup + GithubAppSetup.new(session: {}, bootstrap_secret: github_app_configurations.bootstrap_secret).tap do |setup| + setup.authorize!(github_app_configurations.bootstrap_secret) + end + end + + def import_attributes + { + app_id: "907", client_id: "Iv1.manual", client_secret: "manual-client-secret", + private_key: github_app_configurations.test_private_key, + webhook_secret: "manual-webhook-secret", installation_id: "8642", confirmed: "1" + } + end + + def app_payload(attributes) + github_app_configurations.manifest_credentials.merge( + "id" => attributes.fetch(:app_id).to_i, "client_id" => attributes.fetch(:client_id) + ) + end + + def installation_payload(attributes) + { + id: attributes.fetch(:installation_id).to_i, app_id: attributes.fetch(:app_id).to_i, + account: { id: 654, login: "customer", type: "Organization" }, suspended_at: nil + } + end + + def stub_import(attributes, app: app_payload(attributes), installation: installation_payload(attributes)) + stub_request(:get, "#{GITHUB_API_URL}/app") + .with { |request| request.headers["Authorization"].to_s.start_with?("Bearer ") } + .to_return(status: 200, body: JSON.generate(app), headers: { "Content-Type" => "application/json" }) + stub_request(:get, "#{GITHUB_API_URL}/app/installations/#{attributes.fetch(:installation_id)}") + .to_return(status: 200, body: JSON.generate(installation), headers: { "Content-Type" => "application/json" }) + stub_github_installation_token(installation_id: attributes.fetch(:installation_id)) + stub_github_repositories([]) + end + + def assert_rejected_import(setup, **overrides) + attributes = import_attributes.merge(overrides) + error = nil + assert_no_difference("GithubAppConfiguration.count") do + error = assert_raises(GithubAppSetup::Error) { setup.import!(**attributes) } + end + assert_no_secrets(error.message, attributes) + end + + def assert_no_secrets(text, attributes) + attributes.values_at(:client_secret, :private_key, :webhook_secret).each do |secret| + assert_not_includes text, secret + end + end +end diff --git a/hub/test/models/github_app_pinned_installation_test.rb b/hub/test/models/github_app_pinned_installation_test.rb new file mode 100644 index 00000000..efeca675 --- /dev/null +++ b/hub/test/models/github_app_pinned_installation_test.rb @@ -0,0 +1,242 @@ +require "test_helper" + +class GithubAppPinnedInstallationTest < ActiveSupport::TestCase + test "polling refreshes only the pinned installation and its selected repositories" do + configuration = pinned_configuration + stub_github_app(configuration:) + stub_selected_installation + stub_github_installation_token + stub_github_repositories([ repository_payload(2001, "customer/selected") ]) + + configuration.synchronize_repositories! + + assert_equal 456, configuration.reload.installation_id + assert_equal 654, configuration.installation_account_github_id + assert_equal "active", configuration.installation_state + assert_equal [ "customer/selected" ], Repository.active.pluck(:name_with_owner) + assert_not_requested :get, "#{GITHUB_API_URL}/orgs/github/installation" + end + + test "a missing pinned installation denies access without adopting the owner's installation or deleting data" do + configuration = pinned_configuration + repository = repositories.create + build = builds.create_successful(prototype: prototypes.create(repository:)) + stub_github_app(configuration:) + stub_request(:get, "#{GITHUB_API_URL}/app/installations/456").to_return(status: 404, body: "{}") + stub_github_account_installation \ + account: { id: 321, login: "github", type: "Organization" }, installation_id: 789 + + configuration.synchronize_repositories! + + assert_equal 456, configuration.reload.installation_id + assert_equal 654, configuration.installation_account_github_id + assert_equal "removed", configuration.installation_state + assert_not Repository.active.exists?(repository.id) + assert Build.exists?(build.id) + assert Prototype.exists?(build.prototype_id) + assert_equal "removed", repository.reload.installation_state + assert_not_requested :get, "#{GITHUB_API_URL}/orgs/github/installation" + assert_not_requested :post, "#{GITHUB_API_URL}/app/installations/789/access_tokens" + end + + test "a suspended pinned installation denies access and retains its saved data" do + configuration = pinned_configuration + repository = repositories.create + build = builds.create_successful(prototype: prototypes.create(repository:)) + stub_github_app(configuration:) + stub_selected_installation(suspended_at: Time.current.iso8601) + + configuration.synchronize_repositories! + + assert_equal "suspended", configuration.reload.installation_state + assert_equal 456, configuration.installation_id + assert_not Repository.active.exists?(repository.id) + assert Build.exists?(build.id) + assert_not_requested :post, "#{GITHUB_API_URL}/app/installations/456/access_tokens" + end + + test "polling rejects a changed account for the saved pinned installation" do + configuration = pinned_configuration + repository = repositories.create + stub_selected_installation(account: { id: 321, login: "github", type: "Organization" }) + + assert_raises(GithubAppConfiguration::Installation::OwnerMismatch) do + configuration.installation.synchronize! + end + + assert_equal 654, configuration.reload.installation_account_github_id + assert_equal "active", repository.reload.installation_state + end + + test "transferring App ownership preserves the independently pinned connection" do + configuration = pinned_configuration + repository = repositories.create + stub_github_app(configuration:, owner: { id: 987, login: "new-owner", type: "User" }) + + configuration.ownership.refresh! + + assert_equal 987, configuration.reload.owner_github_id + assert_equal "User", configuration.owner_type + assert_equal "pinned", configuration.installation_policy + assert_equal 456, configuration.installation_id + assert_equal 654, configuration.installation_account_github_id + assert_equal "active", configuration.installation_state + assert_equal "active", repository.reload.installation_state + end + + test "a signed removal for the pinned account disables access and finishes its receipt without deleting builds" do + configuration = pinned_configuration + repository = repositories.create + build = builds.create_successful(prototype: prototypes.create(repository:)) + body = JSON.generate( + action: "deleted", installation: { + id: 456, app_id: configuration.github_app_id, + account: { id: 654, login: "customer", type: "Organization" } + } + ) + signature = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", configuration.webhook_secret, body)}" + + GithubWebhook.process! \ + configuration:, body:, signature:, event: "installation", delivery_id: SecureRandom.uuid + + assert_equal "removed", configuration.reload.installation_state + assert_equal 456, configuration.installation_id + assert_equal "removed", repository.reload.installation_state + assert Build.exists?(build.id) + assert WebhookDelivery.sole.processed? + end + + { + "a new installation" => [ "installation", "created", 789 ], + "a removal of another installation" => [ "installation", "deleted", 789 ], + "another installation's repository selection" => [ "installation_repositories", "added", 789 ], + "the pinned ID with another account" => [ "installation", "suspend", 456 ] + }.each do |description, (event, action, installation_id)| + test "signed #{description} cannot create a trusted receipt or spend an admission quota" do + configuration = pinned_configuration + repository = repositories.create + body = JSON.generate( + action:, installation: { + id: installation_id, app_id: configuration.github_app_id, + account: { id: 321, login: "github", type: "Organization" }, suspended_at: nil + } + ) + admitted = false + signature = "sha256=#{OpenSSL::HMAC.hexdigest("SHA256", configuration.webhook_secret, body)}" + + assert_no_difference([ "WebhookDelivery.count", "WebhookRedelivery.count" ]) do + GithubWebhook.process! \ + configuration:, body:, signature:, event:, delivery_id: SecureRandom.uuid do + admitted = true + true + end + end + + assert_not admitted + assert_equal 456, configuration.reload.installation_id + assert_equal 654, configuration.installation_account_github_id + assert_equal "active", configuration.installation_state + assert_equal "active", repository.reload.installation_state + end + end + + test "diagnostics reports a selected cross-account installation as reachable" do + configuration = pinned_configuration + stub_github_app(configuration:) + stub_selected_installation + + result = configuration.diagnostics.call + + assert_equal "reachable", result.app + assert_equal "reachable", result.installation + end + + %w[removed suspended].each do |installation_state| + test "a #{installation_state} pinned installation excludes repository recovery but permits lifecycle recovery" do + configuration = pinned_configuration + entries = %w[push workflow_run installation].each_with_index.map do |event, index| + entry = WebhookRedelivery.record_pending! \ + configuration:, guid: SecureRandom.uuid, event:, installation_id: configuration.installation_id, + delivered_at: Time.current, attempt_id: 8001 + index + entry.update!(due_at: Time.current) + entry + end + configuration.update!(installation_state:) + request = stub_request(:post, "#{GITHUB_API_URL}/app/hook/deliveries/8003/attempts") + .to_return(status: 202, body: "") + + entries.each { _1.redeliver!(configuration:) } + + entries.first(2).each do |entry| + assert_equal "failed", entry.reload.state + assert_equal "installation_changed", entry.failure_code + assert_equal 0, entry.attempts + end + assert_equal "waiting", entries.last.reload.state + assert_requested request, times: 1 + [ 8001, 8002 ].each do |attempt_id| + assert_not_requested :post, "#{GITHUB_API_URL}/app/hook/deliveries/#{attempt_id}/attempts" + end + end + end + + test "remote recovery metadata for another installation cannot create pinned recovery work" do + configuration = pinned_configuration + metadata = { + "id" => 8001, "guid" => SecureRandom.uuid, "event" => "installation", "action" => "created", + "installation_id" => 789, "status_code" => 503, "delivered_at" => Time.current.iso8601 + } + + assert_no_difference([ "WebhookRedelivery.count", "WebhookDelivery.count" ]) do + WebhookRedelivery.record_remote!(configuration:, metadata:) + end + end + + test "diagnostics does not accept another App at the selected installation endpoint" do + configuration = pinned_configuration + stub_github_app(configuration:) + stub_selected_installation(app_id: 999) + + result = configuration.diagnostics.call + + assert_not_equal "reachable", result.installation + end + + test "credential recovery verifies the App owner rather than its selected installation account" do + configuration = pinned_configuration + stub_github_app(configuration:) + confirmation = GithubAppConfiguration::Administration::RECOVERY_PHRASE + + configuration.administration.recover_credentials! \ + confirmation:, client_secret: "replacement-secret", + private_key: github_app_configurations.test_private_key + + assert_equal "replacement-secret", configuration.reload.client_secret + assert_equal 654, configuration.installation_account_github_id + stub_github_app(configuration:, owner: { id: 654, login: "customer", type: "Organization" }) + + assert_raises(GithubAppConfiguration::Rotator::IdentityMismatch) do + configuration.administration.recover_credentials! \ + confirmation:, client_secret: "untrusted-secret", + private_key: github_app_configurations.test_private_key + end + + assert_equal "replacement-secret", configuration.reload.client_secret + end + + private + + def pinned_configuration + github_app_configurations.create_installed \ + installation_policy: "pinned", installation_account_github_id: 654, + installation_account_login: "customer", installation_account_type: "Organization" + end + + def stub_selected_installation(**overrides) + payload = { + id: 456, app_id: 123, account: { id: 654, login: "customer", type: "Organization" }, suspended_at: nil + }.merge(overrides) + stub_request(:get, "#{GITHUB_API_URL}/app/installations/456") + .to_return(status: 200, body: JSON.generate(payload), headers: { "Content-Type" => "application/json" }) + end +end From 297fe4bae892cd28e8237549f1654952b2378698 Mon Sep 17 00:00:00 2001 From: Will Eastler <32074613+weastler@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:46:18 -0700 Subject: [PATCH 02/12] fix(hub): accept managed GitHub App owners Accept underscore-containing managed-user logins in authenticated App ownership metadata while retaining the existing App ID, owner type, organization-login, and App-slug checks. Cover cross-account import and subsequent polling, plus invalid identity boundaries. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e113915-1ab2-4d2e-ac75-4f9b1c974604 --- .../github_app_configuration/ownership.rb | 10 ++++--- .../models/github_app_manual_import_test.rb | 27 ++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/hub/app/models/github_app_configuration/ownership.rb b/hub/app/models/github_app_configuration/ownership.rb index 88223ebd..3363fec9 100644 --- a/hub/app/models/github_app_configuration/ownership.rb +++ b/hub/app/models/github_app_configuration/ownership.rb @@ -39,11 +39,15 @@ def attributes_for(app) owner_type: owner.fetch("type"), app_slug: app.fetch("slug") } + login_pattern = if attributes[:owner_type] == "User" + /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/ + else + /\A[A-Za-z0-9][A-Za-z0-9-]*\z/ + end valid = attributes[:owner_github_id].is_a?(Integer) && attributes[:owner_github_id].positive? && GithubAppConfiguration::OWNER_TYPES.include?(attributes[:owner_type]) && - attributes.values_at(:owner_login, :app_slug).all? do |value| - value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9-]*\z/) - end + attributes[:owner_login].is_a?(String) && attributes[:owner_login].match?(login_pattern) && + attributes[:app_slug].is_a?(String) && attributes[:app_slug].match?(/\A[A-Za-z0-9][A-Za-z0-9-]*\z/) raise KeyError if !valid attributes diff --git a/hub/test/models/github_app_manual_import_test.rb b/hub/test/models/github_app_manual_import_test.rb index 8c7c0e3f..bad1528b 100644 --- a/hub/test/models/github_app_manual_import_test.rb +++ b/hub/test/models/github_app_manual_import_test.rb @@ -1,6 +1,26 @@ require "test_helper" class GithubAppManualImportTest < ActiveSupport::TestCase + test "imports and polls an organization installation for a managed-user App owner" do + attributes = import_attributes + app = app_payload(attributes).merge( + "owner" => { "id" => 321, "login" => "operator_acme", "type" => "User" } + ) + stub_import(attributes, app:) + + configuration = authorized_setup.import!(**attributes) + configuration.synchronize_repositories! + + assert_equal "operator_acme", configuration.reload.owner_login + assert_equal "User", configuration.owner_type + assert_equal 321, configuration.owner_github_id + assert_equal "pinned", configuration.installation_policy + assert_equal 8642, configuration.installation_id + assert_equal 654, configuration.installation_account_github_id + assert_equal "Organization", configuration.installation_account_type + assert_equal "active", configuration.installation_state + end + test "imports one explicitly approved cross-account installation and its selected repositories" do setup = authorized_setup attributes = import_attributes @@ -87,7 +107,12 @@ class GithubAppManualImportTest < ActiveSupport::TestCase { "another App" => { "id" => 908 }, "another OAuth client" => { "client_id" => "Iv1.other" }, - "a malformed App owner" => { "owner" => { "id" => "321", "login" => "github", "type" => "Organization" } } + "a malformed App owner" => { "owner" => { "id" => "321", "login" => "github", "type" => "Organization" } }, + "an unsafe user login" => { "owner" => { "id" => 321, "login" => "operator/enterprise", "type" => "User" } }, + "an underscore in an organization login" => { + "owner" => { "id" => 321, "login" => "invalid_org", "type" => "Organization" } + }, + "an underscore in an App slug" => { "slug" => "invalid_app" } }.each do |description, changes| test "rejects #{description} returned by authenticated App lookup" do attributes = import_attributes From e39697c0afe49857f7d54508a0b8f814e9148373 Mon Sep 17 00:00:00 2001 From: Will Eastler <32074613+weastler@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:43:10 -0700 Subject: [PATCH 03/12] docs(hub): add manual deployment and maintenance guides Generalize VM installation, Azure DNS and identity setup, image updates, source builds, patches, backups, and GitHub App connection instructions. Link the operator walkthroughs from the shared and Hub documentation indexes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e113915-1ab2-4d2e-ac75-4f9b1c974604 --- docs/README.md | 2 +- docs/prototype-environment.md | 2 +- hub/README.md | 4 +- hub/docs/README.md | 1 + hub/docs/azure-vm-deployment.md | 182 +++++++++++++++++++++++++++++ hub/docs/backups-and-restore.md | 118 +++++++++++++++++-- hub/docs/deployment.md | 23 +++- hub/docs/github-app-setup.md | 112 +++++++++++++++--- hub/docs/manual-updates.md | 150 ++++++++++++++++++++++++ hub/docs/manual-vm-installation.md | 153 ++++++++++++++++++++++++ hub/docs/source-image-builds.md | 173 +++++++++++++++++++++++++++ 11 files changed, 887 insertions(+), 33 deletions(-) create mode 100644 hub/docs/azure-vm-deployment.md create mode 100644 hub/docs/manual-updates.md create mode 100644 hub/docs/manual-vm-installation.md create mode 100644 hub/docs/source-image-builds.md diff --git a/docs/README.md b/docs/README.md index bb473ad2..deacc3b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/prototype-environment.md b/docs/prototype-environment.md index 081299e8..3f49032a 100644 --- a/docs/prototype-environment.md +++ b/docs/prototype-environment.md @@ -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:** diff --git a/hub/README.md b/hub/README.md index 309de134..56d60e60 100644 --- a/hub/README.md +++ b/hub/README.md @@ -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 && diff --git a/hub/docs/README.md b/hub/docs/README.md index 96370916..b474556b 100644 --- a/hub/docs/README.md +++ b/hub/docs/README.md @@ -5,6 +5,7 @@ Start with [Deployment](deployment.md) to run Replica Hub or [Development](devel The [shared contributor guides](../../docs/README.md) apply across Replica. This index covers Hub-specific behavior and operations. - **Operate Hub:** [Deployment](deployment.md), [server-side tracing](deployment.md#optional-server-side-tracing), [GitHub App setup](github-app-setup.md), [security](security.md), and [backups](backups-and-restore.md). +- **Manual installation and maintenance:** [Linux VM installation](manual-vm-installation.md), [Azure DNS and HTTPS](azure-vm-deployment.md), [image updates](manual-updates.md), and [source builds and patches](source-image-builds.md). Use [GitHub App setup](github-app-setup.md#manual-creation-and-connection) for manual App registration or connection. - **GitHub and Builds:** [App setup](github-app-setup.md), [access](authentication-and-repository-authorization.md), [discovery](build-discovery.md), [Actions](github-actions-builds.md), and [storage](artifact-storage.md). - **Review:** [Gallery](gallery.md), [previews](preview-serving.md), [collaboration](review-collaboration.md), and [share links](share-links.md). - **Analytics:** [Repository activity events](analytics.md). diff --git a/hub/docs/azure-vm-deployment.md b/hub/docs/azure-vm-deployment.md new file mode 100644 index 00000000..5649d7a6 --- /dev/null +++ b/hub/docs/azure-vm-deployment.md @@ -0,0 +1,182 @@ +# Prepare an Azure VM for Hub + +This guide adds Azure networking, DNS, and automatic HTTPS to the [manual VM installation](manual-vm-installation.md). Hub still uses the normal image and Compose bundle. It runs once on persistent local storage; this is not an Azure Files, Container Apps, or high-availability deployment. + +You need permission to create the VM and public DNS zone, assign a role at that zone, and add delegation at the parent domain's existing DNS provider. Use the Azure portal; Azure CLI and a container registry of your own are not required. + +## Choose your values + +Replace the example domain and every `REPLACE_WITH_...` field. Do not commit the completed files or resource identifiers. + +| Value | Example or source | +| --- | --- | +| Dedicated registered domain | `example.com`, reserved entirely for Hub and previews | +| Hub hostname and Azure child DNS zone | `hub.example.com` | +| Preview wildcard | `*.preview.hub.example.com` | +| VM resource group/name | Your approved resource names | +| VM public IP | Static IPv4 from the VM's Overview | +| DNS subscription ID/resource group | The child DNS zone's Overview, which may differ from the VM's | +| Certificate contact | An actual team-monitored email address | +| Caddy and Azure plugin versions | Reviewed exact releases chosen by your operator | + +This example delegates `hub.example.com` while leaving `example.com` at its existing provider. Delegation does not satisfy [domain isolation](deployment.md#choose-an-isolated-domain) if unrelated applications use the same registered domain. + +## Create the VM and network rules + +In **Azure portal -> Virtual machines -> Create**, choose an approved subscription, region, and supported Ubuntu Server LTS image. Use an x64 or ARM64 VM matching the selected Hub image. A modest VM can establish the installation flow; choose production CPU, memory, and disk from workload measurements. + +Use SSH public-key authentication, a Standard static public IP, and a persistent managed OS disk. Disable **Delete with VM** for persistent disks and enable **System assigned managed identity**. Do not use an ephemeral OS disk or Azure's temporary `/mnt` disk for Docker or Hub data. + +Configure the network security group: + +| Port | Source | Purpose | +| --- | --- | --- | +| TCP 22 | Approved administrator CIDRs, or use approved Bastion access | SSH | +| TCP 80 | Internet | HTTPS redirects | +| TCP 443 | Internet | Hub, previews, callbacks, and webhooks | + +Leave ports 3000, 2019, and Docker's API closed. Permit outbound DNS, time synchronization, package/image downloads, GitHub, Azure identity/management, and certificate authorities according to your network policy. Review charges for compute, disks, IP, DNS, traffic, and backup. + +Verify the VM's SSH host key before your first connection. Azure **Run command -> RunShellScript** can print public host-key fingerprints: + +```sh +for key in /etc/ssh/ssh_host_*_key.pub; do + ssh-keygen -lf "$key" +done +``` + +Compare the key type and fingerprint with your SSH prompt. Continue the [VM installation](manual-vm-installation.md#prepare-the-vm) on that VM. For Ubuntu, use [Docker's official repository instructions](https://docs.docker.com/engine/install/ubuntu/), not a script that replaces an existing runtime. + +## Delegate the child DNS zone + +Create a **public DNS zone**, not a Private DNS zone or DNS Private Resolver, named `hub.example.com`. Copy its four assigned name servers from Overview. Do not reuse another zone's server names. + +At the authoritative provider for `example.com`, add an NS record set named `hub` pointing to those four servers. Some providers need four separate NS entries with the same name. Do not change registrar name servers, the parent apex NS records, or unrelated records. Coordinate conflicting records before replacing anything. + +For Cloudflare, use **DNS -> Records -> Add record**, choose **NS**, enter `hub` as Name, and enter one Azure server per record. These are DNS-only records; the delegated service does not pass through Cloudflare's proxy or WAF. + +Inside the Azure child zone, add: + +| Name | Type | Value | +| --- | --- | --- | +| `@` | A | VM static public IPv4 | +| `*.preview` | A | The same VM IPv4 | + +Keep the zone's generated NS and SOA records. These A records are separate from the parent's NS delegation. Do not add AAAA records without working IPv6 routing and firewall rules. + +From your computer, replace the example names and check: + +```sh +nslookup -type=NS hub.example.com +nslookup -type=SOA hub.example.com +nslookup hub.example.com +nslookup b123.preview.hub.example.com +``` + +Require Azure's assigned NS/SOA and the VM IP for both hostnames. Allow DNS caches to expire. Any inherited CAA policy must allow your chosen certificate authority and wildcard issuance. + +## Grant certificate automation access + +Confirm the VM's system-assigned identity is On. Open the **child DNS zone -> Access control (IAM) -> Add role assignment**. Assign **DNS Zone Contributor** to that VM's managed identity at this **zone only**, not the resource group, subscription, or shared parent. + +This permits certificate TXT-record changes within the zone. Processes that can use the VM identity can exercise those permissions too. Allow time for role propagation. + +## Configure the HTTPS proxy + +Standard Caddy does not include Azure DNS support. Build a separate proxy image with the [Azure DNS module](https://github.com/caddy-dns/azure). Choose reviewed exact Caddy and module versions from their release metadata and check the module's configuration for those versions. + +Create `proxy/` inside the deployment directory. If files already exist, inspect and back them up instead of overwriting them. Create the following files with your editor. + +`proxy/Dockerfile`: + +```dockerfile +FROM caddy:REPLACE_WITH_CADDY_VERSION-builder AS builder +RUN xcaddy build vREPLACE_WITH_CADDY_VERSION \ + --with github.com/caddy-dns/azure@vREPLACE_WITH_AZURE_MODULE_VERSION + +FROM caddy:REPLACE_WITH_CADDY_VERSION +COPY --from=builder /usr/bin/caddy /usr/bin/caddy +``` + +Use the same Caddy version in all three places. For example, substitute a version number without adding a second `v`. These dependencies are separate from the Hub release; retain their pins and review them when updating the proxy. + +`proxy/compose.yml`: + +```yaml +name: replica-proxy +services: + caddy: + build: . + image: replica-caddy-azure:REPLACE_WITH_LOCAL_PROXY_TAG + network_mode: host + restart: unless-stopped + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +volumes: + caddy-data: + caddy-config: +``` + +`proxy/Caddyfile`: + +```caddyfile +{ + email REPLACE_WITH_CERTIFICATE_CONTACT_EMAIL + acme_ca https://acme-v02.api.letsencrypt.org/directory +} + +hub.example.com, *.preview.hub.example.com { + tls { + dns azure { + subscription_id REPLACE_WITH_DNS_SUBSCRIPTION_ID + resource_group_name REPLACE_WITH_DNS_RESOURCE_GROUP + } + } + reverse_proxy 127.0.0.1:3000 +} +``` + +Replace both hostnames and all placeholders before building or starting. Use the DNS zone's subscription ID, not its display name. An unchanged email placeholder can pass configuration parsing but fail certificate registration. + +For a module version supporting this mode, omitting `tenant_id`, `client_id`, and `client_secret` selects managed identity. Do not add a personal Azure secret. Linux host networking lets Caddy reach Hub's loopback port and the VM identity endpoint; do not add `ports:` or expose Hub on `0.0.0.0`. + +Once DNS and identity permissions are ready, run on the VM: + +```sh +( + set -eu + cd "$HOME/replica-hub/proxy" + docker compose build + docker compose run --rm --no-deps caddy \ + caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile + docker compose up --detach + docker compose ps + docker compose logs --tail 100 caddy +) +``` + +Require **Valid configuration**, then check certificate issuance and public HTTPS. Validation alone proves neither Azure access nor certificate issuance. After changing an existing Caddyfile, validate first and run `docker compose restart caddy`; editing the mounted file does not reload the running server. + +Keep the Caddy data/config volumes, which contain certificate and account state. Back them up as sensitive state or plan certificate reissuance. Do not delete them to retry issuance. + +## Verify and operate + +Return to [HTTPS and GitHub setup](manual-vm-installation.md#complete-https-and-github-setup). Verify proxy address handling through the actual public route, not only local health. Leave `REPLICA_AZURE_FRONT_DOOR_ID` unset when Front Door is not in the request path. + +| Symptom | Check before changing anything | +| --- | --- | +| Azure DNS 403 | Correct VM identity, zone-scoped role, zone subscription/resource group, role propagation | +| Certificate challenge failure | Parent delegation, both A records, CAA, DNS egress | +| Invalid certificate contact | Replace the email placeholder, validate, restart Caddy | +| HTTPS timeout | Static IP, TCP 443 rule, proxy state, conflicting AAAA | +| Proxy 502 | Hub loopback health, port, and Linux host networking | +| Preview fails | Wildcard DNS/TLS and then a real Build, not only an invented name | + +Use [backups](backups-and-restore.md) and [manual updates](manual-updates.md) for Hub maintenance; updating Hub does not require changing Caddy or DNS. Azure VM Backup and resource deletion locks are extra safeguards, not substitutes for consistent off-VM application backups. Do not delete the VM's disks, DNS zone, or resource group as troubleshooting. diff --git a/hub/docs/backups-and-restore.md b/hub/docs/backups-and-restore.md index 98f645ee..264d5430 100644 --- a/hub/docs/backups-and-restore.md +++ b/hub/docs/backups-and-restore.md @@ -14,17 +14,121 @@ Separate secret backup +-- deployment secrets ``` -Back up the Hub data at one consistent point so database records do not refer to missing Build files. Store the secret backup separately with the same retention and access controls. +Back up the Hub data at one consistent point so database records do not refer to missing Build files. Encrypt off-VM copies and store the secret backup separately with appropriate retention and access controls. Treat both backups as sensitive: the data archive includes reviews and encrypted credentials. Local Build files live under `REPLICA_ARTIFACT_ROOT`, which defaults to `storage/artifacts`. [Learning stores](learnings.md#operations) live under `REPLICA_LEARNING_ROOT`, which defaults to `storage/learning`. -To restore Hub: +For the default Compose deployment, the whole `/rails/storage` volume contains the SQLite databases and sidecar files, local Builds/screenshots, and learning stores. If you changed the storage roots or use external artifact storage, include those locations in the same consistent backup plan. The commands below cover the default local-storage layout only. -1. Restore every SQLite database. -2. Restore artifact storage and the learning stores. -3. Restore the encryption keys and deployment secrets. -4. Check that Hub can read the App credentials: +## Take a consistent Compose backup + +Schedule downtime and prevent other operators from changing configuration during the backup. Stop every writer to the data, including any separately configured workers or learning commands. The standard bundle runs these within the single Hub container. + +On the VM, set `DEPLOY_DIR` to the existing deployment directory. This block records the running image and volume, saves the image for recovery, copies deployment files, and separately preserves the unchanged `.env`. It then stops Hub, archives the full volume, checks the archives, and restarts the unchanged container: ```sh -bin/devcontainer exec bin/rails replica:github:diagnose +DEPLOY_DIR="$HOME/replica-hub" ``` + +```sh +( + set -eu + umask 077 + cd "$DEPLOY_DIR" + CONTAINER=$(env -u REPLICA_IMAGE docker compose ps --quiet hub) + test -n "$CONTAINER" + OLD_IMAGE=$(docker inspect --format '{{.Image}}' "$CONTAINER") + VOLUME=$(docker inspect --format \ + '{{range .Mounts}}{{if eq .Destination "/rails/storage"}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}{{end}}' \ + "$CONTAINER") + test "$VOLUME" = replica-hub_replica-data + docker volume inspect "$VOLUME" >/dev/null + mkdir -p "$HOME/replica-backups" "$HOME/replica-secret-backups" + BACKUP=$(mktemp -d "$HOME/replica-backups/hub.XXXXXX") + SECRETS=$(mktemp -d "$HOME/replica-secret-backups/hub.XXXXXX") + printf '%s\n' "$OLD_IMAGE" > "$BACKUP/image-id.txt" + printf '%s\n' "$VOLUME" > "$BACKUP/volume.txt" + cp compose.yml replica "$BACKUP/" + cp .env "$SECRETS/hub.env" + chmod 600 "$SECRETS/hub.env" + docker image save --output "$BACKUP/hub-image.tar" "$OLD_IMAGE" + tar -tf "$BACKUP/hub-image.tar" >/dev/null + env -u REPLICA_IMAGE docker compose stop hub + test "$(docker inspect --format '{{.State.Running}}' "$CONTAINER")" = false + docker run --rm --pull never --network none --user 0:0 --entrypoint tar \ + --mount "type=volume,src=$VOLUME,dst=/data,readonly" \ + "$OLD_IMAGE" -czf - -C /data . > "$BACKUP/hub-data.tar.gz" + tar -tzf "$BACKUP/hub-data.tar.gz" >/dev/null + ( + cd "$BACKUP" + sha256sum hub-data.tar.gz hub-image.tar compose.yml replica image-id.txt volume.txt > SHA256SUMS + sha256sum -c SHA256SUMS + ) + ( + cd "$SECRETS" + sha256sum hub.env > SHA256SUMS + sha256sum -c SHA256SUMS + ) + env -u REPLICA_IMAGE docker compose start hub + CODE=$(curl --fail --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --retry 60 --retry-all-errors --retry-delay 2 --retry-max-time 120 \ + --connect-timeout 5 --max-time 5 http://127.0.0.1:3000/up) + test "$CODE" = 200 + printf 'Data and image backup: %s\nSeparate secret backup: %s\n' "$BACKUP" "$SECRETS" +) +``` + +The mount assertion deliberately stops for a customized deployment. Confirm its actual storage plan before adapting that assertion. Adjust the loopback URL if you changed `REPLICA_PORT`. Never substitute a new empty volume for the existing one. + +If any step fails, do not upgrade. Hub may remain stopped. Inspect the error, then use `env -u REPLICA_IMAGE docker compose start hub` from the unchanged deployment to restore service when safe. Do not initialize again. + +Transfer the completed data/image backup to approved encrypted off-VM storage and the secret backup to separately controlled storage. Verify `SHA256SUMS` at both destinations. Checksums detect damaged copies; they do not encrypt or prove a restore works. Retain the old image, source/patch records for custom builds, and version-specific recovery notes. + +Back up reverse-proxy configuration separately. If using the [Azure proxy](azure-vm-deployment.md), preserve its Dockerfile, Compose file, and Caddyfile, and back up certificate/account volumes as sensitive state or plan certificate reissuance. An Azure VM snapshot taken during writes is not automatically a consistent SQLite/artifact backup. + +## Restore to a clean target + +Rehearse restoration before relying on Hub. Use an isolated replacement VM with the same CPU architecture and no production traffic. Do not let a restored copy send jobs or receive webhooks alongside the live Hub; coordinate outbound access and any cutover. + +1. Verify both backup checksums. Recover the matching original `.env`, image, deployment files, full data archive, and any external storage. Do not mix secrets or data from different deployments. +2. Load the saved image with `docker image load --input /path/to/backup/hub-image.tar`. Compare its ID with `image-id.txt`. If using a registry copy instead, pull the recorded immutable digest and verify it. A moving tag is not a recovery reference. +3. Restore `compose.yml` and `replica` into a new deployment directory and the secret backup as `.env`, mode `0600`. Make `REPLICA_IMAGE` select the recovered image ID or immutable digest, with no duplicate setting. Do not run `./replica init`. +4. Confirm Hub is stopped and the intended volume does not already exist. Create a new empty volume using the name expected by the restored Compose file. Extract only into that empty volume, preserving numeric ownership, as shown below. +5. Restore proxy configuration, DNS routing, identity permissions, and certificate state, or arrange certificate reissuance. Keep the original origins unless deliberately coordinating a hostname change with GitHub. +6. Start the recovered Hub only after isolating it from the live deployment. Check local health, decryptable App credentials, sign-in, existing reviews, a stored Build, preview files, and background work before cutover. + +Example extraction on the clean target, after loading and checking the image: + +```sh +BACKUP=/absolute/path/to/REPLACE_WITH_DATA_BACKUP +RESTORE_IMAGE=sha256:REPLACE_WITH_SAVED_IMAGE_ID +RESTORE_VOLUME=replica-hub_replica-data +``` + +```sh +( + set -eu + (cd "$BACKUP" && sha256sum -c SHA256SUMS) + test "$(cat "$BACKUP/image-id.txt")" = "$RESTORE_IMAGE" + docker image inspect "$RESTORE_IMAGE" >/dev/null + if docker volume inspect "$RESTORE_VOLUME" >/dev/null 2>&1; then + printf 'Refusing to restore over an existing volume.\n' >&2 + exit 1 + fi + docker volume create "$RESTORE_VOLUME" + docker run --rm --pull never --network none --user 0:0 --entrypoint tar \ + --mount "type=volume,src=$RESTORE_VOLUME,dst=/data" \ + --mount "type=bind,src=$BACKUP,dst=/backup,readonly" \ + "$RESTORE_IMAGE" -xzpf /backup/hub-data.tar.gz --numeric-owner -C /data +) +``` + +Run App diagnosis in the recovered Compose deployment: + +```sh +env -u REPLICA_IMAGE docker compose exec -T hub bin/rails replica:github:diagnose +``` + +Diagnosis contacts GitHub and checks App/installation reachability; it does not prove end-user OAuth or signed webhook delivery. Authorize network access for those checks deliberately. Contributor devcontainers are not required on the production VM. + +Never extract over live or nonempty storage. If recovery follows an update, preserve the newest data first and account for writes since the selected backup. Restoring an older backup discards those writes unless separately recovered. An image-only downgrade is not generally safe after database migrations or App connection. diff --git a/hub/docs/deployment.md b/hub/docs/deployment.md index 69cdae3c..30cea191 100644 --- a/hub/docs/deployment.md +++ b/hub/docs/deployment.md @@ -28,6 +28,19 @@ Downloads can redirect to additional storage hosts, so these names are not a com If a download is denied, resolve the network approval before retrying. These requirements do not authorize bypassing your organization's network controls. +## Manual operator walkthroughs + +These walkthroughs use placeholders for your own hostnames, resources, releases, and credentials. This guide remains the deployment contract; the walkthroughs provide the ordered tasks. + +| Task | Guide | +| --- | --- | +| Install on a persistent Linux VM | [Manual VM installation](manual-vm-installation.md) | +| Configure Azure resources, DNS delegation, and HTTPS | [Azure VM deployment](azure-vm-deployment.md) | +| Create or connect the GitHub App | [GitHub App setup](github-app-setup.md) | +| Replace an image while retaining data | [Manual updates](manual-updates.md) | +| Build an unreleased commit or reviewed patch | [Source image builds](source-image-builds.md) | +| Back up or recover a deployment | [Backups and restore](backups-and-restore.md) | + ## Optional server-side tracing Hub can send server-side traces to an OpenTelemetry Protocol (OTLP) endpoint. Traces describe Rails requests, Active Job enqueueing and execution, instrumented Active Record operations, and outbound Net::HTTP calls. The published image includes OpenTelemetry instrumentation and its HTTP/protobuf exporter, not vendor-specific APM SDKs. @@ -181,12 +194,12 @@ Set `REPLICA_WELCOME_VIDEO_POSTER_URL` to a direct HTTPS image URL to show a sti Choose an explicit `hub-v*` release from [Hub releases](https://github.com/github/replica/releases). Do not use `releases/latest`: Hub and canvas releases share this repository, and a canvas release may not contain a Hub bundle. -The example selects `hub-v1.0.0`, the stable Hub release published on October 15, 2026. It contains both `replica-deploy.tar.gz` and `replica-deploy.tar.gz.sha256`. The Replica repository and its release assets are public, so these downloads do not require GitHub authentication. +Replace `hub-vREPLACE_WITH_VERSION` with the selected release tag. Confirm it contains both `replica-deploy.tar.gz` and `replica-deploy.tar.gz.sha256`. The Replica repository and its release assets are public, so these downloads do not require GitHub authentication. Run these commands on a Linux server with `curl`, `tar`, and `sha256sum`: ```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 && @@ -202,7 +215,7 @@ The checksum file records the archive's SHA-256 digest, a value calculated from The bundle contains `compose.yml` and the `replica` command. You do not need to clone the source repository. -The `hub-v1.0.0` bundle defaults to the major-version image tag `ghcr.io/github/replica-hub:1`. See [Image versions](#image-versions) for the stable tags available when running the image without the bundle. +The bundle's `compose.yml` selects its default image. See [Image versions](#image-versions) for tag behavior, or [manual VM installation](manual-vm-installation.md#pin-the-image-and-initialize-once) to pin the selected release by digest. ## Create the configuration @@ -321,7 +334,7 @@ Replica Hub can open a pull request that installs the required GitHub Actions wo ## Operate the deployment -Use the wrapper instead of calling Docker Compose directly: +Use the wrapper for routine operations: | Goal | Command | | ------------------------------ | ------------------ | @@ -333,6 +346,8 @@ Use the wrapper instead of calling Docker Compose directly: Updates are intentional. Replica Hub does not install new images automatically, and `./replica update` never moves the deployment to a new major version. +Take a [consistent backup](backups-and-restore.md#take-a-consistent-compose-backup) before updating. An immutable digest keeps selecting the same image, and a locally built tag cannot be updated through a registry pull. Follow [manual updates](manual-updates.md) to select and verify a different release or source-built image; that walkthrough uses explicit Compose options to avoid an unintended pull or rebuild. + ## Contain public sharing Public sharing is off by default on both new and upgraded deployments. The public-sharing upgrade permanently revokes existing links and pending preview handoffs. An enrolled Hub administrator can enable **new** links from `/sharing_settings`, with fresh GitHub proof and explicit confirmation. Disabling later revokes all links again. See [Share links](share-links.md) before upgrading a deployment that uses public URLs; do not promise those URLs will survive the upgrade. diff --git a/hub/docs/github-app-setup.md b/hub/docs/github-app-setup.md index 2beb8e86..4a85000b 100644 --- a/hub/docs/github-app-setup.md +++ b/hub/docs/github-app-setup.md @@ -12,17 +12,17 @@ team opens /setup on its self-hosted Hub ## Configuration -Set these values before opening `/setup`: +For a Compose installation, `./replica init` creates the required settings and prints the bootstrap secret as the **setup code**. Do not generate another configuration for an existing database. For other platforms, supply equivalent environment settings before opening `/setup`: ```text -REPLICA_HUB_ORIGIN=https://replica.example.com +REPLICA_HUB_ORIGIN=https://hub.example.com REPLICA_BOOTSTRAP_SECRET= REPLICA_DATABASE_ENCRYPTION_PRIMARY_KEY= REPLICA_DATABASE_ENCRYPTION_DETERMINISTIC_KEY= REPLICA_DATABASE_ENCRYPTION_KEY_DERIVATION_SALT= ``` -Generate the three database encryption values inside the devcontainer: +For contributor environments that need encryption values, generate them inside the devcontainer: ```sh bin/devcontainer exec bin/rails db:encryption:init @@ -52,14 +52,16 @@ Create the App under your GitHub organization so your team can sign in. A privat With a private organization App, connecting a public repository lets every organization member read its Hub reviews, not just its contributors. Keep the App private. See [App ownership and repository visibility](authentication-and-repository-authorization.md#app-ownership-and-repository-visibility) before choosing repositories or changing visibility. -1. Start Hub with the bootstrap secret. -2. Open `/setup`. -3. Choose the GitHub organization that owns the prototype repositories, or your personal account for a single-person Hub. -4. Create the App and install it on the repositories Hub should use. -5. Hub confirms that the installation belongs to the selected owner before saving it. +1. Start Hub with its original bootstrap secret and verify its public HTTPS address. +2. Open `/setup`, enter the setup code and target organization's exact login, then choose **Continue**. Leave the organization blank only for a personal, single-person Hub. +3. On the next page, choose **Create the App on GitHub**. +4. Review the App registration in GitHub and install it only on approved prototype repositories. +5. Complete the returns to Hub in the same browser. Hub confirms that the installation belongs to the selected owner before saving it. The setup request expires after 15 minutes and can be used once. +If a GitHub administrator must register or approve the App, have that person start a fresh `/setup` flow in their own browser. Provide the Hub origin, target organization, approved repositories, and requested permissions for advance review. Share the bootstrap secret only through an approved secret channel: it also protects operator enrollment. Do not forward an in-progress callback URL, registration URL, or browser cookie, or send `.env` and encryption keys. Manifest registration sends generated credentials directly back to Hub; the administrator should not email them. + After setup, enroll the person administering Hub at `/operator/enrollment` using the bootstrap secret and a fresh GitHub sign-in. App creation does not automatically designate its owner or the first signed-in person as a Hub operator. Existing configured deployments use the same route without resetting their App. See [Security audit](security-audit.md#enroll-an-operator). The App requests: @@ -82,16 +84,90 @@ Use this flow when manifest registration is unavailable or you already administe On an existing deployment, back up its database and retain its `.env`, storage, and encryption secrets before upgrading. The migration preserves configured manifest connections and their ownership-transfer behavior. Manual connection is initial setup only: it does not overwrite an existing Hub App configuration. Do not edit database owner fields or run a separate bootstrap script. -1. Open `/setup`, enter the bootstrap secret, and choose **Create manually or connect an existing App**. The organization field is used only for manifest creation; manual import verifies the actual owner with GitHub. The manual page provides this deployment's exact URLs, GitHub form labels, permissions, and credential fields. Authorization expires after 15 minutes; keep this page open while configuring GitHub in another tab. -2. On the configured GitHub host, open the intended account's **Settings**, **Developer settings**, **GitHub Apps**, then **New GitHub App**, or open an existing App. Under **General**, choose a unique **GitHub App name**, enter Hub's origin as **Homepage URL**, and enter `/auth/github/callback` as **Callback URL**. Leave **Setup URL** blank and leave **Request user authorization (OAuth) during installation** and **Redirect on update** unchecked. Keep user authorization token expiration enabled. -3. Under **Webhook**, select **Active**, set **Webhook URL** to `/github/webhooks`, and create a long random **Webhook secret** through your approved secret-management tool. Keep SSL verification enabled. Retain the secret for Hub's connection form. GitHub must be able to reach this HTTPS endpoint. -4. Under **Permissions & events**, grant **Administration: Read-only**, **Actions: Read-only**, **Contents: Read and write**, **Pull requests: Read and write**, and **Workflows: Read and write** under repository permissions. Under **Subscribe to events**, select **Push** and **Workflow run**, then save. GitHub also delivers installation lifecycle and installation-repository changes. An existing installation's account administrator must approve increased permissions separately. -5. Check **Where can this GitHub App be installed?** and who may authorize the App. A private personal App does not support team sign-in. Cross-account installation may require public visibility. A public App does not publish private repositories, but it broadens who can authorize it: anyone who can read a connected public repository could read its Hub reviews. Choose repositories with that audience in mind; see [App visibility](authentication-and-repository-authorization.md#public-apps). -6. Choose **Install App** and select the intended account, or use the existing installation. Choose **Only select repositories** and select the prototype repositories for this Hub. If GitHub requires approval, an authorized account administrator must finish that step. Copy the numeric **Installation ID** from the resulting installation settings URL, after `/installations/`. It is not the App ID and is not a credential. Use the actual deployment's ID; no ID is built into Hub. -7. In the App's **General** settings, copy **App ID** and **Client ID**. Under **Client secrets**, choose **Generate a new client secret**. Under **Private keys**, choose **Generate a private key** and download the PEM file. Enter these values, the **Webhook secret**, and **Installation ID** in Hub. The client and webhook secrets are limited to 4 KiB each; the PEM is limited to 16 KiB. Never put secrets in links, Git, chat, or command-line arguments. -8. Confirm the account and repository selection in GitHub, read Hub's approval statement, and choose **Verify and connect this installation**. Hub authenticates the App, verifies its App/client identity and actual owner, verifies that the specified active installation belongs to it, and reads its repositories before saving the connection atomically. If verification fails, nothing is saved and credentials must be re-entered. Hub does not automatically change GitHub settings. -9. Enroll a Hub operator at `/operator/enrollment`, then verify ordinary GitHub sign-in with an intended reviewer. Private-key verification does not validate the OAuth client secret or prove user authorization eligibility. Check that only repositories selected in the installation are discoverable, that read-only users cannot perform write operations, and that a user without repository access is denied. -10. In the App's **Advanced** settings, inspect a recent supported webhook delivery after pushing a test branch. Confirm GitHub can deliver to Hub and Hub discovers the Build. A successful import does not verify the webhook secret or network path. Use `replica:github:diagnose` for App/installation reachability and `replica:github:poll` to synchronize immediately; diagnosis does not prove end-user sign-in or webhook delivery. +Use an image that includes the manual flow and the owner-identity behavior your App needs. Instructions in a source branch do not establish that an older published release contains it; use [manual updates](manual-updates.md) or a [reviewed source build](source-image-builds.md) when necessary. + +### Prepare the App in GitHub + +Replace `https://hub.example.com` with your Hub's public origin everywhere. Open the intended account's **Settings -> Developer settings -> GitHub Apps**, then create an App or select the existing one. An organization administrator may need to register or approve it. + +| GitHub field | Value | +| --- | --- | +| GitHub App name | A unique name chosen by your administrator | +| Homepage URL | `https://hub.example.com` | +| Callback URL / OAuth Redirect URI | `https://hub.example.com/auth/github/callback` | +| Setup URL | Blank; `/setup/install` belongs to the manifest flow | +| Webhook URL | `https://hub.example.com/github/webhooks` | +| Webhook Active | Enabled | +| Webhook SSL verification | Enabled | +| Request user authorization during installation | Unchecked | +| Redirect on update, Device Flow, callback wildcard matching | Leave disabled unless separately required and reviewed | +| User authorization token expiration | Enabled | + +Under **Permissions & events**, grant the repository permissions in the [permission table](#manifest-setup). Subscribe to **Push** and **Workflow run**, then save. GitHub also delivers installation lifecycle and installation-repository changes. Existing installation owners must approve increased permissions separately. + +Check **Where can this GitHub App be installed?** and who may authorize it. A private personal App does not support team sign-in. Cross-account installation may require public visibility. A public App does not publish private repositories, but broadens who can authorize it: anyone who can read a connected public repository could read its Hub reviews. See [App visibility](authentication-and-repository-authorization.md#public-apps). + +Choose **Install App**, select the intended account, and use **Only select repositories** for the approved prototype repositories, or inspect the existing installation. Complete any GitHub-required approval. Do not uninstall/reinstall just to locate its ID; that changes the installation identity. + +### Collect the correct credentials + +GitHub layouts differ: client secrets and private keys may be on **Credentials** rather than **General**. If those controls are missing, confirm you administer the App itself, not just its installation. + +| Value | Where it comes from | +| --- | --- | +| Setup code / bootstrap secret | Original VM initialization or the deployment secret manager; used at `/setup`, not as an App credential | +| App ID | Numeric **App ID** in the App's settings | +| Client ID | **Client ID** in those same App settings; not the App ID | +| Client secret | GitHub's **Client secrets -> Generate a new client secret**, or a securely retained existing secret | +| PEM private key | **Private keys -> Generate a private key**; upload the downloaded file | +| Webhook secret | A separate long random secret generated through your approved secret-management tool and set identically in GitHub and Hub | +| Installation ID | Numeric part of the selected installation settings URL after `/installations/`; not the App ID | + +Configure the webhook secret in GitHub before importing. GitHub does not reveal an existing webhook secret for copying. If it is unknown, coordinate an intentional replacement with every affected integration; do not use the setup code as a substitute. + +Keep the client/webhook secrets and PEM in approved secret storage. Client and webhook secrets are limited to 4 KiB each; the PEM is limited to 16 KiB. Never put them in Git, chat, links, screenshots, or command-line arguments. + +### Connect from Hub + +Have the prepared credentials ready before authorizing; setup authorization lasts 15 minutes. + +1. Open `/setup`, enter the original setup code, and choose **Continue**. +2. On the next page, choose the plain-text **Create manually or connect an existing App** link beneath **Create the App on GitHub**. The organization field is for manifest creation, not manual import. `/setup/manual` also works in the same authorized browser session. +3. Enter the App ID, Client ID, client secret, matching webhook secret, and installation ID. Upload the PEM file. +4. Confirm the account and repository selection in GitHub, read Hub's approval statement, and choose **Verify and connect this installation**. + +Hub authenticates the App, checks its App/client identity and actual owner, checks the exact active installation, and reads its repositories before saving atomically. A valid managed-user registration owner can have underscores in its login. The registration owner need not be the organization receiving the installation. Failure saves no configuration and requires credentials to be re-entered. Hub does not change GitHub settings automatically. + +### Verify the connection + +Enroll a Hub operator at `/operator/enrollment`, then verify an intended user's ordinary GitHub sign-in. Private-key verification does **not** validate the OAuth client secret or user authorization eligibility. Check that only selected repositories are discoverable, read-only users cannot perform write operations, and a user without repository access is denied. + +In the App's **Advanced** settings, inspect a supported webhook delivery after an authorized test-branch push. Confirm successful delivery and a real Build with its screenshot and embedded preview. Successful import does not verify webhook-secret agreement or reachability. Keep public sharing disabled unless deliberately approved. + +For a Compose deployment, run diagnosis on the VM: + +```sh +cd "$HOME/replica-hub" && +env -u REPLICA_IMAGE docker compose exec -T hub bin/rails replica:github:diagnose +``` + +Use `replica:github:poll` in place of `replica:github:diagnose` to synchronize immediately. Diagnosis checks App/installation reachability, not end-user sign-in or webhook delivery. + +### Troubleshoot manual connection + +| Symptom | Next check | +| --- | --- | +| No manual link on the setup-code screen | Enter the code and choose Continue first; inspect the next page | +| `/setup/manual` returns 404 | Reauthorize at `/setup` in the same browser; check whether setup is already complete and whether the image includes the route | +| Form returns 422 | Read the message at the top of the long form before retrying | +| Generic credential/installation failure | Check actual IDs, PEM, active installation, permission approval, deployed revision, and GitHub API reachability | +| Upload appears as `(binary)` in browser network tools | Expected multipart file upload; do not convert the PEM | +| Import succeeds but sign-in fails | Check callback URL, client secret, and who GitHub permits to authorize the App | +| Import succeeds but webhooks fail | Check Active, URL, TLS, matching webhook secret, subscriptions, and GitHub delivery result | + +Do not export a HAR or share the raw form payload; it contains the secrets and key. Hub-to-GitHub calls are not visible in the browser's form trace. An HTTP 200 from GitHub's API root proves network/TLS reachability, not App authentication. Do not reset a configured App or replace encryption keys to diagnose these failures. + +### Installation and credential lifecycle Webhook URL, secret, permissions, and credentials belong to the App, not an installation. Do not reuse an App whose webhook already serves another integration unless you have separately arranged compatible delivery. Hub's normal webhook rotation changes the App-wide webhook configuration and affects every installation; connecting an existing App does not isolate those settings. diff --git a/hub/docs/manual-updates.md b/hub/docs/manual-updates.md new file mode 100644 index 00000000..6b099c9e --- /dev/null +++ b/hub/docs/manual-updates.md @@ -0,0 +1,150 @@ +# Update Hub manually + +Use this guide to replace the image in an existing Compose deployment while retaining its App connection, data, and secrets. It covers a selected release or a locally built image, not a new installation. Run commands on the VM as the deployment operator. + +Read the selected version's notes and migration/recovery requirements first. Startup runs database preparation. A healthy new container does not establish that an older image can read its database. + +## Prepare one candidate + +Use a new private change directory for each update. Replace placeholders, retain the chosen values in the same shell, and restore them after reconnecting: + +```sh +DEPLOY_DIR="$HOME/replica-hub" +HUB_HOST=hub.example.com +CHANGE_DIR="$HOME/replica-images/REPLACE_WITH_CHANGE_NAME" +``` + +Check `df -h` and `docker system df` before downloading or building. Do not prune volumes or previous recovery images to make room. + +For a release image, select an explicit `hub-v*` release and pull it before downtime: + +```sh +HUB_RELEASE=hub-vREPLACE_WITH_VERSION +``` + +```sh +( + set -eu + umask 077 + printf '%s\n' "$HUB_RELEASE" | grep -Eq '^hub-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + mkdir -p "$(dirname "$CHANGE_DIR")" + mkdir "$CHANGE_DIR" + IMAGE="ghcr.io/github/replica-hub:${HUB_RELEASE#hub-v}" + docker pull "$IMAGE" + test "$(docker image inspect --format \ + '{{index .Config.Labels "org.opencontainers.image.version"}}' "$IMAGE")" = "${HUB_RELEASE#hub-v}" + PIN=$(docker image inspect --format '{{index .RepoDigests 0}}' "$IMAGE") + printf '%s\n' "$PIN" | grep -Eq '^ghcr\.io/github/replica-hub@sha256:[a-f0-9]{64}$' + printf '%s\n' "$PIN" > "$CHANGE_DIR/candidate-image.txt" + docker image inspect --format '{{.Id}}' "$PIN" > "$CHANGE_DIR/candidate-image-id.txt" + docker image inspect --format '{{.Os}}/{{.Architecture}}' "$PIN" > "$CHANGE_DIR/candidate-platform.txt" + docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ + "$PIN" > "$CHANGE_DIR/candidate-revision.txt" + grep -Eq '^[a-f0-9]{40}$' "$CHANGE_DIR/candidate-revision.txt" +) +``` + +Require a valid version and revision. If the directory exists or a command fails, inspect the partial attempt rather than overwriting it. + +For an unreleased commit or patch, use [Build or patch an image](source-image-builds.md) **instead** of that release block. Set `CHANGE_DIR` to the build's work directory, which contains the same four candidate records. Never assume a branch push publishes an image. + +Run the [isolated smoke check](source-image-builds.md#smoke-check-the-candidate) for either candidate before touching live data. + +## Record the running deployment and back it up + +Confirm the expected Hub is healthy and record its actual volume before replacement: + +```sh +( + set -eu + cd "$DEPLOY_DIR" + env -u REPLICA_IMAGE ./replica status + CONTAINER=$(env -u REPLICA_IMAGE docker compose ps --quiet hub) + test -n "$CONTAINER" + test "$(docker inspect --format '{{.State.Health.Status}}' "$CONTAINER")" = healthy + test ! -e "$CHANGE_DIR/previous-image-id.txt" + test ! -e "$CHANGE_DIR/previous-volume.txt" + docker inspect --format '{{.Image}}' "$CONTAINER" > "$CHANGE_DIR/previous-image-id.txt" + VOLUME=$(docker inspect --format \ + '{{range .Mounts}}{{if eq .Destination "/rails/storage"}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}{{end}}' \ + "$CONTAINER") + test -n "$VOLUME" + printf '%s\n' "$VOLUME" > "$CHANGE_DIR/previous-volume.txt" + printf 'Current storage volume: %s\n' "$VOLUME" +) +``` + +Compare the volume with your deployment record; the default is `replica-hub_replica-data`. A different or missing mount requires investigation, not a substitute empty volume. + +Take a fresh [consistent backup](backups-and-restore.md#take-a-consistent-compose-backup), including the original `.env`, and verify it at the off-VM destinations before continuing. Keep the previous image locally and make it recoverable off-VM. A Docker image ID identifies local content but cannot itself be pulled on a replacement VM. + +If release notes require new deployment files, download and verify that version's bundle into a separate directory. Compare it with the installed files and apply required changes deliberately after backing up. Do not extract a bundle over the live directory or rerun initialization. The image-only steps below assume the existing Compose file remains compatible. + +## Switch the image + +Show the nonsecret candidate reference, then edit the existing configuration: + +```sh +cat "$CHANGE_DIR/candidate-image.txt" && +cd "$DEPLOY_DIR" && +nano .env +``` + +Change only the existing `REPLICA_IMAGE=` line to that exact value. Add one line if absent; never leave duplicates. Preserve every origin, key, bootstrap secret, and other setting. + +The direct Compose command below is intentional: it selects the already checked local image without pulling or building another one. Clearing a stale shell export prevents it from overriding `.env`. + +```sh +( + set -eu + cd "$DEPLOY_DIR" + IMAGE=$(cat "$CHANGE_DIR/candidate-image.txt") + EXPECTED=$(cat "$CHANGE_DIR/candidate-image-id.txt") + test "$(sed -n 's/^REPLICA_IMAGE=//p' .env)" = "$IMAGE" + test "$(docker image inspect --format '{{.Id}}' "$IMAGE")" = "$EXPECTED" + env -u REPLICA_IMAGE docker compose config --quiet + env -u REPLICA_IMAGE docker compose up --detach --no-deps --pull never --no-build hub +) +``` + +Do not run `./replica update` for a local image: it pulls from a registry. For a digest pin it pulls the same content, not a new release. For a moving major/minor tag, the wrapper installs whatever that tag selects; it does not replace backup or release review. + +## Verify the running image and data + +This block is read-only and can be rerun after an interrupted verification without repeating the switch: + +```sh +( + set -eu + cd "$DEPLOY_DIR" + CONTAINER=$(env -u REPLICA_IMAGE docker compose ps --quiet hub) + test -n "$CONTAINER" + test "$(docker inspect --format '{{.Image}}' "$CONTAINER")" = \ + "$(cat "$CHANGE_DIR/candidate-image-id.txt")" + test "$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$CONTAINER")" = \ + "$(cat "$CHANGE_DIR/candidate-revision.txt")" + test "$(docker inspect --format '{{.Os}}/{{.Architecture}}' \ + "$(cat "$CHANGE_DIR/candidate-image-id.txt")")" = "$(cat "$CHANGE_DIR/candidate-platform.txt")" + test "$(docker inspect --format \ + '{{range .Mounts}}{{if eq .Destination "/rails/storage"}}{{.Name}}{{end}}{{end}}' "$CONTAINER")" = \ + "$(cat "$CHANGE_DIR/previous-volume.txt")" + for URL in http://127.0.0.1:3000/up "https://$HUB_HOST/up"; do + CODE=$(curl --fail --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --connect-timeout 5 --max-time 5 --retry 60 --retry-all-errors --retry-delay 2 \ + --retry-max-time 120 "$URL") + test "$CODE" = 200 + printf 'PASS: HTTP 200 from %s\n' "$URL" + done + printf 'PASS: expected image, revision, platform, and retained storage.\n' +) +``` + +If you changed `REPLICA_PORT`, adjust the loopback URL. Do not use `-k` for public HTTPS. A transient startup connection reset is retried; a final failure means stop and inspect logs locally. + +Confirm sign-in, retained App configuration, existing reviews, a stored Build, and an embedded preview. Check any changed behavior, access denials, and background/webhook work before ending maintenance. Do not reconnect or reset a working App just because its image changed. + +## Recover without discarding newer writes + +On failure, preserve logs and the newest data, and stop write traffic before recovery. Never repair startup by generating new encryption keys, rerunning initialization, deleting storage, or using `down -v`. + +An image-only downgrade is safe only when that version's documented schema and data changes permit it. Otherwise recover the matching old image, data, and secrets on a clean target using [Backups and restore](backups-and-restore.md#restore-to-a-clean-target). Account for writes since the backup; restoring old data loses those writes unless separately recovered. diff --git a/hub/docs/manual-vm-installation.md b/hub/docs/manual-vm-installation.md new file mode 100644 index 00000000..557cb54a --- /dev/null +++ b/hub/docs/manual-vm-installation.md @@ -0,0 +1,153 @@ +# Install Hub on a Linux VM + +Use this walkthrough to install one Hub container with Docker Compose on a persistent Linux VM. It covers a new installation, not an upgrade or recovery. For an existing Hub, use [manual updates](manual-updates.md) or [backups and restore](backups-and-restore.md). + +You need administrator access to the VM, control of DNS and HTTPS, and someone authorized to register or administer a GitHub App and approve its repository installation. The VM administrator and GitHub administrator may be different people. + +The [deployment guide](deployment.md) defines supported storage, origins, proxy trust, and optional settings. This walkthrough does not add high availability, autoscaling, enterprise sign-in, or external database support. Run exactly one Hub against persistent local storage. + +## Choose the addresses and release + +Replace the example hostname and every `REPLACE_WITH_...` value before running a block. Keep nonsecret selections in a private installation record; keep credentials in your approved secret manager. + +| Selection | Example or requirement | +| --- | --- | +| Hub hostname | `hub.example.com`, under a registered domain dedicated entirely to Hub and previews | +| Preview wildcard | `*.preview.hub.example.com` | +| VM address and SSH user | Your VM's static public IP and administrator account | +| Deployment directory | `$HOME/replica-hub`; use the same path for later operations | +| Hub release | An explicit `hub-v*` release with a deployment bundle and image | +| Backups | Encrypted off-VM data storage and separately controlled secret storage | + +A dedicated subdomain on a shared registered domain is **not** browser isolation from sibling applications. Follow [Choose an isolated domain](deployment.md#choose-an-isolated-domain). DNS delegation limits DNS permissions, not browser trust. + +Select a release from [Hub releases](https://github.com/github/replica/releases), read its notes, and use documentation from that release when installing it. A feature described on a branch may not exist in an older image. Do not use `releases/latest`, which can select a canvas release, or assume a source commit has a published image. + +## Prepare the VM + +Use a persistent disk, not an ephemeral OS or temporary data disk. Reserve capacity for Hub data, image layers, source builds if needed, and backups. Size the VM for your workload; a successful installation is not a capacity measurement. + +Allow inbound TCP 443 for Hub, previews, OAuth callbacks, and GitHub webhooks. Allow TCP 80 if your proxy uses it for redirects or certificate validation. Limit SSH to approved administrator addresses or a controlled access service. Do not expose port 3000, the proxy administration port, or Docker's API. + +On your computer, connect with your approved SSH client. For example: + +```sh +ssh -i /path/to/REPLACE_WITH_SSH_KEY REPLACE_WITH_USER@REPLACE_WITH_VM_IP +``` + +Verify the server's host-key fingerprint through your cloud console or another trusted channel before accepting a first connection. Stop on a changed or unexpected fingerprint; do not disable host-key checking. + +All following shell blocks run **on the VM**, one at a time. Stop on an error before continuing. Use a fresh supported Linux installation and follow [Docker's installation instructions](https://docs.docker.com/engine/install/) for that distribution to install Engine, the Compose plugin, and Buildx if you will build images. Do not remove an existing runtime or its data to make this guide fit. + +If you grant the deployment user Docker group membership, reconnect to activate it. Docker group membership effectively grants root access. Check: + +```sh +docker version +docker compose version +df -h "$HOME" +docker system df +``` + +The Docker server must be reachable, and its data directory must be on persistent storage. Docker-published ports can bypass host firewall rules; retain the cloud firewall and Hub's loopback binding. + +For Azure resources, delegation, managed identity, and a complete HTTPS proxy example, follow [Azure VM deployment](azure-vm-deployment.md). On another host, arrange an HTTPS proxy that meets the same [DNS and proxy requirements](deployment.md#configure-dns-and-https). + +## Download and verify the bundle + +Set these nonsecret variables in the same VM shell. Restore them if you reconnect: + +```sh +HUB_HOST=hub.example.com +HUB_RELEASE=hub-vREPLACE_WITH_VERSION +DEPLOY_DIR="$HOME/replica-hub" +``` + +This block refuses an existing deployment directory: + +```sh +( + set -eu + umask 077 + printf '%s\n' "$HUB_RELEASE" | grep -Eq '^hub-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + URL="https://github.com/github/replica/releases/download/$HUB_RELEASE" + mkdir "$DEPLOY_DIR" + cd "$DEPLOY_DIR" + curl --fail --location --show-error --output replica-deploy.tar.gz \ + "$URL/replica-deploy.tar.gz" + curl --fail --location --show-error --output replica-deploy.tar.gz.sha256 \ + "$URL/replica-deploy.tar.gz.sha256" + sha256sum -c replica-deploy.tar.gz.sha256 + tar -xzf replica-deploy.tar.gz +) +``` + +Require `replica-deploy.tar.gz: OK`. A failed download or checksum means stop, not extract anyway. Inspect an existing directory before retrying; do not overwrite deployment files. The bundle contains `compose.yml` and `replica`; Git, Ruby, and Node are not required on the VM. + +## Pin the image and initialize once + +Pull the selected release and record its digest, an immutable reference to the image contents: + +```sh +( + set -eu + cd "$DEPLOY_DIR" + IMAGE="ghcr.io/github/replica-hub:${HUB_RELEASE#hub-v}" + docker pull "$IMAGE" + test "$(docker image inspect --format \ + '{{index .Config.Labels "org.opencontainers.image.version"}}' "$IMAGE")" = "${HUB_RELEASE#hub-v}" + PIN=$(docker image inspect --format '{{index .RepoDigests 0}}' "$IMAGE") + printf '%s\n' "$PIN" | grep -Eq '^ghcr\.io/github/replica-hub@sha256:[a-f0-9]{64}$' + test ! -e .env + REPLICA_IMAGE="$PIN" ./replica init "$HUB_HOST" + printf '\nREPLICA_IMAGE=%s\n' "$PIN" >> .env + chmod 600 .env +) +``` + +The public image needs no registry token. If access fails, check the exact package/tag and network policy before adding credentials. For an intentionally restricted registry, use its approved interactive login; never put tokens in this guide or command arguments. + +Save the printed **setup code** in your secret manager. It is `REPLICA_BOOTSTRAP_SECRET`, not the GitHub App client secret or webhook secret. Keep `.env` for the deployment's lifetime and back it up separately. Never rerun initialization or generate new encryption keys to fix an existing installation. + +Do not print `.env` or share full container environment/configuration output. Use `docker compose config --quiet` to check configuration without printing its resolved secrets. + +Start Hub and check loopback health: + +```sh +( + set -eu + cd "$DEPLOY_DIR" + env -u REPLICA_IMAGE ./replica up + env -u REPLICA_IMAGE ./replica status + CODE=$(curl --fail --silent --show-error --output /dev/null --write-out '%{http_code}' \ + --retry 60 --retry-all-errors --retry-delay 2 --retry-max-time 120 \ + --connect-timeout 5 --max-time 5 http://127.0.0.1:3000/up) + test "$CODE" = 200 + printf 'PASS: Hub responds on loopback.\n' +) +``` + +The wrapper waits for container health. Bounded curl retries also cover transient startup connection resets. Health does not prove DNS, TLS, sign-in, or a Build works. + +## Complete HTTPS and GitHub setup + +Point both DNS names to the VM and configure your proxy to forward them to `127.0.0.1:3000`. Preserve the requested hostname and WebSockets. Complete [proxy trust verification](deployment.md#verify-your-deployment) before relying on audit addresses or per-address limits. + +From your computer, check the public address with normal TLS verification: + +```sh +curl --fail --show-error https://hub.example.com/up +curl --silent --show-error --output /dev/null --write-out 'Preview HTTP %{http_code}\n' \ + https://b123.preview.hub.example.com/ +``` + +Replace both hostnames. Require HTTP 200 from Hub. A nonexistent preview may return 404, but DNS and trusted TLS must succeed. Do not use `-k`. + +Follow [GitHub App setup](github-app-setup.md) to create the App through Hub or manually register/connect one. Then enroll a Hub operator separately. Do not use a database bootstrap script or reset an already configured App. + +Before inviting users, verify an intended user's sign-in, repository access and denial, a real Build and embedded preview, and persistence after a planned restart. Keep public sharing disabled unless deliberately approved. Finish a [consistent off-VM backup and restore drill](backups-and-restore.md). + +## Keep the deployment + +Use [deployment operations](deployment.md#operate-the-deployment) for health, logs, and start/stop commands. Use [manual updates](manual-updates.md) to select a new image; `./replica update` cannot advance an immutable digest. Keep the existing `.env`, storage, previous image, and recovery records. + +Never use `docker compose down -v`, delete/prune volumes, or remove persistent disks as routine troubleshooting. diff --git a/hub/docs/source-image-builds.md b/hub/docs/source-image-builds.md new file mode 100644 index 00000000..f8adbf31 --- /dev/null +++ b/hub/docs/source-image-builds.md @@ -0,0 +1,173 @@ +# Build or patch a Hub image + +Build a source image when you need an unreleased fix or an explicitly reviewed local patch. Prefer a published release when it provides the required behavior. A source build does not publish an image or update the running Hub. + +Use a separate source directory outside the deployment directory. Never include the live `.env`, keys, or storage in a build context. You need Docker with Buildx, access to the pinned base images and dependency registries, and space for build layers. Git is needed only for the optional patch step. + +## Select immutable source + +Ask the change owner for the reviewed full Git commit and expected archive checksum, and inspect its changes and check results. Resolve branch names to commits before building; do not use a branch name as the build's permanent identity. Obtain the expected checksum through a trusted handoff, not by accepting whatever a failed download produces. + +Replace these nonsecret values in the same VM shell: + +```sh +SOURCE_COMMIT=REPLACE_WITH_FULL_40_CHARACTER_COMMIT +SOURCE_SHA256=REPLACE_WITH_ARCHIVE_SHA256 +CHANGE_DIR="$HOME/replica-images/REPLACE_WITH_CHANGE_NAME" +``` + +Download and verify the archive into a new directory: + +```sh +( + set -eu + umask 077 + printf '%s\n' "$SOURCE_COMMIT" | grep -Eq '^[a-f0-9]{40}$' + printf '%s\n' "$SOURCE_SHA256" | grep -Eq '^[a-f0-9]{64}$' + mkdir -p "$(dirname "$CHANGE_DIR")" + mkdir "$CHANGE_DIR" + mkdir "$CHANGE_DIR/source" + cd "$CHANGE_DIR" + curl --fail --location --show-error --retry 3 --retry-all-errors \ + --retry-delay 2 --connect-timeout 10 --max-time 120 \ + --output source.tar.gz \ + "https://codeload.github.com/github/replica/tar.gz/$SOURCE_COMMIT" + printf '%s source.tar.gz\n' "$SOURCE_SHA256" > source.sha256 + sha256sum -c source.sha256 + tar -xzf source.tar.gz --strip-components=1 -C source + printf '%s\n' "$SOURCE_COMMIT" > source-commit.txt +) +``` + +Require `source.tar.gz: OK`. Stop on an existing directory, mismatched checksum, or missing expected source. Inspect a partial attempt before retrying; do not delete old work or extract over modified source. + +## Apply a local patch only when needed + +Skip this section when the fix already exists in the selected remote commit. Rebuilding the updated commit is preferable to carrying a second copy of the fix. + +Use a reviewed patch against this exact base, including its regression coverage. Keep the original archive unchanged. Do not edit the running container, alter database identity fields, or layer a single runtime file onto an unrelated image. Dependency, frontend, migration, and generated-file changes need the complete production build. + +Set the path to a patch delivered through your approved channel and its independently supplied checksum: + +```sh +PATCH_FILE=/absolute/path/to/REPLACE_WITH_REVIEWED_PATCH +PATCH_SHA256=REPLACE_WITH_PATCH_SHA256 +``` + +```sh +( + set -eu + command -v git >/dev/null + printf '%s\n' "$PATCH_SHA256" | grep -Eq '^[a-f0-9]{64}$' + test "$(sha256sum "$PATCH_FILE" | cut -d ' ' -f 1)" = "$PATCH_SHA256" + test ! -e "$CHANGE_DIR/applied.patch" + test ! -e "$CHANGE_DIR/patch.sha256" + git -C "$CHANGE_DIR/source" apply --stat "$PATCH_FILE" + git -C "$CHANGE_DIR/source" apply --check "$PATCH_FILE" + cp "$PATCH_FILE" "$CHANGE_DIR/applied.patch" + git -C "$CHANGE_DIR/source" apply "$CHANGE_DIR/applied.patch" + ( + cd "$CHANGE_DIR" + printf '%s applied.patch\n' "$PATCH_SHA256" > patch.sha256 + sha256sum -c patch.sha256 + ) +) +``` + +Inspect the patch's paths and contents before running it. Do not use `--reject`, `--unsafe-paths`, or force options to bypass a mismatch. A failed or partial application needs inspection, not blind reapplication. Validate the changed behavior with the relevant [contributor checks](testing-and-operations.md) before deployment; startup alone is not regression coverage. + +## Build the complete image + +Choose the platform matching the deployment VM. Native builds avoid emulation problems: + +| `uname -m` on the VM | `IMAGE_PLATFORM` | +| --- | --- | +| `x86_64` | `linux/amd64` | +| `aarch64` or `arm64` | `linux/arm64` | + +Choose a unique local tag and version. Include a local-patch suffix when applicable; do not give a patched image the same tag as the unmodified commit. The revision label identifies the base commit, not proof that local source is unchanged. Retain the archive, applied patch/checksum, and image ID together. + +```sh +IMAGE_PLATFORM=linux/amd64 +LOCAL_IMAGE=replica-hub:REPLACE_WITH_UNIQUE_LOCAL_TAG +IMAGE_VERSION=0.0.0-REPLACE_WITH_LOCAL_VERSION +``` + +```sh +( + set -eu + test "$(cat "$CHANGE_DIR/source-commit.txt")" = "$SOURCE_COMMIT" + test -f "$CHANGE_DIR/source/hub/Dockerfile" + test -f "$CHANGE_DIR/source/packages/learning/package-lock.json" + test ! -e "$CHANGE_DIR/candidate-image-id.txt" + if docker image inspect "$LOCAL_IMAGE" >/dev/null 2>&1; then + printf 'Image tag already exists; choose a new tag instead of overwriting it.\n' >&2 + exit 1 + fi + docker buildx build \ + --platform "$IMAGE_PLATFORM" \ + --build-context learning="$CHANGE_DIR/source/packages/learning" \ + --build-arg REPLICA_IMAGE_REVISION="$SOURCE_COMMIT" \ + --build-arg REPLICA_IMAGE_VERSION="$IMAGE_VERSION" \ + --tag "$LOCAL_IMAGE" --load "$CHANGE_DIR/source/hub" + test "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$LOCAL_IMAGE")" = "$IMAGE_PLATFORM" + test "$(docker image inspect --format \ + '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$LOCAL_IMAGE")" = "$SOURCE_COMMIT" + printf '%s\n' "$LOCAL_IMAGE" > "$CHANGE_DIR/candidate-image.txt" + docker image inspect --format '{{.Id}}' "$LOCAL_IMAGE" > "$CHANGE_DIR/candidate-image-id.txt" + printf '%s\n' "$IMAGE_PLATFORM" > "$CHANGE_DIR/candidate-platform.txt" + printf '%s\n' "$SOURCE_COMMIT" > "$CHANGE_DIR/candidate-revision.txt" +) +``` + +The named `learning` build context is required. The production Dockerfile owns runtime and dependency versions; do not override its pins or modify lockfiles to work around connectivity. The image builds frontend assets and installs locked dependencies, so source and release builds both need registry access. + +`bin/devcontainer up --use-npm-proxy URL` configures the development environment only. It is not a production Docker build flag and does not automatically configure the production image. Resolve failed registry access through your approved network/proxy configuration; do not disable TLS or substitute an unapproved registry. + +## Smoke-check the candidate + +For a release candidate, set `CHANGE_DIR` to the records from [manual updates](manual-updates.md#prepare-one-candidate). For a source build, keep the same directory as above. + +This block creates only disposable settings and one owned container, without network, published ports, or live storage mounts. It checks a fresh database, not an upgrade of your retained data or real GitHub credentials: + +```sh +( + set -eu + umask 077 + IMAGE=$(cat "$CHANGE_DIR/candidate-image.txt") + test "$(docker image inspect --format '{{.Id}}' "$IMAGE")" = \ + "$(cat "$CHANGE_DIR/candidate-image-id.txt")" + CHECK_DIR=$(mktemp -d "$CHANGE_DIR/smoke.XXXXXX") + CHECK_ID= + cleanup() { + RESULT=$? + if [ -n "$CHECK_ID" ]; then + docker rm --force "$CHECK_ID" || RESULT=1 + fi + rm -f "$CHECK_DIR/smoke.env" || RESULT=1 + rmdir "$CHECK_DIR" || RESULT=1 + exit "$RESULT" + } + trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + docker run --rm --pull never --network none --entrypoint /bin/sh "$IMAGE" \ + -c 'test ! -e /rails/tmp/local_secret.txt' + docker run --rm --pull never --network none "$IMAGE" \ + bin/replica-deployment-config generate \ + --host smoke.example.com --format dotenv > "$CHECK_DIR/smoke.env" + CHECK_ID=$(docker create --network none --env-file "$CHECK_DIR/smoke.env" "$IMAGE") + docker start "$CHECK_ID" >/dev/null + if ! docker exec "$CHECK_ID" curl --fail --silent --show-error --output /dev/null \ + --retry 60 --retry-all-errors --retry-delay 2 --retry-max-time 120 --max-time 5 \ + http://localhost/up; then + docker logs --tail 100 "$CHECK_ID" + exit 1 + fi + printf 'PASS: isolated production startup.\n' +) +``` + +Require the `PASS` line and successful cleanup. If evaluating manual App setup, also check that the selected version exposes GET and POST `/setup/manual`, for example with `bin/rails routes -g manual` in the disposable container before cleanup. Feature-specific checks supplement startup; they do not prove OAuth or webhook operation. + +After the candidate passes, continue with [recording and backing up the live deployment](manual-updates.md#record-the-running-deployment-and-back-it-up). The image has not yet been deployed. Retain an off-VM copy of a locally built image for recovery; a tag or base commit alone does not preserve its built dependencies and assets. From db74cd15e7c9b98eb56d562aa9e2accf5b637dbb Mon Sep 17 00:00:00 2001 From: Will Eastler <32074613+weastler@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:43:10 -0700 Subject: [PATCH 04/12] fix(hub): clarify manual App credential locations Explain both Credentials and General layouts for client secrets and private keys, keep the setup stories aligned, and cover an active pinned installation alongside its removed state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4e113915-1ab2-4d2e-ac75-4f9b1c974604 --- .../javascript/pages/github-app-page/stories.tsx | 13 +++++++++++++ .../pages/github-app-setup-manual-page/stories.tsx | 10 +++++----- hub/config/locales/github_app_setup.en.yml | 10 +++++----- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/hub/app/javascript/pages/github-app-page/stories.tsx b/hub/app/javascript/pages/github-app-page/stories.tsx index 9e97d5d6..e4a664d8 100644 --- a/hub/app/javascript/pages/github-app-page/stories.tsx +++ b/hub/app/javascript/pages/github-app-page/stories.tsx @@ -41,6 +41,19 @@ export default meta type Story = StoryObj 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: { diff --git a/hub/app/javascript/pages/github-app-setup-manual-page/stories.tsx b/hub/app/javascript/pages/github-app-setup-manual-page/stories.tsx index 48bae86b..ba95f5ec 100644 --- a/hub/app/javascript/pages/github-app-setup-manual-page/stories.tsx +++ b/hub/app/javascript/pages/github-app-setup-manual-page/stories.tsx @@ -19,7 +19,7 @@ const meta = { "I approve this App and installation for this Hub. I have checked the installation's account and selected repositories, configured the webhook, and understand that App credentials and webhook settings affect every installation of this App.", keyLabel: "Private keys - PEM file", keyHint: - "In the App's General settings, choose Generate a private key. Upload the downloaded PEM file (at most 16 KiB). Credentials are never shown again; re-enter them after an error.", + "In the App's settings, open Credentials (or General in older layouts), then Private keys and Generate a private key. Upload the downloaded PEM file (at most 16 KiB). Credentials are never shown again; re-enter them after an error.", instructions: [ { title: "1. General", @@ -39,7 +39,7 @@ const meta = { }, { title: "5. Credentials and confirmation", - body: "From the App's General settings, copy App ID and Client ID, choose Generate a new client secret, and generate a private key. Enter those values below, together with the webhook secret and installation ID. Hub verifies App and installation identity and reads the installation's repositories before saving. This verifies the private key, not the OAuth client secret, webhook delivery, or end-user sign-in; verify those separately after connection. Setup expires after 15 minutes.", + body: "In the App's settings, copy App ID and Client ID. Open Credentials (or General in older layouts) to generate a client secret and private key. Enter those values below, together with the webhook secret and installation ID. Hub verifies App and installation identity and reads the installation's repositories before saving. This verifies the private key, not the OAuth client secret, webhook delivery, or end-user sign-in; verify those separately after connection. Setup expires after 15 minutes.", }, ], settings: [ @@ -59,19 +59,19 @@ const meta = { { name: "app_id", label: "App ID", - hint: "Copy App ID from the App's General settings, not its installation ID.", + hint: "Copy App ID from the App's settings, not its installation ID.", secret: false, }, { name: "client_id", label: "Client ID", - hint: "Copy Client ID from the same App's General settings.", + hint: "Copy Client ID from the same App's settings.", secret: false, }, { name: "client_secret", label: "Client secrets - generated secret", - hint: "Generate a client secret in General settings and copy it here (at most 4 KiB).", + hint: "In Credentials (or General in older layouts), generate a client secret and copy it here (at most 4 KiB).", secret: true, }, { diff --git a/hub/config/locales/github_app_setup.en.yml b/hub/config/locales/github_app_setup.en.yml index 71b2a668..3097ed71 100644 --- a/hub/config/locales/github_app_setup.en.yml +++ b/hub/config/locales/github_app_setup.en.yml @@ -23,7 +23,7 @@ en: complete: 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. confirm: I approve this App and installation for this Hub. I have checked the installation's account and selected repositories, configured the webhook, and understand that App credentials and webhook settings affect every installation of this App. key_label: Private keys - PEM file - key_hint: In the App's General settings, choose Generate a private key. Upload the downloaded PEM file (at most 16 KiB). Credentials are never shown again; re-enter them after an error. + key_hint: In the App's settings, open Credentials (or General in older layouts), then Private keys and Generate a private key. Upload the downloaded PEM file (at most 16 KiB). Credentials are never shown again; re-enter them after an error. invalid_key: Upload a PEM private key of at most 16 KiB. failed: Hub could not connect the App. Check the credentials, App and installation IDs, active installation status, and GitHub connectivity. No configuration was saved. Re-enter the credentials and try again. settings: @@ -50,19 +50,19 @@ en: - title: 4. Install App body: Choose Install App, select the intended account, and choose Only select repositories. Select the prototype repositories for this Hub. If approval is required, an authorized administrator must complete it. Copy the numeric installation ID from the resulting installation settings URL (the number after /installations/). Hub connects only this installation and will not automatically select a replacement. - title: 5. Credentials and confirmation - body: From the App's General settings, copy App ID and Client ID, choose Generate a new client secret, and generate a private key. Enter those values below, together with the webhook secret and installation ID. Hub verifies App and installation identity and reads the installation's repositories before saving. This verifies the private key, not the OAuth client secret, webhook delivery, or end-user sign-in; verify those separately after connection. Setup expires after 15 minutes. + body: In the App's settings, copy App ID and Client ID. Open Credentials (or General in older layouts) to generate a client secret and private key. Enter those values below, together with the webhook secret and installation ID. Hub verifies App and installation identity and reads the installation's repositories before saving. This verifies the private key, not the OAuth client secret, webhook delivery, or end-user sign-in; verify those separately after connection. Setup expires after 15 minutes. fields: - name: app_id label: App ID - hint: Copy App ID from the App's General settings, not its installation ID. + hint: Copy App ID from the App's settings, not its installation ID. secret: false - name: client_id label: Client ID - hint: Copy Client ID from the same App's General settings. + hint: Copy Client ID from the same App's settings. secret: false - name: client_secret label: Client secrets - generated secret - hint: Generate a client secret in General settings and copy it here (at most 4 KiB). + hint: In Credentials (or General in older layouts), generate a client secret and copy it here (at most 4 KiB). secret: true - name: webhook_secret label: Webhook secret From 32d83ecd25616bcbb69be27fcddc40aada8903e1 Mon Sep 17 00:00:00 2001 From: weastler <32074613+weastler@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:39:34 -0700 Subject: [PATCH 05/12] Update hub/docs/security-rate-limits.md Co-authored-by: Nishiki --- hub/docs/security-rate-limits.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/docs/security-rate-limits.md b/hub/docs/security-rate-limits.md index e869e2cf..7876b931 100644 --- a/hub/docs/security-rate-limits.md +++ b/hub/docs/security-rate-limits.md @@ -49,7 +49,7 @@ GitHub retains recoverable deliveries for [three days](https://docs.github.com/e Initial scans and credential re-verification walk the full available history. Ordinary scans follow GitHub's documented date boundary and stop only after a whole page predates the previous completed scan's start. Mixed pages are processed in full. Saving the start, rather than the finish, lets the next scan find deliveries that arrived while earlier pages were being read. -Recovered pushes follow the [Build discovery guard](build-discovery.md#discovery-rules): GitHub must still report that commit at the branch head, with no later local head observation during the lookup. Ordinary live pushes do not make this extra GitHub request. Recovered installation creation must fetch its current GitHub status before changing the installed owner state; an old removed installation cannot replace its active successor. For manually pinned connections, inbound lifecycle events and recovery metadata must identify the approved installation; another installation of the same App cannot change the connection or create trusted recovery work. +Recovered pushes follow the [Build discovery guard](build-discovery.md#discovery-rules): GitHub must still report that commit at the branch head, with no later local head observation during the lookup. Ordinary live pushes do not make this extra GitHub request. Recovered installation creation must fetch its current GitHub status before changing the installed owner state; an old removed installation cannot replace its active successor. Hub only processes webhook events and retries failed deliveries for the installation ID entered during manual setup. Events and delivery records for other installations of the same App are ignored. Changing credentials for the same App preserves pending recovery, but stops dispatch using its old credential revision. The next scheduler tick clears the old scan cursor, queue claim, and worker leases and starts a current-App-authenticated scan. Each retained delivery waits with `credentials_changed` until that scan observes its exact GUID and matching event and installation. Only then can it use the new credentials. Neither re-verification nor repeated credential changes extend the original earliest-delivery deadline, reset consumed attempts, or shorten backoff. Unobserved deliveries cannot resend and expire visibly at the original three-day cutoff. From 556469f2d29d1e1ed2fda069b0bcf2bb1a022194 Mon Sep 17 00:00:00 2001 From: weastler <32074613+weastler@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:09:19 -0700 Subject: [PATCH 06/12] Update autoComplete attribute for TextInput Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- hub/app/javascript/pages/github-app-setup-manual-page/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx b/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx index 0a4e38e5..cd7de458 100644 --- a/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx +++ b/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx @@ -52,7 +52,7 @@ export function GithubAppSetupManualPage({ data }: { data: GithubAppSetupManualP From eb6ebd8fbb3211087b29c6008206d96069d680ae Mon Sep 17 00:00:00 2001 From: weastler <32074613+weastler@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:09:40 -0700 Subject: [PATCH 07/12] Replace span with Text component for key hint Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../javascript/pages/github-app-setup-manual-page/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx b/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx index cd7de458..d4ced8b9 100644 --- a/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx +++ b/hub/app/javascript/pages/github-app-setup-manual-page/index.tsx @@ -68,8 +68,10 @@ export function GithubAppSetupManualPage({ data }: { data: GithubAppSetupManualP required aria-describedby="setup-key-hint" /> - {data.keyHint} + + {data.keyHint} +