Skip to content

Add GitHub Deployments API integration - #293

Open
nickhammond wants to merge 8 commits into
mainfrom
nickhammond/github-deployments
Open

Add GitHub Deployments API integration#293
nickhammond wants to merge 8 commits into
mainfrom
nickhammond/github-deployments

Conversation

@nickhammond

@nickhammond nickhammond commented Apr 1, 2026

Copy link
Copy Markdown
Member

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_id to deploys, a GithubDeployment Octokit wrapper, a background job to handle the API calls, and the repo_deployment OAuth scope for GitHub tokens.

Todos

  • Verify repo naming in DB

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
@nickhammond
nickhammond marked this pull request as ready for review April 24, 2026 22:04
@nickhammond
nickhammond requested a review from Copilot April 24, 2026 22:04
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + GithubDeployment wrappers and a GithubInstallation model 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_id and 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.

Comment on lines +17 to +46
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

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread app/models/webhook.rb
@@ -16,6 +16,8 @@ def create_channel
end

def notify(event, details)

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
def notify(event, details)
def notify(event, details)
details = details.with_indifferent_access

Copilot uses AI. Check for mistakes.
Comment thread app/models/oauth_token.rb
Comment on lines 38 to 44
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(

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +22
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

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,5 @@
class AddGithubDeploymentIdToDeploys < ActiveRecord::Migration[8.0]

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
class AddGithubDeploymentIdToDeploys < ActiveRecord::Migration[8.0]
class AddGithubDeploymentIdToDeploys < ActiveRecord::Migration[8.1]

Copilot uses AI. Check for mistakes.
Comment thread app/models/notification.rb Outdated
Comment on lines 19 to 20
return true if channel.owner.nil?

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment thread app/models/application.rb
Comment on lines +53 to +55
def hosted_on_github?
repository_url&.include?("github.com")
end

Copilot AI Apr 24, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants