diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 1bc32d7..f007b67 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -22,7 +22,12 @@ jobs: - shell: bash env: ADOBE_RUNTIME_AUTH: ${{ secrets.ADOBE_RUNTIME_AUTH }} + SIGN_REFRESH_TOKEN: ${{ secrets.SIGN_REFRESH_TOKEN }} + SIGN_CLIENT_ID: ${{ secrets.SIGN_CLIENT_ID }} + SIGN_CLIENT_SECRET: ${{ secrets.SIGN_CLIENT_SECRET }} + APP_ID_GITHUB: ${{ secrets.APP_ID_GITHUB }} + APP_KEY_GITHUB: ${{ secrets.APP_KEY_GITHUB }} run: | - bash deploy.sh lookup production && bash deploy.sh setgithubcheck production && bash deploy.sh checker production && bash deploy.sh signwebhook production + bash deploy.sh lookup production && bash deploy.sh setgithubcheck production && bash deploy.sh checker production && bash deploy.sh signwebhook production && bash deploy.sh receiver production # - run: npm run test:integration, add this back later - run: echo "🍏 This job's status is ${{ job.status }}." diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 2284cfe..2340fa8 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -22,7 +22,12 @@ jobs: - shell: bash env: ADOBE_RUNTIME_AUTH: ${{ secrets.ADOBE_RUNTIME_AUTH }} + SIGN_REFRESH_TOKEN: ${{ secrets.SIGN_REFRESH_TOKEN }} + SIGN_CLIENT_ID: ${{ secrets.SIGN_CLIENT_ID }} + SIGN_CLIENT_SECRET: ${{ secrets.SIGN_CLIENT_SECRET }} + APP_ID_GITHUB: ${{ secrets.APP_ID_GITHUB }} + APP_KEY_GITHUB: ${{ secrets.APP_KEY_GITHUB }} run: | - bash deploy.sh lookup && bash deploy.sh setgithubcheck && bash deploy.sh checker && bash deploy.sh signwebhook + bash deploy.sh lookup && bash deploy.sh setgithubcheck && bash deploy.sh checker && bash deploy.sh signwebhook && bash deploy.sh receiver # - run: npm run test:integration, add this back later - run: echo "🍏 This job's status is ${{ job.status }}." diff --git a/deploy.sh b/deploy.sh index 9b399f7..ccdea3e 100755 --- a/deploy.sh +++ b/deploy.sh @@ -8,9 +8,9 @@ then echo "Missing action name as first parameter to script, exiting" exit 1 fi -if ! [[ "$ACTION" =~ ^(checker|lookup|setgithubcheck|signwebhook)$ ]] +if ! [[ "$ACTION" =~ ^(checker|lookup|setgithubcheck|signwebhook|receiver)$ ]] then - echo "Action name must be one of 'checker', 'lookup', 'setgithubcheck' or 'signwebhook', exiting" + echo "Action name must be one of 'checker', 'lookup', 'setgithubcheck', 'signwebhook' or 'receiver', exiting" exit 2 fi ENV="$2" @@ -54,12 +54,35 @@ then fi rm dist/*.bak -# get the config file and repackage to redeploy -$WSK action get "${ACTION_NAME}" --save --apihost adobeioruntime.net --auth "${ADOBE_RUNTIME_AUTH}" -mkdir previous -unzip "${ACTION_NAME}.zip" -q -d previous -cp previous/config.json dist/. -rm -rf "${ACTION_NAME}.zip" previous/ +# build config.json from env vars; the receiver reads no config so it gets an empty one +if [ "${ACTION}" = "receiver" ] +then + echo '{}' > dist/config.json +else + # bail if anything is missing so we don't deploy a half-empty config + missing="" + for v in SIGN_REFRESH_TOKEN SIGN_CLIENT_ID SIGN_CLIENT_SECRET APP_KEY_GITHUB APP_ID_GITHUB + do + eval "val=\$${v}" + if [ -z "${val}" ] + then + missing="${missing} ${v}" + fi + done + if [ -n "${missing}" ] + then + echo "Missing required env vars for ${ACTION_NAME} config:${missing}" + exit 3 + fi + jq -n \ + --arg signRefreshToken "${SIGN_REFRESH_TOKEN}" \ + --arg signClientID "${SIGN_CLIENT_ID}" \ + --arg signClientSecret "${SIGN_CLIENT_SECRET}" \ + --arg githubKey "${APP_KEY_GITHUB}" \ + --arg githubAppId "${APP_ID_GITHUB}" \ + '{signRefreshToken:$signRefreshToken, signClientID:$signClientID, signClientSecret:$signClientSecret, githubKey:$githubKey, githubAppId:$githubAppId}' \ + > dist/config.json +fi pushd dist echo "dist/ content listing:" diff --git a/package.json b/package.json index 050b842..358b081 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "checker/checker.js", "scripts": { "test": "npm run lint && npm run test:unit", - "lint": "eslint checker lookup setgithubcheck signwebhook test", + "lint": "eslint checker lookup setgithubcheck signwebhook receiver test", "test:unit": "NODE_ENV=test jest --config=test/unit/jest.config.js", "test:integration": "NODE_ENV=test jest --config=test/integration/jest.config.js", "test:coverage": "nyc npm run test:unit", diff --git a/receiver/package.json b/receiver/package.json new file mode 100644 index 0000000..31996f3 --- /dev/null +++ b/receiver/package.json @@ -0,0 +1,17 @@ +{ + "name": "receiver", + "version": "1.0.0", + "description": "cla-bot thin GitHub webhook receiver for adobe", + "main": "./receiver.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node receiver.js" + }, + "repository": { + "type": "git", + "url": "git@github.com:adobe/cla-bot.git" + }, + "author": "", + "license": "ISC", + "dependencies": {} +} diff --git a/receiver/receiver.js b/receiver/receiver.js new file mode 100644 index 0000000..557f6c0 --- /dev/null +++ b/receiver/receiver.js @@ -0,0 +1,82 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +const openwhisk = require('openwhisk'); +const utils = require('../utils.js'); + +/* + * Thin GitHub webhook receiver. This becomes the GitHub App's Webhook URL + * (in place of cla-checker). Its only job is to: + * 1. cheaply decide whether this event is one we act on, + * 2. dispatch cla-checker asynchronously (non-blocking), and + * 3. return 202 to GitHub immediately, well inside GitHub's ~10s window. + * + * It NEVER calls the Checks API, so it cannot pass/fail a PR. The PR's + * `Adobe CLA Signed?` check is only ever set by cla-checker -> cla-setgithubcheck. + */ + +const valid_pr_events = ['opened', 'reopened', 'synchronize']; + +async function main (params) { + // ---- (optional hardening) verify the GitHub webhook HMAC here. ---- + // Requires deploying this action with raw-body handling (--web raw) so we can + // HMAC the exact bytes GitHub signed, plus a webhook secret in config.json. + // The current cla-checker does no signature check, so this is left as a + // clearly-marked TODO rather than silently changing the security posture. + // if (!verify_signature(params, config.githubWebhookSecret)) { + // return { statusCode: 401, body: 'invalid signature' }; + // } + + // Same guard cla-checker uses today (checker.js:24-33) -- cheap, no I/O. + const isMergeQueue = params.merge_group && params.action === 'checks_requested'; + const isValidPrEvent = params.pull_request && valid_pr_events.includes(params.action); + + if (!isMergeQueue && !isValidPrEvent) { + return { + statusCode: 202, + body: 'Ignored: not a PR (re)open/synchronize or a merge_group checks_requested event' + }; + } + + // Strip Adobe I/O Runtime web-action metadata so we forward only the GitHub + // payload to the checker (which reads params.action, params.pull_request, + // params.installation, params.repository, params.sender, params.merge_group). + const { + __ow_headers, __ow_method, __ow_path, __ow_body, __ow_query, + ...payload + } = params; + + const ow = openwhisk(); + try { + await ow.actions.invoke({ + name: utils.CHECKER, // 'cla-checker' (or 'cla-checker-stage' in staging) + blocking: false, // fire-and-forget: returns an activation id, not the result + result: false, + params: payload + }); + } catch (e) { + // We still 202 GitHub so it doesn't treat this as a failed delivery and so + // the retry behavior stays predictable. The dispatch error is captured in + // this receiver's own activation record for debugging. + return { + statusCode: 202, + body: `Accepted, but failed to dispatch cla-checker: ${e}` + }; + } + + return { + statusCode: 202, + body: 'Accepted; CLA check dispatched asynchronously.' + }; +} + +exports.main = main; diff --git a/test/unit/receiver.test.js b/test/unit/receiver.test.js new file mode 100644 index 0000000..c79e430 --- /dev/null +++ b/test/unit/receiver.test.js @@ -0,0 +1,137 @@ +/* +Copyright 2026 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ +const openWhisk = require('openwhisk'); +jest.mock('openwhisk'); + +const receiver = require('../../receiver/receiver.js'); + +// Returns an openwhisk mock whose actions.invoke is the provided spy. +function mockOpenWhisk (invoke_spy) { + openWhisk.mockImplementation(() => { + return { + actions: { + invoke: invoke_spy + } + }; + }); +} + +describe('receiver action', function () { + afterEach(() => { + openWhisk.mockReset(); + jest.restoreAllMocks(); + }); + + describe('ignored events', function () { + it('should return 202 without dispatching if no pull_request property exists', async function () { + const invoke_spy = jest.fn(); + mockOpenWhisk(invoke_spy); + const result = await receiver.main({}); + expect(result.statusCode).toBe(202); + expect(result.body).toContain('Ignored'); + expect(invoke_spy).not.toHaveBeenCalled(); + }); + + it('should return 202 without dispatching if pull_request exists but action is not opened/reopened/synchronize', async function () { + const invoke_spy = jest.fn(); + mockOpenWhisk(invoke_spy); + for (const action of ['review_requested', 'edited', 'closed', 'labeled']) { + const result = await receiver.main({ pull_request: { blah: true }, action }); + expect(result.statusCode).toBe(202); + expect(result.body).toContain('Ignored'); + } + expect(invoke_spy).not.toHaveBeenCalled(); + }); + + it('should return 202 without dispatching for a merge_group event whose action is not checks_requested', async function () { + const invoke_spy = jest.fn(); + mockOpenWhisk(invoke_spy); + const result = await receiver.main({ merge_group: { head_sha: '12345' }, action: 'destroyed' }); + expect(result.statusCode).toBe(202); + expect(result.body).toContain('Ignored'); + expect(invoke_spy).not.toHaveBeenCalled(); + }); + }); + + describe('dispatched events', function () { + it('should dispatch cla-checker non-blocking and return 202 for opened/reopened/synchronize', async function () { + for (const action of ['opened', 'reopened', 'synchronize']) { + const invoke_spy = jest.fn().mockResolvedValue({ activationId: 'abc' }); + mockOpenWhisk(invoke_spy); + const result = await receiver.main({ pull_request: { blah: true }, action }); + expect(result.statusCode).toBe(202); + expect(result.body).toContain('Accepted'); + expect(invoke_spy).toHaveBeenCalledTimes(1); + const invoke_args = invoke_spy.mock.calls[0][0]; + expect(invoke_args.name).toBe('cla-checker'); + expect(invoke_args.blocking).toBe(false); + expect(invoke_args.result).toBe(false); + } + }); + + it('should dispatch cla-checker for a merge_group checks_requested event', async function () { + const invoke_spy = jest.fn().mockResolvedValue({ activationId: 'abc' }); + mockOpenWhisk(invoke_spy); + const result = await receiver.main({ + merge_group: { head_sha: '12345' }, + action: 'checks_requested' + }); + expect(result.statusCode).toBe(202); + expect(result.body).toContain('Accepted'); + expect(invoke_spy).toHaveBeenCalledTimes(1); + const invoke_args = invoke_spy.mock.calls[0][0]; + expect(invoke_args.name).toBe('cla-checker'); + expect(invoke_args.blocking).toBe(false); + }); + + it('should forward the github payload but strip Adobe I/O Runtime web-action metadata', async function () { + const invoke_spy = jest.fn().mockResolvedValue({ activationId: 'abc' }); + mockOpenWhisk(invoke_spy); + const params = { + pull_request: { user: { login: 'hiren' }, head: { sha: '12345' } }, + action: 'opened', + installation: { id: '5431' }, + repository: { name: 'photoshop' }, + sender: { login: 'hiren' }, + __ow_headers: { 'x-github-event': 'pull_request' }, + __ow_method: 'post', + __ow_path: '', + __ow_body: 'rawbytes', + __ow_query: '' + }; + await receiver.main(params); + const forwarded = invoke_spy.mock.calls[0][0].params; + // the github payload is forwarded intact + expect(forwarded.pull_request).toEqual(params.pull_request); + expect(forwarded.action).toBe('opened'); + expect(forwarded.installation).toEqual(params.installation); + expect(forwarded.repository).toEqual(params.repository); + expect(forwarded.sender).toEqual(params.sender); + // the runtime metadata is not forwarded + expect(forwarded).not.toHaveProperty('__ow_headers'); + expect(forwarded).not.toHaveProperty('__ow_method'); + expect(forwarded).not.toHaveProperty('__ow_path'); + expect(forwarded).not.toHaveProperty('__ow_body'); + expect(forwarded).not.toHaveProperty('__ow_query'); + }); + }); + + describe('dispatch failure', function () { + it('should still return 202 (so GitHub does not retry-storm) if dispatching cla-checker throws', async function () { + const invoke_spy = jest.fn().mockRejectedValue(new Error('runtime unavailable')); + mockOpenWhisk(invoke_spy); + const result = await receiver.main({ pull_request: { blah: true }, action: 'opened' }); + expect(result.statusCode).toBe(202); + expect(result.body).toContain('failed to dispatch'); + }); + }); +}); diff --git a/utils.js b/utils.js index b018f96..b89e2e3 100644 --- a/utils.js +++ b/utils.js @@ -12,6 +12,7 @@ governing permissions and limitations under the License. module.exports = { CHECKER: 'cla-checker', + RECEIVER: 'cla-receiver', LOOKUP: 'cla-lookup', SETGITHUBCHECK: 'cla-setgithubcheck', SIGNWEBHOOK: 'cla-signwebhook',