Add GitHub Deployments API integration - #293
Conversation
Create GitHub deployments and status updates when deploys are recorded, giving visibility into deploy activity directly in GitHub.
- Add repos/ prefix to deployment status URL path so Octokit resolves the correct GitHub API endpoint - Change deployment description from "Deployed via Shipyrd" to "Tracked by Shipyrd" since Shipyrd is notified of deploys, not performing them - Extract hosted_on_github? method to Application model for reuse
Main branch already has migration 20260331000001, so renumber ours to 20260331000002 to fix db:prepare failure in CI.
- Add github_deployment_id column to deploys in schema.rb so CI's db:prepare creates the column (CI loads schema, not migrations) - Use client.post directly instead of create_deployment_status to avoid Octokit's intermediate GET request on the deployment URL
…ployments # Conflicts: # app/helpers/applications_helper.rb # db/schema.rb
Introduce GithubInstallation as a polymorphic channel owner alongside Webhook and OauthToken. Deploy#dispatch_notifications now fires the single :deploy event for pre-deploy, post-deploy, and failed statuses; each owner filters by status internally (Webhook/Slack fire on post-deploy + failed, GitHub reacts to all three to drive its two-phase deployment lifecycle). Removes the separate GithubDeploymentJob path. Also nests the GitHub repository picker under Applications::GithubController and drops the installation ID from picker warnings since end users can't act on it.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Integrates Shipyrd deploy events with the GitHub Deployments API (via a GitHub App installation token) and adds UI to connect/select a GitHub repository per application.
Changes:
- Added
GithubAppClient+GithubDeploymentwrappers and aGithubInstallationmodel to create/update GitHub deployments on deploy lifecycle events. - Added repository picker UI/routes for GitHub App installations and limited channel behavior for deploy-only notifications.
- Updated deploy notification payload to include
deploy_idand expanded deploy dispatching to additional deploy statuses.
Reviewed changes
Copilot reviewed 29 out of 30 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| test/models/webhook_test.rb | Adds coverage for deploy-status filtering behavior for webhooks. |
| test/models/oauth_token_test.rb | Adds coverage for Slack behavior around deploy-status filtering. |
| test/models/github_installation_test.rb | Adds end-to-end tests for GitHub deployment creation/status updates via installation. |
| test/models/deploy_test.rb | Updates expected notification payload to include deploy_id. |
| test/lib/github_deployment_test.rb | Adds tests for low-level GitHub deployment and status creation calls. |
| test/helpers/channels_helper_test.rb | Adds coverage for GitHub connect URL behavior. |
| test/factories/github_installations.rb | Adds factory for GithubInstallation. |
| lib/github_deployment.rb | Introduces Octokit wrapper for deployments/statuses. |
| lib/github_app_client.rb | Introduces GitHub App JWT + installation token + repo listing helper. |
| db/schema.rb | Adds github_deployment_id on deploys and github_installations table. |
| db/migrate/20260424000001_create_github_installations.rb | Adds + backfills github_installations and migrates existing channels; removes old app column. |
| db/migrate/20260423000001_add_github_installation_id_to_applications.rb | Adds interim applications.github_installation_id to support backfill. |
| db/migrate/20260331000002_add_github_deployment_id_to_deploys.rb | Adds deploys.github_deployment_id. |
| config/routes.rb | Adds per-application GitHub settings resource routes. |
| app/views/applications/github/show.html.erb | Adds repository picker UI and install/empty-state messaging. |
| app/views/applications/_channels.html.erb | Displays connected GitHub repo and adds “Change repository” action for GitHub channel. |
| app/views/applications/_add_channel.html.erb | Prevents adding more than one GitHub channel per application. |
| app/models/webhook.rb | Filters deploy webhook notifications to post-deploy/failed only. |
| app/models/oauth_token.rb | Adds GitHub OAuth scope and filters Slack deploy notifications by status. |
| app/models/notification.rb | Skips notifications when the channel is orphaned (no owner). |
| app/models/github_installation.rb | Implements GitHub deployment creation/status updates for deploy events. |
| app/models/deploy.rb | Dispatches notifications for known deploy hook statuses and includes deploy_id. |
| app/models/channel.rb | Makes owner optional and conditionally exposes GitHub as an available provider. |
| app/models/application.rb | Adds GitHub host detection + repository_full_name + repository matching helper. |
| app/helpers/channels_helper.rb | Routes GitHub connect flow to repository picker. |
| app/controllers/oauth_controller.rb | Adds GitHub App installation flow handling and configurable redirect protocol. |
| app/controllers/applications/github_controller.rb | Adds repository picker controller logic and installation repo aggregation. |
| Gemfile.lock | Adds jwt dependency. |
| Gemfile | Adds jwt dependency. |
| CLAUDE.md | Documents intended GitHub App permission scope constraints. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| def notify(event, details) | ||
| return unless event == :deploy | ||
| return unless application.hosted_on_github? | ||
|
|
||
| token = GithubAppClient.installation_token(installation_id) | ||
| return unless token | ||
|
|
||
| client = GithubDeployment.new(token: token, repo: application.repository_full_name) | ||
|
|
||
| case details[:status] | ||
| when "pre-deploy" | ||
| create_deployment(client, details) | ||
| when "post-deploy" | ||
| update_status(client, details, state: "success", description: "Deploy completed") | ||
| when "failed" | ||
| update_status(client, details, state: "failure", description: "Deploy failed") | ||
| end | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def create_deployment(client, details) | ||
| result = client.create( | ||
| ref: details[:version], | ||
| environment: details[:destination_name].presence || "production", | ||
| description: "Tracked by Shipyrd" | ||
| ) | ||
| Deploy.find(details[:deploy_id]).update_column(:github_deployment_id, result.id) | ||
| client.create_status(deployment_id: result.id, state: "in_progress", description: "Deploy started") | ||
| end |
There was a problem hiding this comment.
details is accessed using symbol keys (e.g., details[:status], details[:deploy_id]). When invoked from Notification (JSON-backed details), Rails typically deserializes JSON hashes with string keys, which would cause the case statement to miss, and could also raise on Deploy.find(details[:deploy_id]) (nil id). Consider normalizing once (e.g., details = details.to_h.with_indifferent_access) at the start of notify (and passing that through), or consistently reading details["status"] / details["deploy_id"] (and same for version / destination_name).
| @@ -16,6 +16,8 @@ def create_channel | |||
| end | |||
|
|
|||
| def notify(event, details) | |||
There was a problem hiding this comment.
This filter only checks details[:status]. If details originates from a persisted Notification payload, it is likely to have string keys (e.g., details["status"]), which would incorrectly skip deploy webhooks even for post-deploy/failed. Normalize to indifferent access (or check both key types) before applying this condition.
| def notify(event, details) | |
| def notify(event, details) | |
| details = details.with_indifferent_access |
| def notify(event, details) | ||
| if provider == "slack" | ||
| return if event == :deploy && !%w[post-deploy failed].include?(details[:status].to_s) | ||
|
|
||
| Slack.new(token).notify( | ||
| event, | ||
| details.merge( |
There was a problem hiding this comment.
Same key-type issue as above: details[:status] will not work if details is a JSON-deserialized hash with string keys. This can cause Slack deploy notifications to be skipped unexpectedly. Normalize details (indifferent access) before filtering/merging.
| def update | ||
| value = params.dig(:application, :repository_url) | ||
| installation_id, html_url = value.to_s.split("|", 2) | ||
|
|
||
| if installation_id.blank? || html_url.blank? | ||
| redirect_to application_github_url(@application), alert: "Choose a repository." | ||
| return | ||
| end | ||
|
|
||
| @application.update!(repository_url: html_url) | ||
| github_installation = @application.github_installation || @application.build_github_installation | ||
| github_installation.update!(installation_id: installation_id) | ||
|
|
||
| redirect_to edit_application_url(@application), notice: "Repository connected." | ||
| end |
There was a problem hiding this comment.
html_url is taken directly from a form value and persisted as repository_url without server-side verification. A crafted request could store an arbitrary URL (potentially impacting links/UI and any logic that derives repo identity from this field). Prefer validating the selection against repositories fetched from GitHub for installation_id (e.g., confirm html_url is one of the returned html_urls for that installation) and/or strictly validate that the URL host is github.com and path matches an expected owner/repo format before persisting.
| @@ -0,0 +1,5 @@ | |||
| class AddGithubDeploymentIdToDeploys < ActiveRecord::Migration[8.0] | |||
There was a problem hiding this comment.
This migration is declared as ActiveRecord::Migration[8.0] while other migrations/schema in this PR use Rails 8.1. Aligning the migration version with the app’s current migration version (8.1) avoids subtle behavior differences across Rails versions and keeps migrations consistent.
| class AddGithubDeploymentIdToDeploys < ActiveRecord::Migration[8.0] | |
| class AddGithubDeploymentIdToDeploys < ActiveRecord::Migration[8.1] |
| return true if channel.owner.nil? | ||
|
|
There was a problem hiding this comment.
Returning true when channel.owner is nil silently drops the notification path, which can hide data integrity problems (or make debugging missed notifications difficult). Consider logging a warning (including notification.id / channel.id) and/or explicitly marking the notification as handled (e.g., setting notified_at) so it doesn’t linger with an unclear state.
| return true if channel.owner.nil? | |
| if channel.owner.nil? | |
| Rails.logger.warn( | |
| "Notification #{id} skipped because channel #{channel.id} has no owner" | |
| ) | |
| update!(notified_at: Time.current) | |
| return true | |
| end |
| def hosted_on_github? | ||
| repository_url&.include?("github.com") | ||
| end |
There was a problem hiding this comment.
Using substring matching can misclassify non-GitHub hosts that contain github.com in the URL (e.g., https://github.com.evil.tld/...). Parsing the URI and checking uri.host == "github.com" (and optionally accepted GitHub hosts like www.github.com) makes the guard more robust, especially since downstream GitHub operations depend on this check.
- Validate repository selection in the GitHub picker controller against the fetched installation repositories so a crafted form submission can't persist an arbitrary repository_url. - Normalize details hash keys (symbolize_keys) in Webhook, OauthToken, and GithubInstallation notify methods to defend against upstream changes to the Notification payload. - Parse the repository URL in Application#hosted_on_github? instead of substring matching so URLs like github.com.evil.tld/... aren't misclassified. - Log and mark the notification as handled when a channel is orphaned instead of silently dropping. - Align AddGithubDeploymentIdToDeploys migration to Rails 8.1.
…ployments # Conflicts: # app/views/applications/_channels.html.erb # db/schema.rb # test/models/webhook_test.rb
Summary
Integrates with the GitHub Deployments API to create deployment records and status updates when deploys are recorded in Shipyrd. On pre-deploy, a GitHub deployment is created with an "in_progress" status. On post-deploy, the deployment status is updated to "success". This gives teams visibility into deploy activity directly in the GitHub UI.
Adds
github_deployment_idto deploys, aGithubDeploymentOctokit wrapper, a background job to handle the API calls, and therepo_deploymentOAuth scope for GitHub tokens.Todos