Github actions workflow dispatch + Webhooks using `Serverless.com` Lift plugin

Slug: serverless-lift-gha

23300 characters 2776 words

#1. serverless.yml Configuration:

service: github-webhook-service provider: name: aws runtime: nodejs20.x region: us-east-1 environment: GITHUB_TOKEN: ${env:GITHUB_TOKEN} # Securely store your GitHub Personal Access Token GITHUB_REPOSITORY: ib-bsb-br/ib-bsb-br.github.io # Replace with your GitHub repository WORKFLOW_ID: dispatch-workflow.yml # The filename or ID of the GitHub Actions workflow to trigger plugins: - serverless-lift functions: handleWebhook: handler: handler.handleWebhook events: - http: path: /webhook # Webhook endpoint path method: post cors: true - eventBridge: eventBus: ${construct:webhook.busName} pattern: source: - webhook detail-type: - new_comment triggerGithubWorkflow: handler: handler.triggerGithubWorkflow events: - http: path: /trigger_github_workflow method: post cors: true constructs: webhook: type: webhook path: /webhook method: POST eventType: $request.body.eventType # Maps to 'detail-type' in EventBridge event insecure: true package: patterns: - '!node_modules/aws-sdk/**' - '!node_modules/@aws-sdk/**'

#2. Handler Functions (handler.mjs):

