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
46 changes: 46 additions & 0 deletions .github/workflows/renovate-hosted-monitor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Renovate hosted monitor

on:
schedule:
- cron: '17 9 * * *'
workflow_dispatch:

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

jobs:
monitor:
name: Check Renovate hosted evidence
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false

- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '24'

- name: Capture Renovate hosted evidence
env:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/check-renovate-hosted.js | tee renovate-hosted.json

- name: Publish evidence summary
shell: bash
run: |
cat renovate-hosted.json >> "$GITHUB_STEP_SUMMARY"
if jq -e '.healthy == false' renovate-hosted.json >/dev/null; then
echo '::warning::No Renovate Dashboard or bot pull request is visible yet; Dependabot fallback remains required.'
fi
Comment on lines +34 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛑 Crash Risk: Missing error handling for JSON parsing. If renovate-hosted.json is empty or contains invalid JSON, the jq command will fail but the step will continue. Add error handling or use set -e to fail on errors.

Suggested change
- name: Publish evidence summary
shell: bash
run: |
cat renovate-hosted.json >> "$GITHUB_STEP_SUMMARY"
if jq -e '.healthy == false' renovate-hosted.json >/dev/null; then
echo '::warning::No Renovate Dashboard or bot pull request is visible yet; Dependabot fallback remains required.'
fi
- name: Publish evidence summary
shell: bash
run: |
set -e
cat renovate-hosted.json >> "$GITHUB_STEP_SUMMARY"
if jq -e '.healthy == false' renovate-hosted.json >/dev/null; then
echo '::warning::No Renovate Dashboard or bot pull request is visible yet; Dependabot fallback remains required.'
fi


- name: Upload evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: renovate-hosted-evidence
path: renovate-hosted.json
12 changes: 12 additions & 0 deletions scripts/check-renovate-hosted.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env node
import { runRenovateHostedCheck } from './lib/renovate-hosted.js';

runRenovateHostedCheck({
repository: process.env.GITHUB_REPOSITORY,
token: process.env.GITHUB_TOKEN,
})
.then((result) => console.log(JSON.stringify(result, null, 2)))
.catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
75 changes: 75 additions & 0 deletions scripts/lib/renovate-hosted.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Summarize hosted Renovate evidence without treating local configuration as
* proof that the GitHub App is operating.
* @param {{issues?: Array, pulls?: Array}} snapshot
* @returns {{healthy: boolean, dashboardIssues: Array, botPulls: Array, checkedAt: string}}
*/
export function summarizeRenovateHosted(snapshot) {
const issues = snapshot.issues ?? [];
const pulls = snapshot.pulls ?? [];
const isBot = (item) =>
String(item.user?.login ?? '')
.toLowerCase()
.includes('renovate');
const dashboardIssues = issues.filter(
(item) =>
String(item.title ?? '')
.toLowerCase()
.includes('dashboard') &&
(isBot(item) ||
String(item.title ?? '')
.toLowerCase()
.includes('renovate'))
);
const botPulls = pulls.filter((item) => isBot(item));

return {
healthy: dashboardIssues.length > 0 || botPulls.length > 0,
dashboardIssues: dashboardIssues.map(({ number, state, title, html_url: url }) => ({
number,
state,
title,
url,
})),
botPulls: botPulls.map(({ number, state, title, html_url: url }) => ({
number,
state,
title,
url,
})),
checkedAt: new Date().toISOString(),
};
}

async function fetchGitHub(path, token) {
const response = await fetch(`https://api.github.com${path}`, {
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${token}`,
'X-GitHub-Api-Version': '2022-11-28',
},
});
if (!response.ok) throw new Error(`GitHub API ${path} returned HTTP ${response.status}`);
return response.json();
}

export async function runRenovateHostedCheck({ repository, token }) {
if (!repository || !token) throw new Error('GITHUB_REPOSITORY and GITHUB_TOKEN are required');
const [owner, repo] = repository.split('/');
Comment on lines +56 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛑 Logic Error: Add validation for repository format before splitting. If repository doesn't contain a slash or has an invalid format (e.g., "myrepo" instead of "owner/repo"), the split operation will produce incorrect values, causing API calls to fail silently or target wrong repositories.

Suggested change
export async function runRenovateHostedCheck({ repository, token }) {
if (!repository || !token) throw new Error('GITHUB_REPOSITORY and GITHUB_TOKEN are required');
const [owner, repo] = repository.split('/');
export async function runRenovateHostedCheck({ repository, token }) {
if (!repository || !token) throw new Error('GITHUB_REPOSITORY and GITHUB_TOKEN are required');
if (!repository.includes('/')) throw new Error('GITHUB_REPOSITORY must be in owner/repo format');
const [owner, repo] = repository.split('/');

const [issues, pulls] = await Promise.all([
fetchGitHub(`/repos/${owner}/${repo}/issues?state=all&per_page=100`, token),
fetchGitHub(`/repos/${owner}/${repo}/pulls?state=all&per_page=100`, token),
]);
Comment on lines +59 to +62

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fetching only 100 items per endpoint may miss Renovate evidence in repositories with many issues or PRs. Consider implementing pagination to retrieve all items, or at minimum increase the limit and add sorting to ensure most recent items are checked first.

return summarizeRenovateHosted({ issues, pulls });
}

if (process.env.RENOVATE_HOSTED_MAIN === '1') {
const repository = process.env.GITHUB_REPOSITORY;
const token = process.env.GITHUB_TOKEN;
runRenovateHostedCheck({ repository, token })
.then((result) => console.log(JSON.stringify(result, null, 2)))
.catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
1 change: 1 addition & 0 deletions scripts/run-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function main() {
'--test',
'--experimental-test-coverage',
'--test-coverage-include=scripts/**/*.js',
'--test-coverage-exclude=scripts/check-renovate-hosted.js',
'--test-coverage-lines=75',
'--test-coverage-functions=80',
'--test-coverage-branches=60',
Expand Down
71 changes: 71 additions & 0 deletions test/renovate-hosted.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runRenovateHostedCheck, summarizeRenovateHosted } from '../scripts/lib/renovate-hosted.js';

test('Renovate Dashboard or bot PR proves hosted health', () => {
const summary = summarizeRenovateHosted({
issues: [
{
number: 10,
state: 'open',
title: 'Dependency Dashboard',
user: { login: 'renovate[bot]' },
html_url: 'https://example.test/10',
},
],
pulls: [],
});
assert.equal(summary.healthy, true);
assert.equal(summary.dashboardIssues[0].number, 10);
});

test('absence of Renovate artifacts remains an explicit unhealthy result', () => {
const summary = summarizeRenovateHosted({
issues: [
{
number: 11,
state: 'open',
title: '[Track] Renovate',
user: { login: 'edithatogo' },
html_url: 'https://example.test/11',
},
],
pulls: [],
});
assert.equal(summary.healthy, false);
assert.deepEqual(summary.botPulls, []);
});

test('hosted check fetches both issue and pull request snapshots', async () => {
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url) => {
requests.push(url);
return {
ok: true,
async json() {
return url.includes('/issues?')
? [
{
number: 12,
title: 'Dependency Dashboard',
user: { login: 'renovate[bot]' },
html_url: 'https://example.test/12',
},
]
: [];
},
};
};

try {
const summary = await runRenovateHostedCheck({
repository: 'edithatogo/authentext',
token: 'test-token',
});
assert.equal(summary.healthy, true);
assert.equal(requests.length, 2);
} finally {
globalThis.fetch = originalFetch;
}
});
Loading