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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/workflows/danger-comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
name: Danger Comment
on:
workflow_run:
workflows: [Danger]
types: [completed]
workflow_call:

permissions:
actions: read
contents: read
issues: write
pull-requests: write

jobs:
comment:
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request')
|| github.event_name == 'workflow_call'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Download Danger Report (workflow_run)
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: danger-report
run-id: ${{ github.event.workflow_run.id }}
repository: ${{ github.event.workflow_run.repository.full_name }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Download Danger Report (reusable call)
if: github.event_name == 'workflow_call'
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: danger-report
- name: Post or Update PR Comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const hasItems = (arr) => Array.isArray(arr) && arr.length > 0;
let report;
try {
report = JSON.parse(fs.readFileSync('danger_report.json', 'utf8'));
} catch (e) {
console.log('No danger report found, skipping comment');
return;
}
if (!report.pr_number) {
console.log('No PR number found in report, skipping comment');
return;
}
let body = '## Danger Report\n\n';
if (hasItems(report.errors)) {
body += '### ❌ Errors\n';
report.errors.forEach(e => body += `- ${e}\n`);
body += '\n';
}
if (hasItems(report.warnings)) {
body += '### ⚠️ Warnings\n';
report.warnings.forEach(w => body += `- ${w}\n`);
body += '\n';
}
if (hasItems(report.messages)) {
body += '### ℹ️ Messages\n';
report.messages.forEach(m => body += `- ${m}\n`);
body += '\n';
}
if (hasItems(report.markdowns)) {
report.markdowns.forEach(md => body += `${md}\n\n`);
}
if (!hasItems(report.errors) &&
!hasItems(report.warnings) &&
!hasItems(report.messages) &&
!hasItems(report.markdowns)) {
body += '✅ All checks passed!';
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: report.pr_number
});
const botComment = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('## Danger Report')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: body
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: report.pr_number,
body: body
});
}
// Fail if there are errors
if (report.errors && report.errors.length > 0) {
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

The error checking is inconsistent. This checks for both existence and length of the errors array, but the hasItems function already performs both checks. Consider using hasItems(report.errors) here for consistency with the rest of the script.

Suggested change
if (report.errors && report.errors.length > 0) {
if (hasItems(report.errors)) {

Copilot uses AI. Check for mistakes.
core.setFailed('Danger found errors');
}
33 changes: 33 additions & 0 deletions .github/workflows/danger-run.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Danger
on:
pull_request:
types: [ opened, reopened, edited, synchronize ]
workflow_call:
jobs:
danger:
name: Danger
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

Inconsistent action version: The workflow uses actions/checkout@v3 while danger-comment.yml uses actions/checkout@v4. For consistency and to benefit from the latest improvements and security fixes, both workflows should use the same (preferably latest) version.

Suggested change
uses: actions/checkout@v3
uses: actions/checkout@v4

Copilot uses AI. Check for mistakes.
with:
fetch-depth: 0
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 2.7
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

The Ruby version 2.7 has reached end-of-life (EOL) in March 2023 and is no longer receiving security updates. Consider using a more recent Ruby version like 3.0 or later for better security and performance. This is especially important for a reusable workflow that will be used across multiple projects.

Suggested change
ruby-version: 2.7
ruby-version: '3.1'

Copilot uses AI. Check for mistakes.
bundler-cache: true
- name: Run Danger
# Note: We use 'dry_run' mode intentionally as part of a two-workflow pattern.
# The actual commenting on GitHub is handled by the danger-comment.yml workflow.
run: bundle exec danger dry_run --verbose
env:
DANGER_REPORT_PATH: danger_report.json
- name: Upload Danger Report
if: always()
uses: actions/upload-artifact@v4
with:
name: danger-report
path: danger_report.json
retention-days: 1
if-no-files-found: ignore
21 changes: 0 additions & 21 deletions .github/workflows/danger.yml

This file was deleted.

1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
### 0.2.2 (Next)

* Your contribution here.
* [#15](https://github.com/ruby-grape/danger/pull/15): Extract danger reporting infrastructure into reusable workflows and gem - [@numbata](https://github.com/numbata).

### 0.2.1 (2024/02/01)

Expand Down
23 changes: 23 additions & 0 deletions Dangerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# frozen_string_literal: true

require 'ruby-grape-danger'
require 'English'

# This Dangerfile provides automatic danger report export and standard checks for Grape projects.
# Other projects can import this via: danger.import_dangerfile(gem: 'ruby-grape-danger')
# to get automatic reporting with their own custom checks.

# Register at_exit hook to export report when Dangerfile finishes
at_exit do
# Only skip if there's an actual exception (not SystemExit from danger calling exit)
next if $ERROR_INFO && !$ERROR_INFO.is_a?(SystemExit)

# Find the Dangerfile instance and get its current status_report
ObjectSpace.each_object(Danger::Dangerfile) do |df|
reporter = RubyGrapeDanger::Reporter.new(df.status_report)
reporter.export_json(
ENV.fetch('DANGER_REPORT_PATH', nil),
ENV.fetch('GITHUB_EVENT_PATH', nil)
)
break
end
Comment on lines +10 to +23
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

Using ObjectSpace.each_object to find the Dangerfile instance is fragile and could be problematic if multiple Dangerfile instances exist in memory. Consider storing a reference to the Dangerfile instance in a way that doesn't require ObjectSpace traversal, or document the assumption that only one Dangerfile instance exists during execution.

Suggested change
# Register at_exit hook to export report when Dangerfile finishes
at_exit do
# Only skip if there's an actual exception (not SystemExit from danger calling exit)
next if $ERROR_INFO && !$ERROR_INFO.is_a?(SystemExit)
# Find the Dangerfile instance and get its current status_report
ObjectSpace.each_object(Danger::Dangerfile) do |df|
reporter = RubyGrapeDanger::Reporter.new(df.status_report)
reporter.export_json(
ENV.fetch('DANGER_REPORT_PATH', nil),
ENV.fetch('GITHUB_EVENT_PATH', nil)
)
break
end
# Capture the Dangerfile instance evaluating this file so we don't need ObjectSpace traversal.
dangerfile_instance = self if defined?(Danger::Dangerfile) && is_a?(Danger::Dangerfile)
# Register at_exit hook to export report when Dangerfile finishes
at_exit do
# Only skip if there's an actual exception (not SystemExit from danger calling exit)
next if $ERROR_INFO && !$ERROR_INFO.is_a?(SystemExit)
# Use the captured Dangerfile instance (if any) to get its current status_report
df = dangerfile_instance
next unless df
reporter = RubyGrapeDanger::Reporter.new(df.status_report)
reporter.export_json(
ENV.fetch('DANGER_REPORT_PATH', nil),
ENV.fetch('GITHUB_EVENT_PATH', nil)
)

Copilot uses AI. Check for mistakes.
end

# --------------------------------------------------------------------------------------------------------------------
# Has any changes happened inside the actual library code?
# --------------------------------------------------------------------------------------------------------------------
Expand Down
102 changes: 98 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@

[![Build Status](https://travis-ci.org/ruby-grape/danger.svg?branch=master)](https://travis-ci.org/ruby-grape/danger)

## Table of Contents
# Table of Contents
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

The heading formatting is inconsistent. The README uses '# Table of Contents' (single hash) for this section, while other sections like '## Setup' use double hashes. For consistency within the document structure, this should be '## Table of Contents'.

Suggested change
# Table of Contents
## Table of Contents

Copilot uses AI. Check for mistakes.

- [Setup](#setup)
- [Set DANGER_GITHUB_API_TOKEN in Travis-CI](#set-danger_github_api_token-in-travis-ci)
- [Add Danger](#add-danger)
- [Add Dangerfile](#add-dangerfile)
- [Add Danger to Travis-CI](#add-danger-to-travis-ci)
- [Add GitHub Actions Workflows](#add-github-actions-workflows)
- [Commit via a Pull Request](#commit-via-a-pull-request)
- [Reusable Workflows](#reusable-workflows)
- [Architecture](#architecture)
- [Benefits of Reusable Workflows](#benefits-of-reusable-workflows)
- [How It Works](#how-it-works)
- [Examples](#examples)
- [License](#license)

## Setup
Expand All @@ -28,16 +32,106 @@ gem 'ruby-grape-danger', require: false

### Add Dangerfile

Commit a `Dangerfile`, eg. [Grape's Dangerfile](https://github.com/ruby-grape/grape/blob/master/Dangerfile).
Create a `Dangerfile` in your project's root that imports `ruby-grape-danger` and adds your project-specific checks:

```ruby
danger.import_dangerfile(gem: 'ruby-grape-danger')

# Your project-specific danger checks
changelog.check!
toc.check!
```

The `ruby-grape-danger` Dangerfile automatically handles:
- Setting up the reporting infrastructure
- Exporting the danger report via `at_exit` hook when the Dangerfile finishes
- Consistent output format for the workflow

### Add GitHub Actions Workflows

Create `.github/workflows/danger.yml`:

```yaml
name: Danger
on:
pull_request:
types: [ opened, reopened, edited, synchronize ]
workflow_call:

jobs:
danger:
uses: ruby-grape/ruby-grape-danger/.github/workflows/danger-run.yml@main
```

Create `.github/workflows/danger-comment.yml`:

```yaml
name: Danger Comment
on:
workflow_run:
workflows: [Danger]
types: [completed]
workflow_call:

jobs:
comment:
uses: ruby-grape/ruby-grape-danger/.github/workflows/danger-comment.yml@main
Comment on lines +63 to +78
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

The README examples reference the reusable Danger workflows using the mutable @main branch (in the uses: ruby-grape/ruby-grape-danger/.github/workflows/...@main lines). If downstream projects copy this configuration, a compromise of the ruby-grape-danger repository (or a malicious change to main) would immediately run attacker-controlled workflow code in their CI with access to their GITHUB_TOKEN and build artifacts. To avoid this supply-chain risk, update the examples to pin the reusable workflows to a specific commit SHA so consumers are not bound to a mutable branch reference.

Copilot uses AI. Check for mistakes.
```

### Commit via a Pull Request

To test things out, make a dummy entry in `CHANGELOG.md` that doesn't match the standard format and make a pull request. Iterate until green.

## Reusable Workflows

This gem provides **reusable GitHub Actions workflows** that can be referenced by any Grape project to implement standardized Danger checks with consistent reporting.

### Architecture

The workflows are separated into two stages:

1. **danger-run.yml**: Executes Danger checks and generates a report
- Runs `bundle exec danger dry_run` with your project's Dangerfile
- Generates a JSON report of check results
- Uploads the report as an artifact

2. **danger-comment.yml**: Posts/updates PR comments with results
- Downloads the Danger report artifact
- Formats and posts results as a PR comment
- Updates existing comment on subsequent runs

### Benefits of Reusable Workflows

✅ **DRY**: Define workflows once in `ruby-grape-danger`, reuse everywhere
✅ **Consistent**: All Grape projects use the same reporting format and behavior
✅ **Maintainable**: Fix a bug in the workflows once, all projects benefit automatically
✅ **Scalable**: Add new checks to any project's Dangerfile without touching workflows

### How It Works

When you reference the reusable workflows:

```yaml
uses: ruby-grape/ruby-grape-danger/.github/workflows/danger-run.yml@main
```

GitHub Actions:
1. Checks out **your project's repository** (not ruby-grape-danger)
2. Installs dependencies from **your Gemfile**
3. Runs danger using **your Dangerfile**
- Your Dangerfile imports `ruby-grape-danger`'s Dangerfile via `danger.import_dangerfile(gem: 'ruby-grape-danger')`
- The imported Dangerfile registers an `at_exit` hook for automatic reporting
- Runs your project-specific checks (added after the import)
- When Dangerfile finishes, the `at_exit` hook automatically exports the report
4. The report is uploaded as an artifact for the commenting workflow

Each project maintains its own Dangerfile with project-specific checks, while the `ruby-grape-danger` gem provides shared infrastructure for consistent reporting and workflow execution.

### Examples

- [danger-changelog](https://github.com/ruby-grape/danger-changelog) - Validates CHANGELOG format
- [grape](https://github.com/ruby-grape/grape) - Multi-check danger implementation

## License

MIT License. See [LICENSE](LICENSE) for details.
Expand Down
5 changes: 5 additions & 0 deletions lib/ruby-grape-danger.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
require 'ruby-grape-danger/version'
require 'ruby-grape-danger/reporter'

module RubyGrapeDanger
end
38 changes: 38 additions & 0 deletions lib/ruby-grape-danger/reporter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
require 'json'

module RubyGrapeDanger
class Reporter
def initialize(status_report)
@status_report = status_report
end
Comment on lines +4 to +7
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

The Reporter class and its public methods lack documentation. Consider adding class-level and method-level documentation to describe the purpose, parameters, and return values. This is particularly important since this is infrastructure code that other projects will rely on.

Copilot uses AI. Check for mistakes.

def export_json(report_path, event_path)
return unless report_path && event_path && File.exist?(event_path)

event = JSON.parse(File.read(event_path))
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

The JSON parsing lacks error handling. If the event file contains invalid JSON, this will raise a JSON::ParserError that propagates up. Consider wrapping this in a rescue block to handle malformed JSON gracefully, similar to how the code handles missing files or missing PR numbers.

Suggested change
event = JSON.parse(File.read(event_path))
begin
event = JSON.parse(File.read(event_path))
rescue JSON::ParserError
return
end

Copilot uses AI. Check for mistakes.
pr_number = event.dig('pull_request', 'number')
return unless pr_number

report = build_report(pr_number)
File.write(report_path, JSON.pretty_generate(report))
end

private

def build_report(pr_number)
{
pr_number: pr_number,
errors: to_messages(@status_report[:errors]),
warnings: to_messages(@status_report[:warnings]),
messages: to_messages(@status_report[:messages]),
markdowns: to_messages(@status_report[:markdowns])
}
end

def to_messages(items)
Array(items).map do |item|
item.respond_to?(:message) ? item.message : item.to_s
Comment on lines +33 to +34
Copy link

Copilot AI Dec 17, 2025

Choose a reason for hiding this comment

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

The to_messages method calls to_s on items that don't respond to :message. This means nil values will become the string "nil" rather than being filtered out. Consider whether empty or nil items should be excluded from the output, or if the current behavior is intentional for debugging purposes.

Suggested change
Array(items).map do |item|
item.respond_to?(:message) ? item.message : item.to_s
Array(items).each_with_object([]) do |item, messages|
msg = item.respond_to?(:message) ? item.message : item
next if msg.nil?
str = msg.to_s
next if str.empty?
messages << str

Copilot uses AI. Check for mistakes.
end
end
end
end
Loading