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
7 changes: 6 additions & 1 deletion .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}."
7 changes: 6 additions & 1 deletion .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}."
39 changes: 31 additions & 8 deletions deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions receiver/package.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
82 changes: 82 additions & 0 deletions receiver/receiver.js
Original file line number Diff line number Diff line change
@@ -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;
137 changes: 137 additions & 0 deletions test/unit/receiver.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
1 change: 1 addition & 0 deletions utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading