Skip to content

Mcp over mqtt

Mcp over mqtt #9776

name: Create pre-staging environment
on:
workflow_dispatch:
inputs:
pr_number:
description: 'Pull request number'
required: true
driver_k8s_branch:
description: 'flowfuse/driver-k8s branch name'
required: true
default: 'main'
nr_launcher_branch:
description: 'flowfuse/nr-launcher branch name'
required: true
default: 'main'
nr_project_nodes_branch:
description: 'flowfuse/nr-project-nodes branch name'
required: true
default: 'main'
nr_file_nodes_branch:
description: 'flowfuse/nr-file-nodes branch name'
required: true
default: 'main'
nr_assistant_branch:
description: 'flowfuse/nr-assistant branch name'
required: true
default: 'main'
nr_tables_nodes_branch:
description: 'flowfuse/nr-tables-nodes branch name'
required: true
default: 'main'
nr_mqtt_nodes_branch:
description: 'flowfuse/nr-mqtt-nodes branch name'
required: true
default: 'main'
nr_subflow_export_branch:
description: 'flowfuse/nr-subflow-export branch name'
required: true
default: 'main'
pull_request:
types:
- opened
- synchronize
- reopened
- closed
paths-ignore:
- 'docs/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || inputs.pr_number }}
cancel-in-progress: false
env:
AWS_ECR_PUSH_ROLE: 'ECR_push_pull_images'
AWS_EKS_DEPLOY_ROLE: 'EKSDeploymentRole'
jobs:
# This job validates if the user, who triggered the workflow, is a member of the organization
# Note: Any workflow re-runs will use the privileges of github.actor, even if the actor initiating the re-run (github.triggering_actor) has different privileges.
validate-user:
name: Validate trigger author
runs-on: ubuntu-latest
if: |
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
github.actor != 'dependabot[bot]' &&
!startsWith(github.head_ref, 'release-')
outputs:
is_org_member: ${{ steps.validate.outputs.is_member }}
steps:
- name: Generate a token
id: generate_token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.GH_BOT_APP_ID }}
private-key: ${{ secrets.GH_BOT_APP_KEY }}
- name: Validate
id: validate
run: |
if [ "${{ github.actor }}" == 'dependabot[bot]' ]; then
echo "is_member=false" >> $GITHUB_OUTPUT
exit 0
fi
member_status=$(gh api orgs/flowfuse/memberships/${{ github.actor }} -q '.state')
if [ "${member_status}" == "active" ]; then
echo "is_member=true" >> $GITHUB_OUTPUT
else
echo "is_member=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
publish_k8s_driver:
name: Build and publish kubernetes driver
needs: validate-user
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
inputs.driver_k8s_branch != 'main'
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@publish_node_package/v1'
with:
package_name: driver-k8s
publish_package: true
repository_name: 'FlowFuse/driver-k8s'
branch_name: ${{ inputs.driver_k8s_branch }}
release_name: "pre-staging-${{ inputs.driver_k8s_branch }}"
secrets:
npm_registry_token: ${{ secrets.NPM_PUBLISH_TOKEN }}
publish_nr_project_nodes:
name: Build and publish nr-project-nodes package
needs: validate-user
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
inputs.nr_project_nodes_branch != 'main'
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@publish_node_package/v1'
with:
package_name: nr-project-nodes
publish_package: true
repository_name: 'FlowFuse/nr-project-nodes'
branch_name: ${{ inputs.nr_project_nodes_branch }}
release_name: "pre-staging-${{ inputs.nr_project_nodes_branch }}"
secrets:
npm_registry_token: ${{ secrets.NPM_PUBLISH_TOKEN }}
publish_nr_file_nodes:
name: Build and publish nr-file-nodes package
needs: validate-user
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
inputs.nr_file_nodes_branch != 'main'
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@publish_node_package/v1'
with:
package_name: nr-file-nodes
publish_package: true
repository_name: 'FlowFuse/nr-file-nodes'
branch_name: ${{ inputs.nr_file_nodes_branch }}
release_name: "pre-staging-${{ inputs.nr_file_nodes_branch }}"
secrets:
npm_registry_token: ${{ secrets.NPM_PUBLISH_TOKEN }}
publish_nr_assistant:
name: Build and publish nr-assistant package
needs: validate-user
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
inputs.nr_assistant_branch != 'main'
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@publish_node_package/v1'
with:
package_name: nr-assistant
publish_package: true
repository_name: 'FlowFuse/nr-assistant'
branch_name: ${{ inputs.nr_assistant_branch }}
release_name: "pre-staging-${{ inputs.nr_assistant_branch }}"
secrets:
npm_registry_token: ${{ secrets.NPM_PUBLISH_TOKEN }}
publish_nr_tables_nodes:
name: Build and publish nr-tables-nodes package
needs: validate-user
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
inputs.nr_tables_nodes_branch != 'main'
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@publish_node_package/v1'
with:
package_name: nr-tables-nodes
publish_package: true
repository_name: 'FlowFuse/nr-tables-nodes'
branch_name: ${{ inputs.nr_tables_nodes_branch }}
release_name: "pre-staging-${{ inputs.nr_tables_nodes_branch }}"
secrets:
npm_registry_token: ${{ secrets.NPM_PUBLISH_TOKEN }}
publish_nr_subflow_export:
name: Build and publish nr-subflow-export package
needs: validate-user
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
inputs.nr_subflow_export_branch != 'main'
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@publish_node_package/v1'
with:
package_name: nr-subflow-export
publish_package: true
repository_name: 'FlowFuse/nr-subflow-export'
branch_name: ${{ inputs.nr_subflow_export_branch }}
release_name: "pre-staging-${{ inputs.nr_subflow_export_branch }}"
secrets:
npm_registry_token: ${{ secrets.NPM_PUBLISH_TOKEN }}
publish_nr_mqtt_nodes:
name: Build and publish nr-mqtt-nodes package
needs: validate-user
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
inputs.nr_mqtt_nodes_branch != 'main'
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@publish_node_package/v1'
with:
package_name: nr-mqtt-nodes
publish_package: true
repository_name: 'FlowFuse/nr-mqtt-nodes'
branch_name: ${{ inputs.nr_mqtt_nodes_branch }}
release_name: "pre-staging-${{ inputs.nr_mqtt_nodes_branch }}"
secrets:
npm_registry_token: ${{ secrets.NPM_PUBLISH_TOKEN }}
publish_nr_launcher:
name: Build and publish nr-launcher package
needs:
- validate-user
- publish_nr_project_nodes
- publish_nr_file_nodes
- publish_nr_assistant
- publish_nr_tables_nodes
- publish_nr_mqtt_nodes
- publish_nr_subflow_export
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
(always() && inputs.nr_launcher_branch != 'main') ||
needs.publish_nr_project_nodes.result == 'success' ||
needs.publish_nr_file_nodes.result == 'success' ||
needs.publish_nr_assistant.result == 'success' ||
needs.publish_nr_tables_nodes.result == 'success' ||
needs.publish_nr_mqtt_nodes.result == 'success' ||
needs.publish_nr_subflow_export.result == 'success'
uses: 'flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml@publish_node_package/v1'
with:
package_name: flowfuse-nr-launcher
publish_package: true
repository_name: 'FlowFuse/nr-launcher'
branch_name: ${{ inputs.nr_launcher_branch }}
release_name: "pre-staging-${{ inputs.nr_launcher_branch == 'main' && github.sha || inputs.nr_launcher_branch }}"
package_dependencies: |
@flowfuse/nr-project-nodes=${{ inputs.nr_project_nodes_branch != 'main' && needs.publish_nr_project_nodes.outputs.release_name || 'nightly' }}
@flowfuse/nr-file-nodes=${{ inputs.nr_file_nodes_branch != 'main' && needs.publish_nr_file_nodes.outputs.release_name || 'nightly' }}
@flowfuse/nr-assistant=${{ inputs.nr_assistant_branch != 'main' && needs.publish_nr_assistant.outputs.release_name || 'nightly' }}
@flowfuse/nr-tables-nodes=${{ inputs.nr_tables_nodes_branch != 'main' && needs.publish_nr_tables_nodes.outputs.release_name || 'nightly' }}
@flowfuse/nr-mqtt-nodes=${{ inputs.nr_mqtt_nodes_branch != 'main' && needs.publish_nr_mqtt_nodes.outputs.release_name || 'nightly' }}
@flowfuse/nr-subflow-export=${{ inputs.nr_subflow_export_branch != 'main' && needs.publish_nr_subflow_export.outputs.release_name || 'nightly' }}
secrets:
npm_registry_token: ${{ secrets.NPM_PUBLISH_TOKEN }}
build-node-red:
name: Build Node-RED 4.1.x container images
needs:
- validate-user
- publish_nr_launcher
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
(always() && needs.publish_nr_launcher.result == 'success')
uses: flowfuse/github-actions-workflows/.github/workflows/build_container_image.yml@build_container_image/v1
with:
image_name: 'node-red'
dockerfile_path: Dockerfile
image_tag_prefix: '4.1.x-'
build_context: './ci/node-red'
build_arguments: |
BUILD_TAG=${{ needs.publish_nr_launcher.outputs.release_name }}
build_platform: "linux/arm64"
npm_registry_url: ${{ vars.PUBLIC_NPM_REGISTRY_URL }}
secrets:
temporary_registry_token: ${{ secrets.GITHUB_TOKEN }}
upload-node-red:
name: Publish Node-RED 4.1.x container images
needs:
- validate-user
- build-node-red
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'workflow_dispatch' &&
(always() && needs.build-node-red.result == 'success')
runs-on: ubuntu-latest
environment: staging
env:
IMAGE_NAME: 'node-red'
PR_NUMBER: ${{ github.event.number == '' && inputs.pr_number || github.event.number }}
outputs:
nr_custom_image_tag: ${{ steps.set_outputs.outputs.nr_image_tag }}
permissions:
contents: read
id-token: write
packages: read
steps:
- name: Set variables
run: |
echo "tagged_image=${{ env.IMAGE_NAME }}:4.1.x-pr-${{ env.PR_NUMBER }}" >> $GITHUB_ENV
echo "timestamp=$(date +%s)" >> $GITHUB_ENV
- name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Configure AWS credentials for ECR interaction
id: aws-config
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ECR_PUSH_ROLE }}
role-session-name: GithubActionsRoleSession
role-duration-seconds: 900
aws-region: eu-west-1
mask-aws-account-id: true
- name: Login to AWS ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6
with:
mask-password: true
- name: Login to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push to ECR
id: image-push
run: |
docker pull ${{ needs.build-node-red.outputs.image }}
docker tag ${{ needs.build-node-red.outputs.image }} ${{ steps.aws-config.outputs.aws-account-id }}.dkr.ecr.eu-west-1.amazonaws.com/flowforge/${{ env.tagged_image }}-${{ env.timestamp }}
docker push ${{ steps.aws-config.outputs.aws-account-id }}.dkr.ecr.eu-west-1.amazonaws.com/flowforge/${{ env.tagged_image }}-${{ env.timestamp }}
- name: Set outputs
id: set_outputs
run: |
echo "nr_image_tag=${{ env.tagged_image }}-${{ env.timestamp }}" >> $GITHUB_OUTPUT
build:
name: Build and contenerize
needs:
- validate-user
- publish_k8s_driver
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
(always() && needs.publish_k8s_driver.result != 'failure') &&
(github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch') &&
github.event.action != 'closed'
runs-on: ubuntu-latest
env:
IMAGE_NAME: 'forge-k8s'
PR_NUMBER: ${{ github.event.number == '' && inputs.pr_number || github.event.number }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set variables
run: |
echo "tagged_image=${{ env.IMAGE_NAME }}:pr-${{ env.PR_NUMBER}}" >> $GITHUB_ENV
- name: Setup QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
- name: Setup Docker buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Set build-args if branch is not main
id: set-build-args
run: |
{
echo "BUILD_ARGS<<EOF"
# CACHEBUST forces a fresh fetch of the device-cache script (helm main branch)
echo "CACHEBUST=${{ github.run_id }}-${{ github.run_attempt }}"
if [ "${{ inputs.driver_k8s_branch }}" != "" ]; then
echo "KUBERNETES_DRIVER_TAG=pre-staging-${{ inputs.driver_k8s_branch }}"
fi
echo "EOF"
} >> $GITHUB_ENV
- name: Build container image
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: "./ci/Dockerfile"
tags: ${{ env.tagged_image }}
push: false
outputs: type=docker,dest=/tmp/k8s-forge.tar
build-args: ${{ env.BUILD_ARGS }}
env:
DOCKER_BUILD_SUMMARY: false
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: k8s-forge
path: /tmp/k8s-forge.tar
retention-days: 7
deploy:
name: Deploy application
if: |
always() &&
needs.build.result == 'success' &&
(needs.publish_k8s_driver.result != 'failure' || needs.upload-node-red.result != 'failure' ) &&
(( github.event_name == 'pull_request' && github.event.action != 'closed' ) ||
( github.event_name == 'workflow_dispatch' && github.event.action != 'closed' ))
runs-on: ubuntu-latest
environment: staging
needs:
- validate-user
- publish_k8s_driver
- upload-node-red
- build
env:
IMAGE_NAME: 'forge-k8s'
PR_NUMBER: ${{ github.event.number == '' && inputs.pr_number || github.event.number }}
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set variables
run: |
echo "tagged_image=${{ env.IMAGE_NAME }}:pr-${{ env.PR_NUMBER}}" >> $GITHUB_ENV
echo "timestamp=$(date +%s)" >> $GITHUB_ENV
- name: Download artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: k8s-forge
path: /tmp
- name: Load image
run: |
docker load --input /tmp/k8s-forge.tar
docker image ls -a
- name: Delete artifact
uses: geekyeggo/delete-artifact@176a747ab7e287e3ff4787bf8a148716375ca118 # v6.0.0
with:
name: k8s-forge
failOnError: false
- name: Configure AWS credentials for ECR interaction
id: aws-config
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ env.AWS_ECR_PUSH_ROLE }}
role-session-name: GithubActionsRoleSession
role-duration-seconds: 900
aws-region: eu-west-1
mask-aws-account-id: true
- name: Login to AWS ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6
with:
mask-password: true
- name: Push to ECR
run: |
docker tag ${{ env.tagged_image }} ${{ steps.aws-config.outputs.aws-account-id }}.dkr.ecr.eu-west-1.amazonaws.com/flowforge/${{ env.tagged_image }}-${{ env.timestamp }}
docker push ${{ steps.aws-config.outputs.aws-account-id }}.dkr.ecr.eu-west-1.amazonaws.com/flowforge/${{ env.tagged_image }}-${{ env.timestamp }}
- name: Configure AWS credentials for EKS interaction
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ env.AWS_EKS_DEPLOY_ROLE }}
role-session-name: GithubActionsRoleSessio
role-duration-seconds: 900
aws-region: eu-west-1
- name: Configure kubeconfig
run: |
aws eks update-kubeconfig --region eu-west-1 --name ${{ secrets.EKS_CLUSTER_NAME }}
- name: Install 1Password CLI
uses: 1password/install-cli-action@a5215d3a7f75c1629216c465ea9ab3ab399c4b71 # v4.0.0
with:
version: 2.25.0
- name: Check out FlowFuse/helm repository (to access latest helm chart)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: 'FlowFuse/helm'
ref: 'main'
path: 'helm-repo'
token: ${{ secrets.GITHUB_TOKEN }}
- name: Check if deployment exists
id: check-initial-setup
run: |
if helm status --namespace "pr-${{ env.PR_NUMBER }}" flowfuse-pr-${{ env.PR_NUMBER }} &> /dev/null; then
echo "initialSetup=false" >> $GITHUB_ENV
else
echo "initialSetup=true" >> $GITHUB_ENV
fi
- name: Deploy
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
run: |
helm upgrade --install \
--create-namespace \
--namespace "pr-${{ env.PR_NUMBER }}" \
--timeout 300s \
--wait \
--atomic \
--values ci/ci-values.yaml \
--set forge.image=${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.eu-west-1.amazonaws.com/flowforge/${{ env.tagged_image }}-${{ env.timestamp }} \
--set forge.entryPoint=${{ env.PR_NUMBER }}.flowfuse.dev \
--set forge.broker.hostname=${{ env.PR_NUMBER }}-mqtt.flowfuse.dev \
--set forge.projectNamespace=pr-${{ env.PR_NUMBER }}-projects \
--set forge.clusterRole.name=pr-${{ env.PR_NUMBER }}-clusterrole \
--set forge.license=${{ secrets.PRE_STAGING_LICENSE }} \
--set forge.aws.IAMRole=arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/flowforge_service_account_role \
--set forge.email.ses.sourceArn=arn:aws:ses:eu-west-1:${{ secrets.AWS_ACCOUNT_ID }}:identity/flowfuse.com \
--set forge.assistant.service.url=$(op read op://ci/staging_flowfuse/assistant_url) \
--set forge.assistant.service.token=$(op read op://ci/staging_flowfuse/assistant_token) \
--set forge.expert.service.url=$(op read op://ci/prestaging_flowfuse/expert_url) \
--set forge.expert.service.token=$(op read op://ci/prestaging_flowfuse/expert_token) \
--set forge.tables.driver.options.database.user=$(op read op://ci/prestaging_flowfuse/local_postgres_admin_username) \
--set forge.tables.driver.options.database.password=$(op read op://ci/prestaging_flowfuse/local_postgres_admin_password) \
--set forge.tables.driver.options.database.host=flowfuse-pr-${{ env.PR_NUMBER }}-postgresql.pr-${{ env.PR_NUMBER }}.svc.cluster.local \
flowfuse-pr-${{ env.PR_NUMBER }} ./helm-repo/helm/flowfuse
- name: Initial setup
if: ${{ env.initialSetup == 'true' }}
run: |
./.github/scripts/initial-setup.sh ${{ env.PR_NUMBER }} ${{ secrets.INIT_CONFIG_PASSWORD_HASH }} ${{ secrets.INIT_CONFIG_ACCESS_TOKEN_HASH }} ${{ secrets.INIT_CONFIG_ACCESS_TOKEN }} ${{ secrets.INIT_CONFIG_PASSWORD }} ${{ secrets.PRE_STAGING_FLOWFUSE_NODES_TOKEN }}
- name: Summary
run: |
echo "### :rocket: Deployment succeeded" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Deployed commit SHA:** ${{ github.event.pull_request.head.sha }}" >> $GITHUB_STEP_SUMMARY
echo "**Deployed to:** [https://${{ env.PR_NUMBER }}.flowfuse.dev](https://${{ env.PR_NUMBER }}.flowfuse.dev)" >> $GITHUB_STEP_SUMMARY
create-custom-stack:
name: Create stack with custom Node-RED image
needs: [upload-node-red, deploy]
if: |
always() &&
github.event_name == 'workflow_dispatch' &&
needs.deploy.result == 'success' &&
needs.upload-node-red.result == 'success'
runs-on: ubuntu-latest
environment: staging
env:
PR_NUMBER: ${{ github.event.number == '' && inputs.pr_number || github.event.number }}
FLOWFUSE_DOMAIN: 'flowfuse.dev'
steps:
- name: Create/update stack
run: |
customStackId=$(curl -ks -XGET -H "Authorization: Bearer ${{ secrets.INIT_CONFIG_ACCESS_TOKEN }}" https://$PR_NUMBER.$FLOWFUSE_DOMAIN/api/v1/stacks/ | jq -r '.stacks[] | select(.name == "NR-41-Custom") | .id')
if [ -n "$customStackId" ]; then
echo "Stack already exists, updating..."
curl -ks -X PUT \
--fail-with-body \
-H "Authorization: Bearer ${{ secrets.INIT_CONFIG_ACCESS_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"properties": {"container": "'"${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.eu-west-1.amazonaws.com/flowforge/${{ needs.upload-node-red.outputs.nr_custom_image_tag }}"'"}}' \
https://$PR_NUMBER.$FLOWFUSE_DOMAIN/api/v1/stacks/$customStackId
else
echo "Stack does not exists, creating..."
projectTypeId=$(curl -ks -XGET -H "Authorization: Bearer ${{ secrets.INIT_CONFIG_ACCESS_TOKEN }}" https://$PR_NUMBER.$FLOWFUSE_DOMAIN/api/v1/project-types/ | jq -r '.types[].id')
curl -ks -w "\n" -XPOST \
--fail-with-body \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${{ secrets.INIT_CONFIG_ACCESS_TOKEN }}" \
-d '{"name":"'"NR-41-Custom"'","label":"'"4.1.x-custom"'", "projectType":"'"$projectTypeId"'","properties":{ "cpu":'"30"',"memory":'"256"',"container":"'"${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.eu-west-1.amazonaws.com/flowforge/${{ needs.upload-node-red.outputs.nr_custom_image_tag }}"'"}}' \
https://$PR_NUMBER.$FLOWFUSE_DOMAIN/api/v1/stacks/
fi
notify-slack:
name: Notify about pre-staging deployment
needs:
- deploy
- create-custom-stack
if: |
always() &&
(needs.deploy.result != 'skipped' || needs.create-custom-stack.result != 'skipped')
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{ github.event.number == '' && inputs.pr_number || github.event.number }}
steps:
# Builds the recipient list (PR author + requested reviewers, or github.actor on workflow_dispatch)
# and resolves each GitHub username to a Slack user ID by scanning workspace profiles and picking the value of custom profile field (GitHub).
# Outputs `recipients` as JSON: [{ slack_id, github, role }, ...]. Unresolved users are skipped with a warning.
- name: Resolve Slack users for recipients
id: resolve
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_GHBOT_TOKEN }}
SLACK_GITHUB_FIELD_ID: "Xf0A2BPU8U77"
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
REQUESTED_REVIEWERS: ${{ toJSON(github.event.pull_request.requested_reviewers) }}
GITHUB_ACTOR: ${{ github.actor }}
EVENT_NAME: ${{ github.event_name }}
with:
script: |
const token = process.env.SLACK_BOT_TOKEN;
const fieldId = process.env.SLACK_GITHUB_FIELD_ID;
// Build recipient list with roles, deduped (first-added role wins: Author > Reviewer > Pusher).
// Bot accounts (e.g. dependabot[bot]) are skipped — they have no Slack profile.
const recipients = [];
const seen = new Set();
const add = (login, role) => {
if (!login) return;
if (login.endsWith('[bot]')) return;
const key = login.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
recipients.push({ github: key, role });
};
if (process.env.EVENT_NAME === 'workflow_dispatch') {
add(process.env.GITHUB_ACTOR, 'Trigger');
} else {
add(process.env.PR_AUTHOR, 'Author');
const reviewers = JSON.parse(process.env.REQUESTED_REVIEWERS || '[]');
for (const r of reviewers) add(r.login, 'Reviewer');
add(process.env.GITHUB_ACTOR, 'Pusher');
}
if (recipients.length === 0) {
core.warning('No recipients to notify');
core.setOutput('recipients', '[]');
return;
}
// Build Slack username -> ID map by scanning workspace profiles
const targets = new Set(recipients.map(r => r.github));
const resolved = new Map();
let cursor;
outer: do {
const params = new URLSearchParams({ limit: '200' });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`https://slack.com/api/users.list?${params}`, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await res.json();
if (!data.ok) return core.setFailed(`Slack users.list error: ${data.error}`);
for (const member of data.members) {
if (member.deleted || member.is_bot) continue;
const profileRes = await fetch(`https://slack.com/api/users.profile.get?user=${member.id}`, {
headers: { Authorization: `Bearer ${token}` }
});
const profileData = await profileRes.json();
if (!profileData.ok) continue;
const ghField = profileData.profile?.fields?.[fieldId]?.value;
if (!ghField) continue;
const ghLower = ghField.toLowerCase();
if (targets.has(ghLower)) {
resolved.set(ghLower, member.id);
if (resolved.size === targets.size) break outer;
}
}
cursor = data.response_metadata?.next_cursor;
} while (cursor);
const output = [];
for (const r of recipients) {
const slack_id = resolved.get(r.github);
if (slack_id) {
output.push({ slack_id, github: r.github, role: r.role });
} else {
core.warning(`No Slack user found with GitHub username: ${r.github} (role: ${r.role})`);
}
}
core.info(`Resolved ${output.length}/${recipients.length} recipients`);
core.setOutput('recipients', JSON.stringify(output));
# Sends one DM per resolved recipient via Slack chat.postMessage.
# Same message content for everyone, with a *Role:* field indicating Author / Reviewer / Trigger.
# Step fails only if all sends fail; partial failures are logged as warnings.
- name: Send Slack notifications
if: steps.resolve.outputs.recipients != '' && steps.resolve.outputs.recipients != '[]'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_GHBOT_TOKEN }}
RECIPIENTS: ${{ steps.resolve.outputs.recipients }}
PR_NUMBER: ${{ env.PR_NUMBER }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
GRAFANA_URL: ${{ secrets.STAGING_GRAFANA_URL }}
with:
script: |
const token = process.env.SLACK_BOT_TOKEN;
const recipients = JSON.parse(process.env.RECIPIENTS);
const prNumber = process.env.PR_NUMBER;
const prTitle = process.env.PR_TITLE || `#${prNumber}`;
const prAuthor = process.env.PR_AUTHOR || 'n/a';
const deployResult = process.env.DEPLOY_RESULT;
const commitSha = process.env.COMMIT_SHA;
const serverUrl = process.env.SERVER_URL;
const repository = process.env.REPOSITORY;
const runId = process.env.RUN_ID;
const grafanaUrl = process.env.GRAFANA_URL;
const statusText = deployResult === 'success' ? ':white_check_mark: Success' : ':x: Failure ';
const logsUrl = `https://${grafanaUrl}/explore?orgId=1&left=%7B%22datasource%22:%22P8E80F9AEF21F6940%22,%22queries%22:%5B%7B%22refId%22:%22A%22,%22editorMode%22:%22code%22,%22expr%22:%22%7Bnamespace%3D%5C%22pr-${prNumber}%5C%22%7D%20%7C%3D%20%60%60%20%7C%20json%22,%22queryType%22:%22range%22%7D%5D,%22range%22:%7B%22from%22:%22now-30m%22,%22to%22:%22now%22%7D%7D`;
let failures = 0;
for (const r of recipients) {
const blocks = [
{
type: 'header',
text: { type: 'plain_text', text: `Pull Request ${prNumber} pre-staging deployment`, emoji: true }
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Status:*\n${statusText}` },
{ type: 'mrkdwn', text: `*Role:*\n${r.role}` },
{ type: 'mrkdwn', text: `*Pull Request:*\n<https://github.com/FlowFuse/flowfuse/pull/${prNumber}|${prTitle}>` },
{ type: 'mrkdwn', text: `*Workflow run:*\n<${serverUrl}/${repository}/actions/runs/${runId}|View>` }
]
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Author:*\n${prAuthor}` },
{ type: 'mrkdwn', text: `*Commit SHA:*\n<${serverUrl}/${repository}/commit/${commitSha}|${commitSha}>` },
{ type: 'mrkdwn', text: `*Deployed to:*\n<https://${prNumber}.flowfuse.dev|https://${prNumber}.flowfuse.dev>` },
{ type: 'mrkdwn', text: `*Logs:*\n<${logsUrl}|View>` }
]
}
];
const res = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json; charset=utf-8'
},
body: JSON.stringify({ channel: r.slack_id, blocks })
});
const data = await res.json();
if (!data.ok) {
core.warning(`Slack chat.postMessage failed for ${r.github} (${r.slack_id}): ${data.error}`);
failures++;
} else {
core.info(`Notified ${r.github} (${r.role}) at ${r.slack_id}`);
}
}
if (failures > 0 && failures === recipients.length) {
core.setFailed(`All ${failures} Slack notifications failed`);
}
destroy:
name: Remove application
needs: [ validate-user ]
runs-on: ubuntu-latest
if: |
needs.validate-user.outputs.is_org_member == 'true' &&
github.event_name == 'pull_request' &&
github.event.action == 'closed'
environment: staging
env:
IMAGE_NAME: 'forge-k8s'
PR_NUMBER: ${{ github.event.number == '' && inputs.pr_number || github.event.number }}
permissions:
contents: read
id-token: write
steps:
- name: Configure AWS credentials for EKS interaction
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6.2.1
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ env.AWS_EKS_DEPLOY_ROLE }}
role-session-name: GithubActionsRoleSession
role-duration-seconds: 900
aws-region: eu-west-1
- name: Configure kubeconfig
run: |
aws eks update-kubeconfig --region eu-west-1 --name ${{ secrets.EKS_CLUSTER_NAME }}
- name: Remove resources
run: |
if helm list -n "pr-${{ env.PR_NUMBER }}" --filter "^flowfuse-pr-${{ env.PR_NUMBER }}$" | grep "flowfuse-pr-${{ env.PR_NUMBER }}"; then
helm uninstall --namespace "pr-${{ env.PR_NUMBER }}" flowfuse-pr-${{ env.PR_NUMBER }}
sleep 15
kubectl delete namespace "pr-${{ env.PR_NUMBER }}"
else
echo "Release flowfuse-pr-${{ env.PR_NUMBER }} does not exist"
fi