From 3195866cb184af768c056ea1129bf33f10ddd14d Mon Sep 17 00:00:00 2001 From: gjarzebak95 Date: Wed, 7 Jan 2026 09:35:42 +0000 Subject: [PATCH 1/7] fix(SA-675): filter out suspended and archived Google users - Add query parameter to filter suspended users at API level - Add secondary filter in formatUserList for archived users - Include suspended/archived fields in API response - Add tests for suspended/archived user filtering --- src/google.ts | 4 +++- tests/google.spec.ts | 39 ++++++++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/google.ts b/src/google.ts index e7b5c91c7..cb36543b7 100644 --- a/src/google.ts +++ b/src/google.ts @@ -31,7 +31,8 @@ export async function getGithubUsersFromGoogle(): Promise> { customer: 'my_customer', maxResults: 250, projection: 'custom', - fields: 'users(customSchemas/Accounts/github(value))', + query: 'isSuspended=false', + fields: 'users(customSchemas/Accounts/github(value),suspended,archived)', customFieldMask: 'Accounts', }) @@ -43,6 +44,7 @@ export async function getGithubUsersFromGoogle(): Promise> { export function formatUserList(users): Set { return new Set( users + .filter((user) => !user.suspended && !user.archived) .map((user) => user.customSchemas?.Accounts?.github?.map((account) => account.value?.toLowerCase())) .flat() .filter(Boolean), diff --git a/tests/google.spec.ts b/tests/google.spec.ts index 6d04c3c57..dc2fe0aa9 100644 --- a/tests/google.spec.ts +++ b/tests/google.spec.ts @@ -3,19 +3,30 @@ import { google } from 'googleapis' import * as mod from '../src/google' const fakeUsersResponse = [ - { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } } }, + { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } }, suspended: false, archived: false }, { customSchemas: { Accounts: { github: [{ value: 'Foo' }, , { value: 'tar' }] }, }, + suspended: false, + archived: false, }, { customSchemas: { Accounts: { github: [{ value: 'foo' }, { value: 'bar' }] }, }, + suspended: false, + archived: false, }, ] +const fakeUsersResponseWithSuspended = [ + { customSchemas: { Accounts: { github: [{ value: 'activeuser' }] } }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [{ value: 'suspendeduser' }] } }, suspended: true, archived: false }, + { customSchemas: { Accounts: { github: [{ value: 'archiveduser' }] } }, suspended: false, archived: true }, + { customSchemas: { Accounts: { github: [{ value: 'botharchivedandsuspended' }] } }, suspended: true, archived: true }, +] + describe('google integration', () => { beforeEach(() => { process.env.GOOGLE_EMAIL_ADDRESS = 'hello@example.com' @@ -49,12 +60,26 @@ describe('google integration', () => { it('formatUserList bad', () => expect( mod.formatUserList([ - {}, - { customSchemas: {} }, - { customSchemas: { Accounts: {} } }, - { customSchemas: { Accounts: { github: [] } } }, - { customSchemas: { Accounts: { github: [{}] } } }, - { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } } }, + { suspended: false, archived: false }, + { customSchemas: {}, suspended: false, archived: false }, + { customSchemas: { Accounts: {} }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [] } }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [{}] } }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [{ value: 'chrisns' }] } }, suspended: false, archived: false }, ]), ).toMatchSnapshot()) + + it('formatUserList filters out suspended users', () => { + const result = mod.formatUserList(fakeUsersResponseWithSuspended) + expect(result).toEqual(new Set(['activeuser'])) + }) + + it('formatUserList filters out archived users', () => { + const users = [ + { customSchemas: { Accounts: { github: [{ value: 'active' }] } }, suspended: false, archived: false }, + { customSchemas: { Accounts: { github: [{ value: 'archived' }] } }, suspended: false, archived: true }, + ] + const result = mod.formatUserList(users) + expect(result).toEqual(new Set(['active'])) + }) }) From 2cbad6967e5cc2f1831fd66a4a0d6538afe95c8d Mon Sep 17 00:00:00 2001 From: gjarzebak95 Date: Wed, 7 Jan 2026 09:36:43 +0000 Subject: [PATCH 2/7] fix(SA-675): exit 0 when membership changes are successfully applied Previously, any mismatch would trigger non-zero exit even when ADD_USERS and REMOVE_USERS were enabled and changes were made. Now only exits non-zero when mismatch exists AND changes were not configured to be applied (dry-run mode). --- index.ts | 16 +++++++++++++--- tests/index.spec.ts | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/index.ts b/index.ts index ac6237889..32e223f1a 100644 --- a/index.ts +++ b/index.ts @@ -12,17 +12,27 @@ export async function run(): Promise { const usersNotInGithub = new Set(Array.from(googleUsers).filter((x) => !gitHubUsers.has(x))) const usersNotInGoogle = new Set(Array.from(gitHubUsers).filter((x) => !googleUsers.has(x))) + let unfixedMismatch = false + if (usersNotInGithub.size > 0) { console.log(`Users not in github: ${Array.from(usersNotInGithub).join(', ')}`) - if (config.addUsers) await addUsersToGitHubOrg(usersNotInGithub) + if (config.addUsers) { + await addUsersToGitHubOrg(usersNotInGithub) + } else { + unfixedMismatch = true + } } if (usersNotInGoogle.size > 0) { console.log(`Users not in google: ${Array.from(usersNotInGoogle).join(', ')}`) - if (config.removeUsers) await removeUsersFromGitHubOrg(usersNotInGoogle) + if (config.removeUsers) { + await removeUsersFromGitHubOrg(usersNotInGoogle) + } else { + unfixedMismatch = true + } } - const exitCode = usersNotInGoogle.size > 0 || usersNotInGithub.size > 0 ? config.exitCodeOnMissmatch : 0 + const exitCode = unfixedMismatch ? config.exitCodeOnMissmatch : 0 process.exit(exitCode) } diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 0e1aa229a..79e85b9c8 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -30,8 +30,24 @@ describe('missmatch', () => { await mod.run() return expect(processExitSpy).toBeCalledWith(0) }) - it('should exit with 122 if defined when there is a missmatch', async () => { + it('should exit with 122 if defined when there is an unfixed missmatch', async () => { process.env.EXIT_CODE_ON_MISMATCH = '122' + delete process.env.ADD_USERS + delete process.env.REMOVE_USERS + await mod.run() + return expect(processExitSpy).toBeCalledWith(122) + }) + it('should exit with 0 when mismatch is fixed by adding users', async () => { + process.env.EXIT_CODE_ON_MISMATCH = '122' + process.env.ADD_USERS = 'true' + process.env.REMOVE_USERS = 'true' + await mod.run() + return expect(processExitSpy).toBeCalledWith(0) + }) + it('should exit with 122 when only add is enabled but remove mismatch exists', async () => { + process.env.EXIT_CODE_ON_MISMATCH = '122' + process.env.ADD_USERS = 'true' + delete process.env.REMOVE_USERS await mod.run() return expect(processExitSpy).toBeCalledWith(122) }) From 0271c06cbe44be99f527d99850966b131e36b71b Mon Sep 17 00:00:00 2001 From: gjarzebak95 Date: Wed, 7 Jan 2026 09:40:44 +0000 Subject: [PATCH 3/7] feat(SA-675): add error handling and surface API errors - Add OperationError interface to track individual failures - Add OperationResult interface to collect successes and errors - Wrap GitHub API calls in try/catch blocks - Parse specific error codes (422, 403, 404) with helpful messages - Surface max user count errors (422) clearly - Collect all errors and display summary at end of run - Exit with non-zero when errors occur --- index.ts | 24 +++++- src/github.ts | 93 +++++++++++++++----- tests/__snapshots__/github.spec.ts.snap | 50 +---------- tests/__snapshots__/index.spec.ts.snap | 4 +- tests/github.spec.ts | 107 ++++++++++++++++++++---- tests/index.spec.ts | 41 +++++++++ 6 files changed, 228 insertions(+), 91 deletions(-) diff --git a/index.ts b/index.ts index 32e223f1a..cba217006 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,5 @@ import { getGithubUsersFromGoogle } from './src/google' -import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg } from './src/github' +import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg, OperationError } from './src/github' import { config } from './src/config' export async function run(): Promise { @@ -13,11 +13,15 @@ export async function run(): Promise { const usersNotInGoogle = new Set(Array.from(gitHubUsers).filter((x) => !googleUsers.has(x))) let unfixedMismatch = false + const allErrors: OperationError[] = [] if (usersNotInGithub.size > 0) { console.log(`Users not in github: ${Array.from(usersNotInGithub).join(', ')}`) if (config.addUsers) { - await addUsersToGitHubOrg(usersNotInGithub) + const result = await addUsersToGitHubOrg(usersNotInGithub) + if (result.errors.length > 0) { + allErrors.push(...result.errors) + } } else { unfixedMismatch = true } @@ -26,13 +30,25 @@ export async function run(): Promise { if (usersNotInGoogle.size > 0) { console.log(`Users not in google: ${Array.from(usersNotInGoogle).join(', ')}`) if (config.removeUsers) { - await removeUsersFromGitHubOrg(usersNotInGoogle) + const result = await removeUsersFromGitHubOrg(usersNotInGoogle) + if (result.errors.length > 0) { + allErrors.push(...result.errors) + } } else { unfixedMismatch = true } } - const exitCode = unfixedMismatch ? config.exitCodeOnMissmatch : 0 + if (allErrors.length > 0) { + console.error(`\n--- ERRORS SUMMARY ---`) + for (const err of allErrors) { + console.error(`[${err.operation.toUpperCase()}] ${err.user}: ${err.message}`) + } + console.error(`Total errors: ${allErrors.length}`) + } + + const hasErrors = allErrors.length > 0 + const exitCode = unfixedMismatch || hasErrors ? config.exitCodeOnMissmatch : 0 process.exit(exitCode) } diff --git a/src/github.ts b/src/github.ts index ee9afdfba..be1955ade 100644 --- a/src/github.ts +++ b/src/github.ts @@ -2,7 +2,18 @@ import { createAppAuth } from '@octokit/auth-app' import { Octokit } from '@octokit/rest' import * as mod from './github' import { config } from './config' -import { GetResponseTypeFromEndpointMethod } from '@octokit/types' + +export interface OperationError { + user: string + operation: 'add' | 'remove' + message: string + status?: number +} + +export interface OperationResult { + success: string[] + errors: OperationError[] +} export function getAuthenticatedOctokit(): Octokit { return new Octokit({ @@ -57,47 +68,85 @@ export async function getUserIdFromUsername(username: string): Promise { return user.data.id } -export async function addUsersToGitHubOrg(users: Set): Promise { +export async function addUsersToGitHubOrg(users: Set): Promise { + const result: OperationResult = { success: [], errors: [] } for (const user of users) { - await mod.addUserToGitHubOrg(user) + const outcome = await mod.addUserToGitHubOrg(user) + if (outcome === true) { + result.success.push(user) + } else if (outcome !== false && 'error' in outcome) { + result.errors.push(outcome.error) + } } + return result } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export async function addUserToGitHubOrg( - user: string, -): Promise | boolean> { +export async function addUserToGitHubOrg(user: string): Promise<{ error: OperationError } | boolean> { const octokit = mod.getAuthenticatedOctokit() if (config.ignoredUsers.includes(user.toLowerCase())) { console.log(`Ignoring add for ${user}`) return false } - const userId = await mod.getUserIdFromUsername(user) - console.log(`Inviting ${user} (${userId} to ${config.githubOrg})`) - return await octokit.orgs.createInvitation({ - org: config.githubOrg, - invitee_id: userId, - }) + try { + const userId = await mod.getUserIdFromUsername(user) + console.log(`Inviting ${user} (${userId}) to ${config.githubOrg}`) + await octokit.orgs.createInvitation({ + org: config.githubOrg, + invitee_id: userId, + }) + return true + } catch (error) { + const status = error?.status || error?.response?.status + let message = error?.message || String(error) + if (status === 422) { + message = `Validation failed: ${message} (user may already be invited, or org is at max capacity)` + } else if (status === 404) { + message = `User not found: ${user}` + } else if (status === 403) { + message = `Permission denied or rate limited` + } + console.error(`Error adding ${user}: ${message}`) + return { error: { user, operation: 'add', message, status } } + } } -export async function removeUsersFromGitHubOrg(users: Set): Promise { +export async function removeUsersFromGitHubOrg(users: Set): Promise { + const result: OperationResult = { success: [], errors: [] } for (const user of users) { - await mod.removeUserFromGitHubOrg(user) + const outcome = await mod.removeUserFromGitHubOrg(user) + if (outcome === true) { + result.success.push(user) + } else if (outcome !== false && 'error' in outcome) { + result.errors.push(outcome.error) + } } + return result } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export async function removeUserFromGitHubOrg( - user: string, -): Promise | boolean> { +export async function removeUserFromGitHubOrg(user: string): Promise<{ error: OperationError } | boolean> { const octokit = mod.getAuthenticatedOctokit() if (config.ignoredUsers.includes(user.toLowerCase())) { console.log(`Ignoring remove for ${user}`) return false } - console.log(`Removing user/invitation ${user} from ${config.githubOrg}`) - return octokit.orgs.removeMembershipForUser({ - org: config.githubOrg, - username: user, - }) + try { + console.log(`Removing user/invitation ${user} from ${config.githubOrg}`) + await octokit.orgs.removeMembershipForUser({ + org: config.githubOrg, + username: user, + }) + return true + } catch (error) { + const status = error?.status || error?.response?.status + let message = error?.message || String(error) + if (status === 404) { + message = `User not found or not a member: ${user}` + } else if (status === 403) { + message = `Permission denied or rate limited` + } + console.error(`Error removing ${user}: ${message}`) + return { error: { user, operation: 'remove', message, status } } + } } diff --git a/tests/__snapshots__/github.spec.ts.snap b/tests/__snapshots__/github.spec.ts.snap index 497741c1e..5d04de588 100644 --- a/tests/__snapshots__/github.spec.ts.snap +++ b/tests/__snapshots__/github.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`github integration addUserToGitHubOrg 1`] = ` +exports[`github integration addUserToGitHubOrg success 1`] = ` [MockFunction] { "calls": Array [ Array [ @@ -19,29 +19,6 @@ exports[`github integration addUserToGitHubOrg 1`] = ` } `; -exports[`github integration addUsersToGitHubOrg 1`] = ` -[MockFunction] { - "calls": Array [ - Array [ - "foo", - ], - Array [ - "bar", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`; - exports[`github integration getAuthenticatedOctokit 1`] = ` [MockFunction] { "calls": Array [ @@ -79,7 +56,7 @@ exports[`github integration getUserIdFromUsername found 1`] = `123`; exports[`github integration getUserIdFromUsername notfound 1`] = `"Unable to find user id for foo"`; -exports[`github integration removeUserFromGitHubOrg 1`] = ` +exports[`github integration removeUserFromGitHubOrg success 1`] = ` [MockFunction] { "calls": Array [ Array [ @@ -97,26 +74,3 @@ exports[`github integration removeUserFromGitHubOrg 1`] = ` ], } `; - -exports[`github integration removeUsersFromGitHubOrg 1`] = ` -[MockFunction] { - "calls": Array [ - Array [ - "foo", - ], - Array [ - "bar", - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], -} -`; diff --git a/tests/__snapshots__/index.spec.ts.snap b/tests/__snapshots__/index.spec.ts.snap index 457d9a6ea..0d17066ee 100644 --- a/tests/__snapshots__/index.spec.ts.snap +++ b/tests/__snapshots__/index.spec.ts.snap @@ -35,7 +35,7 @@ exports[`missmatch should add users if set to 1`] = ` "results": Array [ Object { "type": "return", - "value": undefined, + "value": Promise {}, }, ], } @@ -128,7 +128,7 @@ exports[`missmatch should remove users if set to 1`] = ` "results": Array [ Object { "type": "return", - "value": undefined, + "value": Promise {}, }, ], } diff --git a/tests/github.spec.ts b/tests/github.spec.ts index 51c08ae6c..8575d2dc2 100644 --- a/tests/github.spec.ts +++ b/tests/github.spec.ts @@ -9,6 +9,7 @@ describe('github integration', () => { jest.spyOn(config, 'githubAppID', 'get').mockReturnValue(123) jest.spyOn(config, 'githubInstallationID', 'get').mockReturnValue(123) jest.spyOn(global.console, 'log').mockImplementation() + jest.spyOn(global.console, 'error').mockImplementation() }) it('getAuthenticatedOctokit', () => { @@ -49,18 +50,40 @@ describe('github integration', () => { return expect(mod.getUserIdFromUsername('foo')).rejects.toMatchSnapshot() }) - it('addUsersToGitHubOrg', async () => { + it('addUsersToGitHubOrg collects successes', async () => { const users = new Set(['foo', 'bar']) - jest.spyOn(mod, 'addUserToGitHubOrg').mockResolvedValue(false) - await mod.addUsersToGitHubOrg(users) - return expect(mod.addUserToGitHubOrg).toMatchSnapshot() + jest.spyOn(mod, 'addUserToGitHubOrg').mockResolvedValue(true) + const result = await mod.addUsersToGitHubOrg(users) + expect(result.success).toEqual(['foo', 'bar']) + expect(result.errors).toEqual([]) }) - it('removeUsersFromGitHubOrg', async () => { + it('addUsersToGitHubOrg collects errors', async () => { const users = new Set(['foo', 'bar']) - jest.spyOn(mod, 'removeUserFromGitHubOrg').mockResolvedValue(false) - await mod.removeUsersFromGitHubOrg(users) - return expect(mod.removeUserFromGitHubOrg).toMatchSnapshot() + jest.spyOn(mod, 'addUserToGitHubOrg').mockResolvedValue({ + error: { user: 'foo', operation: 'add', message: 'test error', status: 422 }, + }) + const result = await mod.addUsersToGitHubOrg(users) + expect(result.success).toEqual([]) + expect(result.errors.length).toBe(2) + }) + + it('removeUsersFromGitHubOrg collects successes', async () => { + const users = new Set(['foo', 'bar']) + jest.spyOn(mod, 'removeUserFromGitHubOrg').mockResolvedValue(true) + const result = await mod.removeUsersFromGitHubOrg(users) + expect(result.success).toEqual(['foo', 'bar']) + expect(result.errors).toEqual([]) + }) + + it('removeUsersFromGitHubOrg collects errors', async () => { + const users = new Set(['foo', 'bar']) + jest.spyOn(mod, 'removeUserFromGitHubOrg').mockResolvedValue({ + error: { user: 'foo', operation: 'remove', message: 'test error', status: 404 }, + }) + const result = await mod.removeUsersFromGitHubOrg(users) + expect(result.success).toEqual([]) + expect(result.errors.length).toBe(2) }) it('removeUserFromGitHubOrg skip ignore', () => { @@ -73,7 +96,7 @@ describe('github integration', () => { expect(mod.addUserToGitHubOrg('foo')).resolves.toBe(false) }) - it('addUserToGitHubOrg', async () => { + it('addUserToGitHubOrg success', async () => { const fakeOctokit = { orgs: { createInvitation: jest.fn().mockResolvedValue(true), @@ -83,22 +106,76 @@ describe('github integration', () => { jest.spyOn(mod, 'getUserIdFromUsername').mockResolvedValue(123) // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await mod.addUserToGitHubOrg('foo') - return expect(fakeOctokit.orgs.createInvitation).toMatchSnapshot() + const result = await mod.addUserToGitHubOrg('foo') + expect(result).toBe(true) + expect(fakeOctokit.orgs.createInvitation).toMatchSnapshot() }) - it('removeUserFromGitHubOrg', async () => { + it('addUserToGitHubOrg handles 422 error (org full)', async () => { const fakeOctokit = { orgs: { - removeMembershipForUser: jest.fn().mockResolvedValue(true), + createInvitation: jest.fn().mockRejectedValue({ status: 422, message: 'Validation Failed' }), + }, + } + jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + jest.spyOn(mod, 'getUserIdFromUsername').mockResolvedValue(123) + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + const result = await mod.addUserToGitHubOrg('foo') + expect(result).toHaveProperty('error') + // @ts-expect-error we know it has error + expect(result.error.status).toBe(422) + // @ts-expect-error we know it has error + expect(result.error.message).toContain('org is at max capacity') + }) + + it('addUserToGitHubOrg handles 403 error (rate limit)', async () => { + const fakeOctokit = { + orgs: { + createInvitation: jest.fn().mockRejectedValue({ status: 403, message: 'Forbidden' }), }, } jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') jest.spyOn(mod, 'getUserIdFromUsername').mockResolvedValue(123) // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) - await mod.removeUserFromGitHubOrg('foo') - return expect(fakeOctokit.orgs.removeMembershipForUser).toMatchSnapshot() + const result = await mod.addUserToGitHubOrg('foo') + expect(result).toHaveProperty('error') + // @ts-expect-error we know it has error + expect(result.error.status).toBe(403) + // @ts-expect-error we know it has error + expect(result.error.message).toContain('rate limited') + }) + + it('removeUserFromGitHubOrg success', async () => { + const fakeOctokit = { + orgs: { + removeMembershipForUser: jest.fn().mockResolvedValue(true), + }, + } + jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + const result = await mod.removeUserFromGitHubOrg('foo') + expect(result).toBe(true) + expect(fakeOctokit.orgs.removeMembershipForUser).toMatchSnapshot() + }) + + it('removeUserFromGitHubOrg handles 404 error', async () => { + const fakeOctokit = { + orgs: { + removeMembershipForUser: jest.fn().mockRejectedValue({ status: 404, message: 'Not Found' }), + }, + } + jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + jest.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + const result = await mod.removeUserFromGitHubOrg('foo') + expect(result).toHaveProperty('error') + // @ts-expect-error we know it has error + expect(result.error.status).toBe(404) + // @ts-expect-error we know it has error + expect(result.error.message).toContain('not a member') }) it('formatUserList', () => { diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 79e85b9c8..8269cc98f 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -6,12 +6,18 @@ import * as mod from '../index' let processExitSpy let consoleSpy +let consoleErrorSpy beforeEach(() => { processExitSpy = jest.spyOn(global.process, 'exit').mockImplementation(() => { return undefined as never }) consoleSpy = jest.spyOn(global.console, 'log').mockImplementation() + consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation() + // @ts-expect-error mockResolved unexpected + github.addUsersToGitHubOrg.mockResolvedValue({ success: [], errors: [] }) + // @ts-expect-error mockResolved unexpected + github.removeUsersFromGitHubOrg.mockResolvedValue({ success: [], errors: [] }) }) describe('missmatch', () => { @@ -110,3 +116,38 @@ describe('match', () => { return expect(github.removeUsersFromGitHubOrg).not.toBeCalled() }) }) + +describe('error handling', () => { + beforeEach(() => { + // @ts-expect-error mockResolved unexpected + google.getGithubUsersFromGoogle.mockResolvedValue(new Set(['a', 'd'])) + // @ts-expect-error mockResolved unexpected + github.getGithubUsersFromGithub.mockResolvedValue(new Set(['b', 'c', 'a'])) + }) + + it('should exit with non-zero when errors occur', async () => { + process.env.EXIT_CODE_ON_MISMATCH = '1' + process.env.ADD_USERS = 'true' + process.env.REMOVE_USERS = 'true' + // @ts-expect-error mockResolved unexpected + github.addUsersToGitHubOrg.mockResolvedValue({ + success: [], + errors: [{ user: 'd', operation: 'add', message: 'org full', status: 422 }], + }) + await mod.run() + expect(processExitSpy).toBeCalledWith(1) + }) + + it('should output error summary when errors occur', async () => { + process.env.ADD_USERS = 'true' + process.env.REMOVE_USERS = 'true' + // @ts-expect-error mockResolved unexpected + github.addUsersToGitHubOrg.mockResolvedValue({ + success: [], + errors: [{ user: 'd', operation: 'add', message: 'org full', status: 422 }], + }) + await mod.run() + expect(consoleErrorSpy).toHaveBeenCalledWith('\n--- ERRORS SUMMARY ---') + expect(consoleErrorSpy).toHaveBeenCalledWith('[ADD] d: org full') + }) +}) From 1dfb02fd71a5b7279083b55808a145d7bbd32f33 Mon Sep 17 00:00:00 2001 From: gjarzebak95 Date: Wed, 7 Jan 2026 09:44:59 +0000 Subject: [PATCH 4/7] feat(SA-675): add Slack notifications for membership changes - Add slack.ts module with notification functionality - Add config options: SLACK_WEBHOOK_URL, SLACK_NOTIFY_ON_ERROR, SLACK_NOTIFY_ON_CHANGE, SLACK_NOTIFY_ALWAYS - Update action.yml with new Slack inputs - Send notifications on errors (default), changes (opt-in), or always - Format messages with users added, removed, and any errors --- action.yml | 23 +++++++ index.ts | 7 +++ src/config.ts | 12 ++++ src/slack.ts | 141 +++++++++++++++++++++++++++++++++++++++++ tests/index.spec.ts | 2 + tests/slack.spec.ts | 150 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 335 insertions(+) create mode 100644 src/slack.ts create mode 100644 tests/slack.spec.ts diff --git a/action.yml b/action.yml index e666faf1b..edf970e14 100644 --- a/action.yml +++ b/action.yml @@ -41,6 +41,21 @@ inputs: github-actor: description: github actor to use to pull the docker image github.actor is probably fine required: true + slack-webhook-url: + description: 'Slack webhook URL for notifications' + required: false + slack-notify-on-error: + description: 'Send Slack notification when errors occur (default: true)' + required: false + default: 'true' + slack-notify-on-change: + description: 'Send Slack notification when membership changes are made' + required: false + default: 'false' + slack-notify-always: + description: 'Always send Slack notification regardless of changes' + required: false + default: 'false' runs: using: "composite" steps: @@ -63,6 +78,10 @@ runs: -e GITHUB_INSTALLATION_ID="$GITHUB_INSTALLATION_ID" \ -e GITHUB_PRIVATE_KEY="$GITHUB_PRIVATE_KEY" \ -e IGNORED_USERS="$IGNORED_USERS" \ + -e SLACK_WEBHOOK_URL="$SLACK_WEBHOOK_URL" \ + -e SLACK_NOTIFY_ON_ERROR="$SLACK_NOTIFY_ON_ERROR" \ + -e SLACK_NOTIFY_ON_CHANGE="$SLACK_NOTIFY_ON_CHANGE" \ + -e SLACK_NOTIFY_ALWAYS="$SLACK_NOTIFY_ALWAYS" \ docker.pkg.github.com/appvia/githubusermanager/githubusermanager:v1.0.5 shell: bash env: @@ -76,4 +95,8 @@ runs: GITHUB_INSTALLATION_ID: ${{ inputs.github-installation-id }} GITHUB_PRIVATE_KEY: ${{ inputs.github-private-key }} IGNORED_USERS: ${{ inputs.ignored-users }} + SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }} + SLACK_NOTIFY_ON_ERROR: ${{ inputs.slack-notify-on-error }} + SLACK_NOTIFY_ON_CHANGE: ${{ inputs.slack-notify-on-change }} + SLACK_NOTIFY_ALWAYS: ${{ inputs.slack-notify-always }} diff --git a/index.ts b/index.ts index cba217006..7dec25329 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,7 @@ import { getGithubUsersFromGoogle } from './src/google' import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg, OperationError } from './src/github' import { config } from './src/config' +import { notifySlack } from './src/slack' export async function run(): Promise { const googleUsers = await getGithubUsersFromGoogle() @@ -14,11 +15,14 @@ export async function run(): Promise { const usersNotInGoogle = new Set(Array.from(gitHubUsers).filter((x) => !googleUsers.has(x))) let unfixedMismatch = false const allErrors: OperationError[] = [] + const addedUsers: string[] = [] + const removedUsers: string[] = [] if (usersNotInGithub.size > 0) { console.log(`Users not in github: ${Array.from(usersNotInGithub).join(', ')}`) if (config.addUsers) { const result = await addUsersToGitHubOrg(usersNotInGithub) + addedUsers.push(...result.success) if (result.errors.length > 0) { allErrors.push(...result.errors) } @@ -31,6 +35,7 @@ export async function run(): Promise { console.log(`Users not in google: ${Array.from(usersNotInGoogle).join(', ')}`) if (config.removeUsers) { const result = await removeUsersFromGitHubOrg(usersNotInGoogle) + removedUsers.push(...result.success) if (result.errors.length > 0) { allErrors.push(...result.errors) } @@ -47,6 +52,8 @@ export async function run(): Promise { console.error(`Total errors: ${allErrors.length}`) } + await notifySlack(addedUsers, removedUsers, allErrors, config.githubOrg) + const hasErrors = allErrors.length > 0 const exitCode = unfixedMismatch || hasErrors ? config.exitCodeOnMissmatch : 0 diff --git a/src/config.ts b/src/config.ts index 8dda2f379..ae74fd008 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,6 +29,18 @@ export const config = { get googleEmailAddress(): string { return process.env.GOOGLE_EMAIL_ADDRESS ?? '' }, + get slackWebhookUrl(): string | undefined { + return process.env.SLACK_WEBHOOK_URL + }, + get slackNotifyOnError(): boolean { + return process.env.SLACK_NOTIFY_ON_ERROR?.toLowerCase() !== 'false' + }, + get slackNotifyOnChange(): boolean { + return process.env.SLACK_NOTIFY_ON_CHANGE?.toLowerCase() === 'true' + }, + get slackNotifyAlways(): boolean { + return process.env.SLACK_NOTIFY_ALWAYS?.toLowerCase() === 'true' + }, } export interface googleCredentials { diff --git a/src/slack.ts b/src/slack.ts new file mode 100644 index 000000000..e1356bb84 --- /dev/null +++ b/src/slack.ts @@ -0,0 +1,141 @@ +import * as https from 'https' +import { URL } from 'url' +import { config } from './config' +import { OperationError } from './github' + +export interface SlackMessage { + text: string + blocks?: SlackBlock[] +} + +export interface SlackBlock { + type: string + text?: { type: string; text: string } + elements?: { type: string; text: string }[] +} + +export async function sendSlackNotification(message: SlackMessage): Promise { + const webhookUrl = config.slackWebhookUrl + if (!webhookUrl) { + return false + } + + return new Promise((resolve) => { + try { + const url = new URL(webhookUrl) + const postData = JSON.stringify(message) + + const options = { + hostname: url.hostname, + port: 443, + path: url.pathname, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + }, + } + + const req = https.request(options, (res) => { + if (res.statusCode === 200) { + resolve(true) + } else { + console.error(`Slack notification failed: ${res.statusCode}`) + resolve(false) + } + }) + + req.on('error', (error) => { + console.error(`Slack notification error: ${error}`) + resolve(false) + }) + + req.write(postData) + req.end() + } catch (error) { + console.error(`Slack notification error: ${error}`) + resolve(false) + } + }) +} + +export function formatMembershipUpdate( + added: string[], + removed: string[], + errors: OperationError[], + org: string, +): SlackMessage { + const hasChanges = added.length > 0 || removed.length > 0 + const hasErrors = errors.length > 0 + + let emoji = ':white_check_mark:' + if (hasErrors && !hasChanges) { + emoji = ':x:' + } else if (hasErrors) { + emoji = ':warning:' + } + + const blocks: SlackBlock[] = [ + { + type: 'header', + text: { type: 'plain_text', text: `${emoji} GitHub Org Sync: ${org}` }, + }, + ] + + if (added.length > 0) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `*Users Added:* ${added.join(', ')}` }, + }) + } + + if (removed.length > 0) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `*Users Removed:* ${removed.join(', ')}` }, + }) + } + + if (errors.length > 0) { + const errorText = errors.map((e) => `• [${e.operation}] ${e.user}: ${e.message}`).join('\n') + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: `*Errors:*\n${errorText}` }, + }) + } + + if (!hasChanges && !hasErrors) { + blocks.push({ + type: 'section', + text: { type: 'mrkdwn', text: 'No changes required - all users in sync.' }, + }) + } + + return { + text: `GitHub Org Sync: ${added.length} added, ${removed.length} removed, ${errors.length} errors`, + blocks, + } +} + +export async function notifySlack( + added: string[], + removed: string[], + errors: OperationError[], + org: string, +): Promise { + const hasChanges = added.length > 0 || removed.length > 0 + const hasErrors = errors.length > 0 + + const shouldNotify = + (hasErrors && config.slackNotifyOnError) || (hasChanges && config.slackNotifyOnChange) || config.slackNotifyAlways + + if (!shouldNotify) { + return + } + + const message = formatMembershipUpdate(added, removed, errors, org) + const sent = await sendSlackNotification(message) + if (sent) { + console.log('Slack notification sent') + } +} diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 8269cc98f..64fb1ada7 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -1,7 +1,9 @@ jest.mock('../src/google') jest.mock('../src/github') +jest.mock('../src/slack') import * as google from '../src/google' import * as github from '../src/github' +import * as slack from '../src/slack' import * as mod from '../index' let processExitSpy diff --git a/tests/slack.spec.ts b/tests/slack.spec.ts new file mode 100644 index 000000000..ed9060c35 --- /dev/null +++ b/tests/slack.spec.ts @@ -0,0 +1,150 @@ +jest.mock('https') +import * as https from 'https' +import { config } from '../src/config' +import * as mod from '../src/slack' +import { EventEmitter } from 'events' + +describe('slack integration', () => { + let consoleSpy: jest.SpyInstance + let consoleErrorSpy: jest.SpyInstance + + beforeEach(() => { + consoleSpy = jest.spyOn(global.console, 'log').mockImplementation() + consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('sendSlackNotification', () => { + it('returns false when no webhook URL configured', async () => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue(undefined) + const result = await mod.sendSlackNotification({ text: 'test' }) + expect(result).toBe(false) + }) + + it('sends notification when webhook URL is configured', async () => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + const mockReq = new EventEmitter() as any + mockReq.write = jest.fn() + mockReq.end = jest.fn() + const mockRes = new EventEmitter() as any + mockRes.statusCode = 200 + ;(https.request as jest.Mock).mockImplementation((options, callback) => { + setTimeout(() => callback(mockRes), 0) + return mockReq + }) + const result = await mod.sendSlackNotification({ text: 'test' }) + expect(result).toBe(true) + }) + + it('returns false when request fails with non-200', async () => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + const mockReq = new EventEmitter() as any + mockReq.write = jest.fn() + mockReq.end = jest.fn() + const mockRes = new EventEmitter() as any + mockRes.statusCode = 500 + ;(https.request as jest.Mock).mockImplementation((options, callback) => { + setTimeout(() => callback(mockRes), 0) + return mockReq + }) + const result = await mod.sendSlackNotification({ text: 'test' }) + expect(result).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalled() + }) + + it('returns false when request errors', async () => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + const mockReq = new EventEmitter() as any + mockReq.write = jest.fn() + mockReq.end = jest.fn() + ;(https.request as jest.Mock).mockImplementation(() => { + setTimeout(() => mockReq.emit('error', new Error('Network error')), 0) + return mockReq + }) + const result = await mod.sendSlackNotification({ text: 'test' }) + expect(result).toBe(false) + expect(consoleErrorSpy).toHaveBeenCalled() + }) + }) + + describe('formatMembershipUpdate', () => { + it('formats message with added users', () => { + const message = mod.formatMembershipUpdate(['user1', 'user2'], [], [], 'myorg') + expect(message.text).toContain('2 added') + expect(message.blocks).toBeDefined() + expect(JSON.stringify(message.blocks)).toContain('user1, user2') + }) + + it('formats message with removed users', () => { + const message = mod.formatMembershipUpdate([], ['user1'], [], 'myorg') + expect(message.text).toContain('1 removed') + expect(JSON.stringify(message.blocks)).toContain('user1') + }) + + it('formats message with errors', () => { + const errors = [{ user: 'baduser', operation: 'add' as const, message: 'failed', status: 422 }] + const message = mod.formatMembershipUpdate([], [], errors, 'myorg') + expect(message.text).toContain('1 errors') + expect(JSON.stringify(message.blocks)).toContain('baduser') + }) + + it('formats message when no changes', () => { + const message = mod.formatMembershipUpdate([], [], [], 'myorg') + expect(JSON.stringify(message.blocks)).toContain('No changes required') + }) + }) + + describe('notifySlack', () => { + let mockReq: any + + beforeEach(() => { + jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + mockReq = new EventEmitter() as any + mockReq.write = jest.fn() + mockReq.end = jest.fn() + const mockRes = new EventEmitter() as any + mockRes.statusCode = 200 + ;(https.request as jest.Mock).mockImplementation((options, callback) => { + setTimeout(() => callback(mockRes), 0) + return mockReq + }) + }) + + it('does not send when no changes and slackNotifyAlways is false', async () => { + jest.spyOn(config, 'slackNotifyOnError', 'get').mockReturnValue(true) + jest.spyOn(config, 'slackNotifyOnChange', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyAlways', 'get').mockReturnValue(false) + await mod.notifySlack([], [], [], 'myorg') + expect(https.request).not.toHaveBeenCalled() + }) + + it('sends when slackNotifyAlways is true', async () => { + jest.spyOn(config, 'slackNotifyOnError', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyOnChange', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyAlways', 'get').mockReturnValue(true) + await mod.notifySlack([], [], [], 'myorg') + expect(https.request).toHaveBeenCalled() + }) + + it('sends when errors occur and slackNotifyOnError is true', async () => { + jest.spyOn(config, 'slackNotifyOnError', 'get').mockReturnValue(true) + jest.spyOn(config, 'slackNotifyOnChange', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyAlways', 'get').mockReturnValue(false) + const errors = [{ user: 'baduser', operation: 'add' as const, message: 'failed', status: 422 }] + await mod.notifySlack([], [], errors, 'myorg') + expect(https.request).toHaveBeenCalled() + }) + + it('sends when changes occur and slackNotifyOnChange is true', async () => { + jest.spyOn(config, 'slackNotifyOnError', 'get').mockReturnValue(false) + jest.spyOn(config, 'slackNotifyOnChange', 'get').mockReturnValue(true) + jest.spyOn(config, 'slackNotifyAlways', 'get').mockReturnValue(false) + await mod.notifySlack(['newuser'], [], [], 'myorg') + expect(https.request).toHaveBeenCalled() + }) + }) +}) From 9e4ae4024b36440bc338350ad1d98fa56e2419fa Mon Sep 17 00:00:00 2001 From: gjarzebak95 Date: Wed, 7 Jan 2026 09:47:14 +0000 Subject: [PATCH 5/7] chore(SA-675): update Node.js to LTS 18.20.0 Node 15.12.0 was EOL. Updated to Node 18 LTS for security and compatibility. Major npm package updates (octokit, jest, eslint) require careful testing due to breaking API changes and should be addressed in a follow-up PR. --- .github/workflows/ci.yml | 2 +- .nvmrc | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d47438f5..a63f715bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: - node-version: 15.12.0 + node-version: 18.20.0 - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 with: diff --git a/.nvmrc b/.nvmrc index fac0b0a83..f4e1dd5b0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.20.0 +18.20.0 From 023e1c944cc7b4276033f5d5f5e795e9aefd7219 Mon Sep 17 00:00:00 2001 From: gjarzebak95 Date: Wed, 7 Jan 2026 09:54:21 +0000 Subject: [PATCH 6/7] chore(SA-675): fix lint warnings in test files - Remove unused slack import from index.spec.ts - Remove unused consoleSpy variable from slack.spec.ts - Add eslint-disable comments for necessary any types in test mocks --- tests/index.spec.ts | 1 - tests/slack.spec.ts | 11 +++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/index.spec.ts b/tests/index.spec.ts index 64fb1ada7..d8851ad41 100644 --- a/tests/index.spec.ts +++ b/tests/index.spec.ts @@ -3,7 +3,6 @@ jest.mock('../src/github') jest.mock('../src/slack') import * as google from '../src/google' import * as github from '../src/github' -import * as slack from '../src/slack' import * as mod from '../index' let processExitSpy diff --git a/tests/slack.spec.ts b/tests/slack.spec.ts index ed9060c35..e2d15f7ca 100644 --- a/tests/slack.spec.ts +++ b/tests/slack.spec.ts @@ -5,11 +5,10 @@ import * as mod from '../src/slack' import { EventEmitter } from 'events' describe('slack integration', () => { - let consoleSpy: jest.SpyInstance let consoleErrorSpy: jest.SpyInstance beforeEach(() => { - consoleSpy = jest.spyOn(global.console, 'log').mockImplementation() + jest.spyOn(global.console, 'log').mockImplementation() consoleErrorSpy = jest.spyOn(global.console, 'error').mockImplementation() }) @@ -26,9 +25,11 @@ describe('slack integration', () => { it('sends notification when webhook URL is configured', async () => { jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockReq = new EventEmitter() as any mockReq.write = jest.fn() mockReq.end = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockRes = new EventEmitter() as any mockRes.statusCode = 200 ;(https.request as jest.Mock).mockImplementation((options, callback) => { @@ -41,9 +42,11 @@ describe('slack integration', () => { it('returns false when request fails with non-200', async () => { jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockReq = new EventEmitter() as any mockReq.write = jest.fn() mockReq.end = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockRes = new EventEmitter() as any mockRes.statusCode = 500 ;(https.request as jest.Mock).mockImplementation((options, callback) => { @@ -57,6 +60,7 @@ describe('slack integration', () => { it('returns false when request errors', async () => { jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') + // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockReq = new EventEmitter() as any mockReq.write = jest.fn() mockReq.end = jest.fn() @@ -98,14 +102,17 @@ describe('slack integration', () => { }) describe('notifySlack', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockReq: any beforeEach(() => { jest.spyOn(config, 'slackWebhookUrl', 'get').mockReturnValue('https://hooks.slack.com/services/test') jest.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + // eslint-disable-next-line @typescript-eslint/no-explicit-any mockReq = new EventEmitter() as any mockReq.write = jest.fn() mockReq.end = jest.fn() + // eslint-disable-next-line @typescript-eslint/no-explicit-any const mockRes = new EventEmitter() as any mockRes.statusCode = 200 ;(https.request as jest.Mock).mockImplementation((options, callback) => { From 386e9d1c5815b0a6c82dabf8878744aeed1e9b3a Mon Sep 17 00:00:00 2001 From: gjarzebak95 Date: Fri, 9 Jan 2026 18:14:05 +0000 Subject: [PATCH 7/7] chore(SA-675): update deprecated GitHub Actions to v4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout: v3.5.2 → v4.2.2 - actions/setup-node: v3.6.0 → v4.1.0 - actions/cache: v3.3.1 → v4.2.3 Fixes CI failure due to deprecated actions/cache version being blocked by GitHub as of Dec 2024. --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a63f715bc..4c9c2052e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 + - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 with: node-version: 18.20.0 - - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 with: path: node_modules key: npm-${{ hashFiles('package-lock.json') }}