import { Octokit } from "@octokit/rest"; import { Base64 } from "js-base64"; /** * Converts a string to a URL-friendly slug. * @param {string} text - The text to slugify. * @return {string} Slugified text. */ const slugify = (text) => { return text .toString() .toLowerCase() .trim() .replace(/\s+/g, '-') // Replace spaces with - .replace(/[^\w\-]+/g, '') // Remove all non-word chars .replace(/\-\-+/g, '-') // Replace multiple - with single - .replace(/^-+/, '') // Trim - from start of text .replace(/-+$/, ''); // Trim - from end of text }; /** * Formats a date string to YYYY-MM-DD. * @param {string|Date} date - The date to format. * @return {string} Formatted date string. */ const formatDate = (date) => { const d = new Date(date); if (isNaN(d.getTime())) { throw new Error('Invalid date provided'); } return d.toISOString().split('T')[0]; }; /** * Creates the content for a blog post in Markdown format. * @param {Object} data - Data for the blog post. * @param {string} data.by_nickname - Author's nickname. * @param {string} data.by_email - Author's email. * @param {string} data.content - Post content. * @param {string|Date} data.time - Timestamp of the post. * @return {string} Formatted blog post content. */ const createPostContent = (data) => { const { by_nickname, by_email, content, time } = data; const slugName = slugify(by_nickname); const date = formatDate(time); return `--- tags: ${by_email} info: aberto. date: ${date} type: post layout: post published: true slug: ${slugName} title: '${by_nickname}' --- ${content}`; }; /** * Triggers a GitHub Actions workflow using workflow_dispatch. * @param {Octokit} octokit - Authenticated Octokit instance. * @param {string} owner - Repository owner. * @param {string} repo - Repository name. * @param {string} ref - Git reference (branch or tag). * @param {string} workflowId - Workflow file name or ID. * @param {Object} inputs - Inputs for the workflow. */ const triggerWorkflowDispatch = async (octokit, owner, repo, ref, workflowId, inputs = {}) => { try { const response = await octokit.request( 'POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', { owner, repo, workflow_id: workflowId, ref, inputs, } ); if (response.status !== 204) { throw new Error(`Failed to dispatch workflow. GitHub API status: ${response.status}`); } console.log(`Workflow '${workflowId}' dispatched successfully on ${repo}`); return response.data; } catch (error) { console.error('Error dispatching workflow:', error); throw error; } }; /** * Returns CORS headers. * @return {Object} CORS headers. */ const getCorsHeaders = () => { return { 'Access-Control-Allow-Origin': 'https://ib.bsb.br', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }; }; /** * Handles incoming webhook events to create/update a blog post and trigger a workflow. * @param {Object} event - Event data from AWS Lambda invocation. * @return {Object} Response object with statusCode, headers, and body. */ export const handleWebhook = async (event) => { try { console.log('Event received:', JSON.stringify(event, null, 2)); let body; // Determine if the event is from an HTTP request or EventBridge if (event.body) { // HTTP request body = JSON.parse(event.body); } else if (event.detail) { // EventBridge event body = event.detail; } else { throw new Error('No data received in the event.'); } // Handle nested 'data' object if present const data = body.data || body; // Extract and validate data from the webhook event const { by_nickname, by_email, content } = data; if (!by_email || !by_nickname || !content) { throw new Error('Missing required fields: by_email, by_nickname, or content.'); } // Use 'createdAt' or 'updatedAt' as the time, or default to current time let eventTime = data.createdAt || data.updatedAt || new Date().toISOString(); // Validate and format the time try { eventTime = formatDate(eventTime); } catch (e) { // If invalid, default to current date eventTime = formatDate(new Date().toISOString()); } // Validate required environment variables const githubToken = process.env.GITHUB_TOKEN; const githubRepository = process.env.GITHUB_REPOSITORY; const workflowId = process.env.WORKFLOW_ID; if (!githubToken || !githubRepository || !workflowId) { throw new Error('Missing required environment variables.'); } const [owner, repo] = githubRepository.split('/'); const octokit = new Octokit({ auth: githubToken }); // Create or update the blog post file in the repository const slugName = slugify(by_nickname); const path = `_posts/${eventTime}-${slugName}.md`; const message = `New post by ${by_nickname}`; const postContent = createPostContent({ by_nickname, by_email, content, time: eventTime, }); const contentEncoded = Base64.encode(postContent); // Check if the file already exists let sha; try { const { data: fileData } = await octokit.repos.getContent({ owner, repo, path, }); sha = fileData.sha; // File exists, so we'll update it } catch (err) { if (err.status !== 404) { console.error('Error fetching file content:', err); throw err; } // File does not exist; proceed to create it } // Create or update the file in the repository await octokit.repos.createOrUpdateFileContents({ owner, repo, path, message, content: contentEncoded, sha, // Include sha if updating an existing file }); // Dispatch the workflow const ref = 'main'; // You can make this dynamic if needed await triggerWorkflowDispatch(octokit, owner, repo, ref, workflowId); return { statusCode: 200, headers: getCorsHeaders(), body: JSON.stringify({ message: 'File created/updated and workflow triggered', path, }), }; } catch (error) { console.error('Error in handleWebhook:', error); return { statusCode: error.statusCode || 500, headers: getCorsHeaders(), body: JSON.stringify({ message: error.message || 'An unexpected error occurred.', }), }; } }; /** * Handles requests to manually trigger a GitHub Actions workflow via HTTP endpoint. * @param {Object} event - Event data from AWS Lambda invocation. * @return {Object} Response object with statusCode, headers, and body. */ export const triggerGithubWorkflow = async (event) => { try { if (event.httpMethod === 'OPTIONS') { // Respond to CORS preflight request return { statusCode: 200, headers: getCorsHeaders(), body: '', }; } console.log('Event received:', JSON.stringify(event, null, 2)); const body = event.body ? JSON.parse(event.body) : {}; const { ref = 'main', workflow_id, inputs = {} } = body; // Validate required environment variables const githubToken = process.env.GITHUB_TOKEN; const githubRepository = process.env.GITHUB_REPOSITORY; if (!githubToken || !githubRepository) { throw new Error('Missing required environment variables.'); } const workflowId = workflow_id || process.env.WORKFLOW_ID; if (!workflowId) { throw new Error('Workflow ID is required.'); } const [owner, repo] = githubRepository.split('/'); const octokit = new Octokit({ auth: githubToken }); // Dispatch the workflow await triggerWorkflowDispatch(octokit, owner, repo, ref, workflowId, inputs); return { statusCode: 200, headers: getCorsHeaders(), body: JSON.stringify({ message: 'Workflow dispatched successfully.' }), }; } catch (error) { console.error('Error in triggerGithubWorkflow:', error); return { statusCode: error.statusCode || 500, headers: getCorsHeaders(), body: JSON.stringify({ message: error.message || 'Failed to dispatch workflow.', }), }; } };

#3. Client-Side JavaScript (If Applicable):


#Deployment and Configuration Instructions:

  1. Set Environment Variables:

    • GITHUB_TOKEN: Personal Access Token with appropriate permissions (stored securely).
    • GITHUB_REPOSITORY: Your GitHub repository in the format owner/repo.
    • WORKFLOW_ID: The filename or ID of the GitHub Actions workflow to trigger.
export GITHUB_TOKEN="your_personal_access_token" export GITHUB_REPOSITORY="owner/repo" export WORKFLOW_ID="main.yml"
  1. Deploy the Serverless Application:

    • Install dependencies: npm install
    • Deploy using the Serverless Framework: (1) {npx serverless print; (2) serverless deploy.
  2. Configure GitHub Actions Workflow:

    • Create a workflow file in your repository (e.g., your-workflow-file.yml).
    • Ensure it includes workflow_dispatch in the on section:
name: Dispatch Workflow on: workflow_dispatch: jobs: dispatch_event: runs-on: ubuntu-latest steps: - name: Dispatch GitHub Actions Workflow run: | curl -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer $" \ -H "Content-Type: application/json" \ -d '{"event_type":"trigger-jekyll", "client_payload": {"message": "Triggered from main workflow"}}' \ https://api.github.com/repos/ib-bsb-br/ib-bsb-br.github.io/dispatches
URL: https://ib.bsb.br/serverless-lift-gha

Reference: 1. serverless.yml Configuration: service: github-webhook-service provider: name: aws runtime: nodejs20.x region: us-east-1 environment: GITHUB_TOKEN: ${env:GITHUB_TOKEN} # Securely store your GitHub Personal Access Token GITHUB_REPOSITORY: ib-bsb-br/ib-bsb-br.github.io # Replace with your GitHub repository WORKFLOW_ID: dispatch-workflow.yml # The filename or ID of the GitHub Actions workflow to trigger plugins: - serverless-lift functions: handleWebhook: handler: handler.handleWebhook events: - http: path: /webhook # Webhook endpoint path method: post cors: true - eventBridge: eventBus: ${construct:webhook.busName} pattern: source: - webhook detail-type: - new_comment triggerGithubWorkflow: handler: handler.triggerGithubWorkflow events: - http: path: /trigger_github_workflow method: post cors: true constructs: webhook: type: webhook path: /webhook method: POST eventType: $request.body.eventType # Maps to 'detail-type' in EventBridge event insecure: true package: patterns: - '!node_modules/aws-sdk/**' - '!node_modules/@aws-sdk/**' 2. Handler Functions (handler.mjs): import { Octokit } from "@octokit/rest"; import { Base64 } from "js-base64"; /** * Converts a string to a URL-friendly slug. * @param {string} text - The text to slugify. * @return {string} Slugified text. */ const slugify = (text) => { return text .toString() .toLowerCase() .trim() .replace(/\s+/g, '-') // Replace spaces with - .replace(/[^\w\-]+/g, '') // Remove all non-word chars .replace(/\-\-+/g, '-') // Replace multiple - with single - .replace(/^-+/, '') // Trim - from start of text .replace(/-+$/, ''); // Trim - from end of text }; /** * Formats a date string to YYYY-MM-DD. * @param {string|Date} date - The date to format. * @return {string} Formatted date string. */ const formatDate = (date) => { const d = new Date(date); if (isNaN(d.getTime())) { throw new Error('Invalid date provided'); } return d.toISOString().split('T')[0]; }; /** * Creates the content for a blog post in Markdown format. * @param {Object} data - Data for the blog post. * @param {string} data.by_nickname - Author's nickname. * @param {string} data.by_email - Author's email. * @param {string} data.content - Post content. * @param {string|Date} data.time - Timestamp of the post. * @return {string} Formatted blog post content. */ const createPostContent = (data) => { const { by_nickname, by_email, content, time } = data; const slugName = slugify(by_nickname); const date = formatDate(time); return `--- tags: ${by_email} info: aberto. date: ${date} type: post layout: post published: true slug: ${slugName} title: '${by_nickname}' --- ${content}`; }; /** * Triggers a GitHub Actions workflow using workflow_dispatch. * @param {Octokit} octokit - Authenticated Octokit instance. * @param {string} owner - Repository owner. * @param {string} repo - Repository name. * @param {string} ref - Git reference (branch or tag). * @param {string} workflowId - Workflow file name or ID. * @param {Object} inputs - Inputs for the workflow. */ const triggerWorkflowDispatch = async (octokit, owner, repo, ref, workflowId, inputs = {}) => { try { const response = await octokit.request( 'POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches', { owner, repo, workflow_id: workflowId, ref, inputs, } ); if (response.status !== 204) { throw new Error(`Failed to dispatch workflow. GitHub API status: ${response.status}`); } console.log(`Workflow '${workflowId}' dispatched successfully on ${repo}`); return response.data; } catch (error) { console.error('Error dispatching workflow:', error); throw error; } }; /** * Returns CORS headers. * @return {Object} CORS headers. */ const getCorsHeaders = () => { return { 'Access-Control-Allow-Origin': 'https://ib.bsb.br', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', }; }; /** * Handles incoming webhook events to create/update a blog post and trigger a workflow. * @param {Object} event - Event data from AWS Lambda invocation. * @return {Object} Response object with statusCode, headers, and body. */ export const handleWebhook = async (event) => { try { console.log('Event received:', JSON.stringify(event, null, 2)); let body; // Determine if the event is from an HTTP request or EventBridge if (event.body) { // HTTP request body = JSON.parse(event.body); } else if (event.detail) { // EventBridge event body = event.detail; } else { throw new Error('No data received in the event.'); } // Handle nested 'data' object if present const data = body.data || body; // Extract and validate data from the webhook event const { by_nickname, by_email, content } = data; if (!by_email || !by_nickname || !content) { throw new Error('Missing required fields: by_email, by_nickname, or content.'); } // Use 'createdAt' or 'updatedAt' as the time, or default to current time let eventTime = data.createdAt || data.updatedAt || new Date().toISOString(); // Validate and format the time try { eventTime = formatDate(eventTime); } catch (e) { // If invalid, default to current date eventTime = formatDate(new Date().toISOString()); } // Validate required environment variables const githubToken = process.env.GITHUB_TOKEN; const githubRepository = process.env.GITHUB_REPOSITORY; const workflowId = process.env.WORKFLOW_ID; if (!githubToken || !githubRepository || !workflowId) { throw new Error('Missing required environment variables.'); } const [owner, repo] = githubRepository.split('/'); const octokit = new Octokit({ auth: githubToken }); // Create or update the blog post file in the repository const slugName = slugify(by_nickname); const path = `_posts/${eventTime}-${slugName}.md`; const message = `New post by ${by_nickname}`; const postContent = createPostContent({ by_nickname, by_email, content, time: eventTime, }); const contentEncoded = Base64.encode(postContent); // Check if the file already exists let sha; try { const { data: fileData } = await octokit.repos.getContent({ owner, repo, path, }); sha = fileData.sha; // File exists, so we'll update it } catch (err) { if (err.status !== 404) { console.error('Error fetching file content:', err); throw err; } // File does not exist; proceed to create it } // Create or update the file in the repository await octokit.repos.createOrUpdateFileContents({ owner, repo, path, message, content: contentEncoded, sha, // Include sha if updating an existing file }); // Dispatch the workflow const ref = 'main'; // You can make this dynamic if needed await triggerWorkflowDispatch(octokit, owner, repo, ref, workflowId); return { statusCode: 200, headers: getCorsHeaders(), body: JSON.stringify({ message: 'File created/updated and workflow triggered', path, }), }; } catch (error) { console.error('Error in handleWebhook:', error); return { statusCode: error.statusCode || 500, headers: getCorsHeaders(), body: JSON.stringify({ message: error.message || 'An unexpected error occurred.', }), }; } }; /** * Handles requests to manually trigger a GitHub Actions workflow via HTTP endpoint. * @param {Object} event - Event data from AWS Lambda invocation. * @return {Object} Response object with statusCode, headers, and body. */ export const triggerGithubWorkflow = async (event) => { try { if (event.httpMethod === 'OPTIONS') { // Respond to CORS preflight request return { statusCode: 200, headers: getCorsHeaders(), body: '', }; } console.log('Event received:', JSON.stringify(event, null, 2)); const body = event.body ? JSON.parse(event.body) : {}; const { ref = 'main', workflow_id, inputs = {} } = body; // Validate required environment variables const githubToken = process.env.GITHUB_TOKEN; const githubRepository = process.env.GITHUB_REPOSITORY; if (!githubToken || !githubRepository) { throw new Error('Missing required environment variables.'); } const workflowId = workflow_id || process.env.WORKFLOW_ID; if (!workflowId) { throw new Error('Workflow ID is required.'); } const [owner, repo] = githubRepository.split('/'); const octokit = new Octokit({ auth: githubToken }); // Dispatch the workflow await triggerWorkflowDispatch(octokit, owner, repo, ref, workflowId, inputs); return { statusCode: 200, headers: getCorsHeaders(), body: JSON.stringify({ message: 'Workflow dispatched successfully.' }), }; } catch (error) { console.error('Error in triggerGithubWorkflow:', error); return { statusCode: error.statusCode || 500, headers: getCorsHeaders(), body: JSON.stringify({ message: error.message || 'Failed to dispatch workflow.', }), }; } }; 3. Client-Side JavaScript (If Applicable): Events Deployment and Configuration Instructions: Set Environment Variables: GITHUB_TOKEN: Personal Access Token with appropriate permissions (stored securely). GITHUB_REPOSITORY: Your GitHub repository in the format owner/repo. WORKFLOW_ID: The filename or ID of the GitHub Actions workflow to trigger. export GITHUB_TOKEN="your_personal_access_token" export GITHUB_REPOSITORY="owner/repo" export WORKFLOW_ID="main.yml" Deploy the Serverless Application: Install dependencies: npm install Deploy using the Serverless Framework: (1) {npx serverless print; (2) serverless deploy. Configure GitHub Actions Workflow: Create a workflow file in your repository (e.g., your-workflow-file.yml). Ensure it includes workflow_dispatch in the on section: name: Dispatch Workflow on: workflow_dispatch: jobs: dispatch_event: runs-on: ubuntu-latest steps: - name: Dispatch GitHub Actions Workflow run: | curl -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer $" \ -H "Content-Type: application/json" \ -d '{"event_type":"trigger-jekyll", "client_payload": {"message": "Triggered from main workflow"}}' \ https://api.github.com/repos/ib-bsb-br/ib-bsb-br.github.io/dispatches