Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
type: Other
scope:
- ckeditor5-dev-ci
see:
- https://github.com/ckeditor/ckeditor5-internal/issues/4450
---

Added `ckeditor5-dev-ci-notify-github-actions-status`, a GitHub Actions equivalent of `ckeditor5-dev-ci-notify-circle-status` that posts a Slack notification summarizing a failed workflow.
27 changes: 26 additions & 1 deletion packages/ckeditor5-dev-ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ CKEditor 5 CI utilities

Utils for [CKEditor 5](https://ckeditor.com) CI builds.

Contains tools for sending Slack notifications by Circle CI.
Contains tools for sending Slack notifications from Circle CI and GitHub Actions workflows.

## Available scripts

Expand Down Expand Up @@ -111,6 +111,31 @@ These commands accept a mix of environment variables and command line arguments.
- `--trigger-commit-hash` — Commit SHA to construct the commit URL. Useful when a pipeline was triggered via a different repository.
- `--hide-author` — `"true"`/`"false"` to hide the author in Slack.

- ⚙️ **`ckeditor5-dev-ci-notify-github-actions-status`**

Sends a Slack notification summarizing the current GitHub Actions workflow run.
Designed to be called from a step (or dedicated job) gated by `if: failure()`; the script does not check the workflow status before sending the notification.
Fetches the commit author and workflow run start time via the GitHub API (works with private repositories).

**Environment variables:**
- `CKE5_GITHUB_TOKEN` — GitHub token with the `repo` scope, used to fetch commit author and workflow run metadata.
- `CKE5_SLACK_WEBHOOK_URL` — Incoming Webhook URL for the Slack channel receiving notifications.

**GitHub-provided variables:**
- `GITHUB_REPOSITORY` &mdash; `<owner>/<repo>` of the current workflow.
- `GITHUB_REF_NAME` &mdash; Branch or tag name that triggered the workflow.
- `GITHUB_SHA` &mdash; Commit SHA of the current run.
- `GITHUB_RUN_ID` &mdash; ID of the current workflow run.
- `GITHUB_RUN_ATTEMPT` &mdash; *(Optional)* Run attempt number; included in the Slack message when greater than `1`.
- `GITHUB_WORKFLOW` &mdash; *(Optional)* Display name of the current workflow.
- `GITHUB_SERVER_URL` &mdash; *(Optional)* Server URL; defaults to `https://github.com`.
- `GITHUB_API_URL` &mdash; *(Optional)* API base URL; defaults to `https://api.github.com`.

**Parameters:**
- `--trigger-repository-slug` &mdash; `<org>/<repo>` to construct the commit URL when provided with `--trigger-commit-hash`. Useful when a workflow was triggered via a different repository.
- `--trigger-commit-hash` &mdash; Commit SHA to construct the commit URL. Useful when a workflow was triggered via a different repository.
- `--hide-author` &mdash; `"true"`/`"false"` to hide the author in Slack.

- ⚙️ **`ckeditor5-dev-ci-trigger-circle-build`**

Triggers a **new CircleCI pipeline** for a specified repository.
Expand Down
159 changes: 159 additions & 0 deletions packages/ckeditor5-dev-ci/bin/notify-github-actions-status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/usr/bin/env node

/**
* @license Copyright (c) 2003-2026, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/

import { parseArgs } from 'node:util';
import slackNotify from 'slack-notify';
import formatMessage from '../lib/format-message.js';

// This script assumes that it is being executed in a GitHub Actions runner.
// The step using it should be conditional on the workflow having failed
// (for example via `if: failure()` or a dedicated job depending on the failed one),
// since the script itself does not check whether the workflow failed before sending
// the notification.
//
// Described environment variables starting with "CKE5" must be added by the integrator.

const {
/**
* Required. Token to a GitHub account with the `repo` scope. It is required for obtaining
* the author of the commit that triggered the failed workflow run. The repository can be
* private, so the public, unauthenticated API cannot be used.
*/
CKE5_GITHUB_TOKEN,

/**
* Required. Webhook URL of the Slack channel where the notification should be sent.
*/
CKE5_SLACK_WEBHOOK_URL,

// Variables that are available by default in the GitHub Actions environment.
// See: https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables.
GITHUB_REPOSITORY,
GITHUB_REF_NAME,
GITHUB_SHA,
GITHUB_RUN_ID,
GITHUB_RUN_ATTEMPT,
GITHUB_WORKFLOW,
GITHUB_SERVER_URL,
GITHUB_API_URL
} = process.env;

