Skip to content

Add granular RBAC SSO support #10902

Add granular RBAC SSO support

Add granular RBAC SSO support #10902

Workflow file for this run

name: Tests
on:
push:
branches:
- main
- maintenance
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-changes:
name: Verify changes
runs-on: ubuntu-latest
outputs:
run_backend_tests: ${{ steps.changed-files.outputs.backend_changed }}
run_postgres_tests: ${{ steps.changed-files.outputs.postgres_changed }}
run_ui_tests: ${{ steps.changed-files.outputs.ui_changed }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Find changed files
id: changed-files
run: |
./.github/scripts/detect_changed_files.sh
check-ts-drift:
name: Check TypeScript drift
runs-on: ubuntu-latest
needs: check-changes
if: |
needs.check-changes.outputs.run_backend_tests == 'true' ||
needs.check-changes.outputs.run_postgres_tests == 'true' ||
needs.check-changes.outputs.run_ui_tests == 'true'
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Use Node.js 24
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Install Dependencies
run: npm ci
- name: Build the forge
run: npm run build
- name: Generate TypeScript types
run: npm run generate:types
env:
DEV_ENTERPRISE_TIER_LICENSE: ${{ secrets.DEV_ENTERPRISE_TIER_LICENSE }}
- name: Check drift
run: |
if ! git diff --exit-code frontend/src/types/generated.ts ; then
echo ""
echo "::error::Type drift detected! Run 'npm run generate:types' locally and commit the result."
exit 1
fi
backend-tests:
if: ${{ needs.check-changes.outputs.run_backend_tests == 'true' }}
name: Backend tests
needs:
- check-changes
- check-ts-drift
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Use Node.js 24
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Install Dependencies
run: npm ci
- name: Build the forge
run: npm run build
- name: Run forge unit tests
run: npm run cover:unit:forge
- name: Run forge system tests
run: npm run cover:system
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: backend
postgres-tests:
if: ${{ needs.check-changes.outputs.run_postgres_tests == 'true' }}
name: Postgres tests
needs:
- check-changes
- check-ts-drift
runs-on: ubuntu-latest
timeout-minutes: 45
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: secret
POSTGRES_USER: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
-m 3GB
ports:
- 5432:5432
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup NodeJS 24
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24
- name: Install Dependencies
run: npm ci
- name: Build the forge
run: npm run build
- name: Test the forge (with PostgreSQL)
env:
FF_TEST_DB_POSTGRES: true
FF_TEST_DB_POSTGRES_HOST: localhost
FF_TEST_DB_POSTGRES_PORT: 5432
FF_TEST_DB_POSTGRES_USER: postgres
FF_TEST_DB_POSTGRES_PASSWORD: secret
FF_TEST_DB_POSTGRES_DATABASE: flowforge
NODE_OPTIONS: "--max-old-space-size=6144"
run: npm run test
ui-unit-tests:
if: ${{ needs.check-changes.outputs.run_ui_tests == 'true' }}
name: UI unit tests
needs:
- check-changes
- check-ts-drift
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Dependencies
run: npm ci
- name: Run Linting Tests
run: npm run lint
- name: Run UI Unit Tests
run: npm run test:unit:frontend
ui-os-tests:
if: ${{ needs.check-changes.outputs.run_ui_tests == 'true' }}
name: UI OS tests
needs:
- check-changes
- check-ts-drift
runs-on: ubuntu-latest
services:
email-service:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
options: >-
--env MP_MAX_MESSAGES=5000
--env MP_SMTP_AUTH_ACCEPT_ANY=1
--env MP_SMTP_AUTH_ALLOW_INSECURE=1
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Dependencies
run: npm ci
- name: Run UI E2E (Cypress) Tests - OS
uses: cypress-io/github-action@948d67d3074f1bbb6379c8bdbb04e95d2f8e593f # v7.4.0
with:
install: false
config-file: config/cypress-os.config.js
build: npm run build
start: npm run cy:web-server:os
env:
NO_SMTP_SERVER: 'true'
SMTP_HOST: 'localhost'
SMTP_PORT: '1025'
SMTP_WEB_PORT: '8025'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: os-cypress-output-${{ github.run_id }}-${{ github.run_number }}
path: |
test/e2e/frontend/cypress/screenshots
test/e2e/frontend/cypress/videos
ui-ee-tests:
if: ${{ needs.check-changes.outputs.run_ui_tests == 'true' }}
name: UI EE tests
needs:
- check-changes
- check-ts-drift
runs-on: ubuntu-latest
services:
email-service:
image: axllent/mailpit
ports:
- 8025:8025
- 1025:1025
options: >-
--env MP_MAX_MESSAGES=5000
--env MP_SMTP_AUTH_ACCEPT_ANY=1
--env MP_SMTP_AUTH_ALLOW_INSECURE=1
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Dependencies
run: npm ci
- name: Run UI E2E (Cypress) Tests - EE
uses: cypress-io/github-action@948d67d3074f1bbb6379c8bdbb04e95d2f8e593f # v7.4.0
with:
install: false
config-file: config/cypress-ee.config.js
build: npm run build
start: npm run cy:web-server:ee
env:
NO_SMTP_SERVER: 'true'
SMTP_HOST: 'localhost'
SMTP_PORT: '1025'
SMTP_WEB_PORT: '8025'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
name: ee-cypress-output-${{ github.run_id }}-${{ github.run_number }}
path: |
test/e2e/frontend/cypress/screenshots
test/e2e/frontend/cypress/videos
### We use this job to gather results from all test jobs and determine the final status of the tests execution.
### Jobs executed with the matrix strategy have dynamic names and cannot be referenced in the status checks of the pull request.
tests-summary:
name: Tests summary
if: ${{ always() }}
needs:
- check-changes
- check-ts-drift
- backend-tests
- postgres-tests
- ui-unit-tests
- ui-os-tests
- ui-ee-tests
runs-on: ubuntu-latest
steps:
- name: Evaluate test job results
run: |
results=(
'${{ needs.check-ts-drift.result }}'
'${{ needs.backend-tests.result }}'
'${{ needs.postgres-tests.result }}'
'${{ needs.ui-unit-tests.result }}'
'${{ needs.ui-os-tests.result }}'
'${{ needs.ui-ee-tests.result }}'
)
echo 'Test job results:'
printf '%s\n' "${results[@]}"
for result in "${results[@]}"; do
if [ "$result" = 'failure' ] || [ "$result" = 'cancelled' ]; then
echo 'One or more test jobs failed or were cancelled'
exit 1
fi
done
echo 'All test jobs succeeded or were skipped'
notify-slack:
name: Notify on failure
needs: tests-summary
if: failure()
runs-on: ubuntu-latest
steps:
# On pull_request failure: resolve the PR author's Slack ID by scanning Slack
# profiles and matching the custom profile field (GitHub). Bot authors are skipped
# silently; an unresolved author logs a warning and no notification is sent.
# On push to main: target the gh-pipelines channel directly.
# Outputs `recipients` as JSON: [{ slack_id, github?, role }].
- name: Resolve Slack recipients
id: resolve
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_GHBOT_TOKEN }}
SLACK_GITHUB_FIELD_ID: "Xf0A2BPU8U77"
CHANNEL_ID: "C067BD0377F"
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
ACTOR: ${{ github.actor }}
EVENT_NAME: ${{ github.event_name }}
with:
script: |
const token = process.env.SLACK_BOT_TOKEN;
const fieldId = process.env.SLACK_GITHUB_FIELD_ID;
// Resolve a GitHub login -> Slack user ID by scanning workspace profiles and
// matching the custom profile field (GitHub). Returns undefined if not found.
async function resolveSlackId(login) {
const target = login.toLowerCase();
let cursor;
do {
const params = new URLSearchParams({ limit: '200' });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`https://slack.com/api/users.list?${params}`, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await res.json();
if (!data.ok) { core.setFailed(`Slack users.list error: ${data.error}`); return; }
for (const member of data.members) {
if (member.deleted || member.is_bot) continue;
const profileRes = await fetch(`https://slack.com/api/users.profile.get?user=${member.id}`, {
headers: { Authorization: `Bearer ${token}` }
});
const profileData = await profileRes.json();
if (!profileData.ok) continue;
const ghField = profileData.profile?.fields?.[fieldId]?.value;
if (!ghField) continue;
if (ghField.toLowerCase() === target) return member.id;
}
cursor = data.response_metadata?.next_cursor;
} while (cursor);
return undefined;
}
// Push to main - notify the channel, mentioning the actor who pushed.
if (process.env.EVENT_NAME !== 'pull_request') {
const actor = process.env.ACTOR;
const mention = actor ? await resolveSlackId(actor) : undefined;
if (!mention) core.warning(`No Slack user found with GitHub username: ${actor}`);
core.setOutput('recipients', JSON.stringify([{ slack_id: process.env.CHANNEL_ID, role: 'Channel', mention: mention || null }]));
return;
}
// Pull request - notify the author. Bot authors have no Slack profile: skip silently.
const author = process.env.PR_AUTHOR;
if (!author || author.endsWith('[bot]')) {
core.setOutput('recipients', '[]');
return;
}
const slackId = await resolveSlackId(author);
if (!slackId) {
core.warning(`No Slack user found with GitHub username: ${author}`);
core.setOutput('recipients', '[]');
return;
}
core.setOutput('recipients', JSON.stringify([{ slack_id: slackId, github: author.toLowerCase(), role: 'Author' }]));
# Sends the failure notification to each recipient (DM to the PR author, or the channel
# on main). chat.postMessage accepts a user ID or a channel ID as `channel`.
- name: Send Slack notification
if: steps.resolve.outputs.recipients != '' && steps.resolve.outputs.recipients != '[]'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_GHBOT_TOKEN }}
RECIPIENTS: ${{ steps.resolve.outputs.recipients }}
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COMMIT_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
ACTOR: ${{ github.actor }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
with:
script: |
const token = process.env.SLACK_BOT_TOKEN;
const recipients = JSON.parse(process.env.RECIPIENTS);
const isBranch = process.env.EVENT_NAME !== 'pull_request';
const refName = process.env.REF_NAME;
const prNumber = process.env.PR_NUMBER;
const commitSha = process.env.COMMIT_SHA;
const actor = process.env.ACTOR;
const serverUrl = process.env.SERVER_URL;
const repository = process.env.REPOSITORY;
const runId = process.env.RUN_ID;
const headerText = isBranch
? `Tests failed against ${refName} branch`
: `Tests failed against ${prNumber} pull request`;
const summaryIcon = isBranch ? 'no_entry' : 'warning';
const summaryText = isBranch
? ' Deployment to FFC environments will not happen until this issue is resolved.'
: ' Please resolve the problem before merging your changes into the main branch.';
const refLink = isBranch
? `*Branch:* ${refName}`
: `*Pull request:* <${serverUrl}/${repository}/pull/${prNumber}|${prNumber}>`;
let failures = 0;
for (const r of recipients) {
// Channel posts mention the actor's Slack ID (a real ping) if resolved;
// otherwise (and for DMs) show the plain GitHub login.
const authorText = r.mention ? `<@${r.mention}>` : actor;
const blocks = [
{ type: 'header', text: { type: 'plain_text', text: `:x: ${headerText}`, emoji: true } },
{ type: 'divider' },
{ type: 'rich_text', elements: [ { type: 'rich_text_section', elements: [
{ type: 'emoji', name: summaryIcon },
{ type: 'text', text: summaryText, style: { bold: true } }
] } ] },
{ type: 'divider' },
{ type: 'section', fields: [
{ type: 'mrkdwn', text: `*Author:* ${authorText}` },
{ type: 'mrkdwn', text: `<${serverUrl}/${repository}/actions/runs/${runId}|View failed workflow>` },
{ type: 'mrkdwn', text: `*Last commit:* <${serverUrl}/${repository}/commit/${commitSha}|${commitSha}>` },
{ type: 'mrkdwn', text: refLink }
] }
];
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json; charset=utf-8'
},
body: JSON.stringify({ channel: r.slack_id, blocks })
});
const data = await res.json();
if (!data.ok) {
core.warning(`Slack chat.postMessage failed for ${r.slack_id}: ${data.error}`);
failures++;
} else {
core.info(`Notified ${r.slack_id} (${r.role})`);
}
}
if (failures > 0 && failures === recipients.length) {
core.setFailed(`All ${failures} Slack notifications failed`);
}