const { values: cliArguments } = parseArgs( {
options: {
/**
* Optional. If both are defined, the script will use the URL as the commit URL.
* Otherwise, the URL will be constructed using the current repository data.
*/
'trigger-repository-slug': {
type: 'string',
default: process.env.CKE5_TRIGGER_REPOSITORY_SLUG
},
'trigger-commit-hash': {
type: 'string',
default: process.env.CKE5_TRIGGER_COMMIT_HASH
},

/**
* Optional. If set to "true" or "1", commit author will be hidden.
* See: https://github.com/ckeditor/ckeditor5/issues/9252.
*/
'hide-author': {
type: 'string',
default: process.env.CKE5_SLACK_NOTIFY_HIDE_AUTHOR
}
}
} );

notifyGitHubActionsStatus().catch( err => {
console.error( err );
process.exit( 1 );
} );

async function notifyGitHubActionsStatus() {
assertRequiredEnvironmentVariables();

const serverUrl = ( GITHUB_SERVER_URL || 'https://github.com' ).replace( /\/$/, '' );
const apiUrl = ( GITHUB_API_URL || 'https://api.github.com' ).replace( /\/$/, '' );
const [ repositoryOwner, repositoryName ] = GITHUB_REPOSITORY.split( '/' );
const runAttempt = GITHUB_RUN_ATTEMPT ? Number( GITHUB_RUN_ATTEMPT ) : 1;

const runData = await getWorkflowRunData( { apiUrl, repositoryOwner, repositoryName } );
const buildUrl = [ serverUrl, GITHUB_REPOSITORY, 'actions', 'runs', GITHUB_RUN_ID, 'attempts', runAttempt ].join( '/' );
const startedAtIso = runData.run_started_at || runData.created_at;

const message = await formatMessage( {
slackMessageUsername: 'GitHub Actions',
iconUrl: 'https://avatars.githubusercontent.com/in/15368?s=80&v=4',
repositoryOwner,
repositoryName,
branch: GITHUB_REF_NAME,
buildTitle: GITHUB_WORKFLOW || 'Workflow run',
buildUrl,
buildId: `#${ GITHUB_RUN_ID }${ runAttempt > 1 ? ` (attempt ${ runAttempt })` : '' }`,
githubToken: CKE5_GITHUB_TOKEN,
triggeringCommitUrl: getTriggeringCommitUrl( serverUrl ),
apiUrl,
startTime: startedAtIso ? Math.ceil( new Date( startedAtIso ).getTime() / 1000 ) : null,
endTime: Math.ceil( Date.now() / 1000 ),
shouldHideAuthor: isTrueLike( cliArguments[ 'hide-author' ] )
} );

return slackNotify( CKE5_SLACK_WEBHOOK_URL )
.send( message )
.catch( err => console.log( 'API error occurred:', err ) );
}

function assertRequiredEnvironmentVariables() {
const required = {
CKE5_GITHUB_TOKEN,
CKE5_SLACK_WEBHOOK_URL,
GITHUB_REPOSITORY,
GITHUB_REF_NAME,
GITHUB_SHA,
GITHUB_RUN_ID
};

for ( const [ name, value ] of Object.entries( required ) ) {
if ( !value ) {
throw new Error( `Missing environment variable: ${ name }` );
}
}
}

async function getWorkflowRunData( { apiUrl, repositoryOwner, repositoryName } ) {
const fetchUrl = [ apiUrl, 'repos', repositoryOwner, repositoryName, 'actions', 'runs', GITHUB_RUN_ID ].join( '/' );
const fetchOptions = {
method: 'GET',
headers: {
'Accept': 'application/vnd.github+json',
'Authorization': `Bearer ${ CKE5_GITHUB_TOKEN }`,
'X-GitHub-Api-Version': '2022-11-28'
}
};

const response = await fetch( fetchUrl, fetchOptions );

if ( !response.ok ) {
throw new Error( `Failed to fetch workflow run ${ GITHUB_RUN_ID }: HTTP ${ response.status }.` );
}

return response.json();
}

function getTriggeringCommitUrl( serverUrl ) {
const cliRepoSlug = cliArguments[ 'trigger-repository-slug' ];
const cliCommitHash = cliArguments[ 'trigger-commit-hash' ];

const repoSlug = cliRepoSlug && cliCommitHash ? cliRepoSlug.trim() : GITHUB_REPOSITORY;
const hash = cliRepoSlug && cliCommitHash ? cliCommitHash.trim() : GITHUB_SHA;

return [ serverUrl, repoSlug, 'commit', hash ].join( '/' );
}

function isTrueLike( value ) {
return value === true || value === 1 || value === '1' || value === 'true';
}
27 changes: 18 additions & 9 deletions packages/ckeditor5-dev-ci/lib/format-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

import { bots, members } from './data/index.js';

const REPOSITORY_REGEXP = /github\.com\/([^/]+)\/([^/]+)/;

/**
* @param {object} options
* @param {string} options.slackMessageUsername
Expand All @@ -19,12 +17,13 @@ const REPOSITORY_REGEXP = /github\.com\/([^/]+)\/([^/]+)/;
* @param {string} options.buildId
* @param {string} options.githubToken
* @param {string} options.triggeringCommitUrl
* @param {string} [options.apiUrl]
* @param {number} options.startTime
* @param {number} options.endTime
* @param {boolean} options.shouldHideAuthor
*/
export default async function formatMessage( options ) {
const commitDetails = await getCommitDetails( options.triggeringCommitUrl, options.githubToken );
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Enterprise repo links wrong host

Medium Severity

The Slack attachment still builds the Repository (branch) links from a hardcoded https://github.com base, while this change routes commit URLs, issue references, and API calls through GITHUB_SERVER_URL / apiUrl for GitHub Enterprise. On Enterprise, those repository and branch links point at the wrong host even when the rest of the message is correct.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e2b60bd. Configure here.

const commitDetails = await getCommitDetails( options.triggeringCommitUrl, options.githubToken, options.apiUrl );
const repoUrl = `https://github.com/${ options.repositoryOwner }/${ options.repositoryName }`;

return {
Expand Down Expand Up @@ -162,14 +161,16 @@ function getFormattedMessage( commitMessage, triggeringCommitUrl ) {
return '_Unavailable._';
}

const [ , repoOwner, repoName ] = triggeringCommitUrl.match( REPOSITORY_REGEXP );
const url = new URL( triggeringCommitUrl );
const serverUrl = url.origin;
const [ , repoOwner, repoName ] = url.pathname.split( '/' );

return commitMessage
.replace( / #(\d+)/g, ( _, issueId ) => {
return ` <https://github.com/${ repoOwner }/${ repoName }/issues/${ issueId }|#${ issueId }>`;
return ` <${ serverUrl }/${ repoOwner }/${ repoName }/issues/${ issueId }|#${ issueId }>`;
} )
.replace( /([\w-]+\/[\w-]+)#(\d+)/g, ( _, repoSlug, issueId ) => {
return `<https://github.com/${ repoSlug }/issues/${ issueId }|${ repoSlug }#${ issueId }>`;
return `<${ serverUrl }/${ repoSlug }/issues/${ issueId }|${ repoSlug }#${ issueId }>`;
} );
}

Expand All @@ -178,10 +179,11 @@ function getFormattedMessage( commitMessage, triggeringCommitUrl ) {
*
* @param {string} triggeringCommitUrl The URL to the commit on GitHub.
* @param {string} githubToken Github token used for authorization a request,
* @param {string} [apiUrl] Optional base URL of the GitHub API.
Comment thread
cursor[bot] marked this conversation as resolved.
* @returns {Promise.<object>}
*/
function getCommitDetails( triggeringCommitUrl, githubToken ) {
const apiGithubUrlCommit = getGithubApiUrl( triggeringCommitUrl );
function getCommitDetails( triggeringCommitUrl, githubToken, apiUrl ) {
const apiGithubUrlCommit = getGithubApiUrl( triggeringCommitUrl, apiUrl );
const options = {
method: 'GET',
credentials: 'include',
Expand All @@ -204,8 +206,15 @@ function getCommitDetails( triggeringCommitUrl, githubToken ) {
* Returns a URL to GitHub API which returns details of the commit that caused the CI to fail its job.
*
* @param {string} triggeringCommitUrl The URL to the commit on GitHub.
* @param {string} [apiUrl] Optional base URL of the GitHub API.
* @returns {string}
*/
function getGithubApiUrl( triggeringCommitUrl ) {
function getGithubApiUrl( triggeringCommitUrl, apiUrl ) {
if ( apiUrl ) {
const [ , owner, repo, , sha ] = new URL( triggeringCommitUrl ).pathname.split( '/' );

return `${ apiUrl.replace( /\/$/, '' ) }/repos/${ owner }/${ repo }/commits/${ sha }`;
}

return triggeringCommitUrl.replace( 'github.com/', 'api.github.com/repos/' ).replace( '/commit/', '/commits/' );
}
1 change: 1 addition & 0 deletions packages/ckeditor5-dev-ci/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"ckeditor5-dev-ci-is-job-triggered-by-member": "bin/is-job-triggered-by-member.js",
"ckeditor5-dev-ci-is-workflow-restarted": "bin/is-workflow-restarted.js",
"ckeditor5-dev-ci-notify-circle-status": "bin/notify-circle-status.js",
"ckeditor5-dev-ci-notify-github-actions-status": "bin/notify-github-actions-status.js",
"ckeditor5-dev-ci-trigger-circle-build": "bin/trigger-circle-build.js",
"ckeditor5-dev-ci-trigger-snyk-scan": "bin/trigger-snyk-scan.js"
},
Expand Down
Loading