Fix degraded map performance after re-rendering map markers several t… #41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # --------------------------------------------------------------------------------------------------------------------- | |
| # This workflow handles all CI/CD related tasks and can be re-used in custom projects | |
| # (https://github.com/openremote/custom-project) or forks of this repo, it handles: | |
| # | |
| # - Running tests on push/release | |
| # - Distributing openremote/manager images to docker hub (DOCKERHUB_USER and DOCKERHUB_PASSWORD must be set) on push/release | |
| # - Deploying to hosts on push/release and/or manual trigger and/or when certain openremote/manager docker tags are updated | |
| # | |
| # By default this workflow will just run tests on push/release; distribution (pushing of openremote/manager docker image) | |
| # and deployment behaviour is configured via the .ci_cd/ci_cd.json file (see .ci_cd/README.md for more details). | |
| # | |
| # DEPLOYMENTS | |
| # | |
| # When a deployment is requested the repo is checked for a 'deployment/Dockerfile' and if that exists then also | |
| # 'deployment/build.gradle' must exist with an installDist task that prepares the deployment image in the | |
| # 'deployment/build' directory; if this condition is not met the deployment will fail. | |
| # | |
| # Secrets, inputs and environment variables are combined for consumption by the '.ci_cd/deploy.sh' bash script, they are | |
| # combined in the following priority: | |
| # | |
| # - '.ci_cd/env/.env' | |
| # - '.ci_cd/env/{ENVIRONMENT}.env' | |
| # - secrets | |
| # - inputs (from manual trigger using workflow_dispatch) | |
| # | |
| # The above variables are output to 'temp/env' file for each requested deployment. If a secret called 'SSH_PASSWORD' is | |
| # found that is output to 'ssh.env' file (so it is not copied to the host), if a secret called 'SSH_KEY' is | |
| # found that is output to 'ssh.key' file (so it is not copied to the host). Sensitive credentials should always be stored | |
| # in github secrets so they are encrypted, do not store within repo files (even private repos as these are not encrypted). | |
| # | |
| # SEE https://github.com/openremote/openremote/tree/master/.ci_cd for details of standard variables and handling. | |
| # | |
| # MANUAL TRIGGER | |
| # | |
| # This workflow can be triggered by push, release, manually or schedule (from any repo other than openremote/openremote) | |
| # if triggered manually then the following inputs are available and these override any set in env files and/or secrets: | |
| # | |
| # Inputs: | |
| # | |
| # - ENVIRONMENT - Which environment to deploy (equivalent to deploy/environment in '.ci_cd/ci_cd.json') | |
| # - MANAGER_TAG - Which manager docker tag to deploy (equivalent to deploy/managerTags in '.ci_cd/ci_cd.json') | |
| # leave empty to build a manager image from the repo (must be an openremote repo or have | |
| # an openremote submodule). | |
| # - CLEAN_INSTALL - Should the .ci_cd/host_init/clean.sh script be run during deployment (warning this will delete | |
| # assets, rules, etc.) | |
| # - PUSH_SNAPSHOT - Should a SNAPSHOT docker image be pushed to AWS ECR | |
| # - COMMIT - Which branch/SHA should be checked out for the deployment (defaults to trigger commit) | |
| # - OR_HOSTNAME - FQDN of host to deploy to (e.g. demo.openremote.app) | |
| # - SSH_USER - Set/override the SSH user to use for SSH/SCP commands | |
| # - SSH_PASSWORD - Set/override the SSH password to use for SSH/SCP commands (SSH key should be preferred) | |
| # - SSH_PORT - Set/override the SSH port to use for SSH/SCP commands | |
| # - OR_ADMIN_PASSWORD - The admin password to set for clean installs | |
| # --------------------------------------------------------------------------------------------------------------------- | |
| name: CI/CD | |
| on: | |
| # Push on master excluding tags | |
| push: | |
| branches: | |
| - 'master' | |
| tags: | |
| - '[0-9]+.[0-9]+.[0-9]+' | |
| # PR | |
| pull_request: | |
| # Manual trigger | |
| workflow_dispatch: | |
| inputs: | |
| ENVIRONMENT: | |
| description: 'Environment to use (if any)' | |
| MANAGER_TAG: | |
| description: 'Manager docker tag to pull' | |
| CLEAN_INSTALL: | |
| description: 'Delete data before starting' | |
| type: boolean | |
| PUSH_SNAPSHOT: | |
| description: 'Push SNAPSHOT docker image to ECR' | |
| type: boolean | |
| COMMIT: | |
| description: 'Repo branch or commit SHA to checkout' | |
| OR_HOSTNAME: | |
| description: 'Host to deploy to (e.g. demo.openremote.app)' | |
| OR_ADMIN_PASSWORD: | |
| description: 'Admin password override' | |
| workflow_call: | |
| inputs: | |
| INPUTS: | |
| type: string | |
| secrets: | |
| SECRETS: | |
| required: false | |
| permissions: | |
| id-token: write | |
| contents: read | |
| security-events: write | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| build: | |
| name: CI/CD | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Get inputs and secrets | |
| id: inputs-and-secrets | |
| shell: python | |
| run: | | |
| import os | |
| import json | |
| # Overlay all inputs and secrets onto this jobs outputs | |
| callerInputs = os.getenv("CALLER_INPUTS") | |
| inputs = os.getenv("INPUTS") | |
| secrets = os.getenv("SECRETS") | |
| eventName = os.getenv("GITHUB_EVENT_NAME") | |
| if inputs is not None and inputs != '': | |
| inputs = json.loads(inputs) | |
| if secrets is not None and secrets != '': | |
| secrets = json.loads(secrets) | |
| if eventName == 'workflow_call' and callerInputs is not None and callerInputs != 'null': | |
| os.system(f"echo 'Processing caller inputs'") | |
| inputs = json.loads(callerInputs) | |
| if inputs is not None and 'INPUTS' in inputs: | |
| os.system("echo 'Processing inputs from caller'") | |
| inputs = json.loads(inputs['INPUTS']) | |
| if 'SECRETS' in secrets: | |
| os.system("echo 'Processing secrets from caller'") | |
| secrets = json.loads(secrets['SECRETS']) | |
| # Iterate over secrets then inputs and assign them as outputs on this step | |
| if secrets is not None and secrets != 'null': | |
| for key, value in secrets.items(): | |
| os.system(f"echo 'Outputting secret: {key}'") | |
| lines = len(value.split("\n")) | |
| if lines > 1: | |
| os.system(f"echo '{key}<<EEOOFF\n{value}\nEEOOFF' >> $GITHUB_OUTPUT") | |
| else: | |
| os.system(f"echo '{key}={value}' >> $GITHUB_OUTPUT") | |
| if inputs is not None and inputs != 'null': | |
| for key, value in inputs.items(): | |
| os.system(f"echo 'Outputting input: {key}'") | |
| lines = len(value.split("\n")) | |
| if lines > 1: | |
| os.system(f"echo '{key}<<EEOOFF\n{value}\nEEOOFF' >> $GITHUB_OUTPUT") | |
| else: | |
| os.system(f"echo '{key}={value}' >> $GITHUB_OUTPUT") | |
| env: | |
| CALLER_INPUTS: ${{ toJSON(inputs) }} | |
| SECRETS: ${{ toJSON(secrets) }} | |
| INPUTS: ${{ toJSON(github.event.inputs) }} | |
| - name: Public IP | |
| id: ip-address | |
| shell: bash | |
| run: | | |
| OPTS="-sf -m 5" | |
| # Try Primary v4 -> Fallback v4 -> Default to empty | |
| PUBLIC_IPV4=$(curl $OPTS v4.ident.me || curl $OPTS 4.tnedi.me || true) | |
| # Try Primary v6 -> Fallback v6 -> Default to empty | |
| PUBLIC_IPV6=$(curl $OPTS v6.ident.me || curl $OPTS 6.tnedi.me || true) | |
| # Validation: Ensure at least one IP was found | |
| if [[ -z "$PUBLIC_IPV4" && -z "$PUBLIC_IPV6" ]]; then | |
| echo "::error::Could not determine Public IP (both v4 and v6 failed)." | |
| exit 1 | |
| fi | |
| echo "ipv4=$PUBLIC_IPV4" >> $GITHUB_OUTPUT | |
| echo "ipv6=$PUBLIC_IPV6" >> $GITHUB_OUTPUT | |
| - name: Checkout | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 | |
| with: | |
| # This will only be available when run by workflow_dispatch otherwise will checkout branch/commit that triggered the workflow | |
| ref: ${{ steps.inputs-and-secrets.outputs.COMMIT }} | |
| submodules: recursive | |
| fetch-depth: 200 | |
| lfs: 'true' | |
| # Check which files have changed to only run appropriate tests and checks | |
| - name: Backend files changed | |
| id: backend-files-changed | |
| if: github.event_name == 'pull_request' | |
| uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45 | |
| with: | |
| files_ignore: | | |
| .ci_cd/** | |
| deployment/** | |
| ui/** | |
| **/*.md | |
| **/*.yml | |
| yarn.lock | |
| # Check which files have changed to only run appropriate tests and checks | |
| - name: UI files changed | |
| id: ui-files-changed | |
| if: github.event_name == 'pull_request' | |
| uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45 | |
| with: | |
| files: | | |
| ui/** | |
| files_ignore: | | |
| **/*.md | |
| **/*.yml | |
| - name: Set skip backend manager tests | |
| id: skip-backend-tests | |
| if: steps.backend-files-changed.outputs.any_modified != 'true' | |
| run: echo "value=true" >> $GITHUB_OUTPUT | |
| - name: Set skip UI component tests | |
| id: skip-ui-component-tests | |
| if: steps.ui-files-changed.outputs.any_modified != 'true' | |
| run: echo "value=true" >> $GITHUB_OUTPUT | |
| - name: Check if main repo | |
| id: is_main_repo | |
| run: | | |
| if [ -f manager/src/main/java/org/openremote/manager/Main.java ]; then | |
| echo "value=true" >> $GITHUB_OUTPUT | |
| else | |
| if [ -e 'openremote' ]; then | |
| echo "::error::openremote submodule no longer supported, see custom project template!" | |
| exit 1 | |
| fi | |
| echo "value=false" >> $GITHUB_OUTPUT | |
| echo "repository=$(sed -E 's#(.+)/.+#\1/openremote#' <<< $GITHUB_REPOSITORY)" >> $GITHUB_OUTPUT | |
| version=$(cat gradle.properties | grep openremoteVersion | sed -E 's#.+=(.+)#\1#' | xargs) | |
| if [ -z "$version" ]; then | |
| echo "openremoteVersion must be set in gradle.properties" | |
| exit 1 | |
| fi | |
| echo "ref=$version" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Checkout main repo | |
| uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 | |
| if: ${{ steps.is_main_repo.outputs.value == 'false' }} | |
| with: | |
| repository: ${{ steps.is_main_repo.outputs.repository }} | |
| path: openremote | |
| ref: ${{ steps.is_main_repo.outputs.ref }} | |
| sparse-checkout: | | |
| .ci_cd | |
| profile | |
| sparse-checkout-cone-mode: false | |
| - name: Install node_modules | |
| if: steps.ui-files-changed.outputs.any_modified == 'true' | |
| run: yarn --immutable | |
| - name: Check UI dependencies | |
| if: steps.ui-files-changed.outputs.any_modified == 'true' && steps.is_main_repo.outputs.value == 'true' | |
| run: npx knip -c ui/knip.jsonc | |
| - name: Check deployment build.gradle | |
| id: check_deployment_gradle | |
| uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3 | |
| with: | |
| files: "deployment/build.gradle" | |
| - name: Check deployment dockerfile | |
| id: check_deployment_dockerfile | |
| uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3 | |
| with: | |
| files: "deployment/Dockerfile" | |
| - name: Check ci_cd existence | |
| id: check_cicd_json | |
| uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3 | |
| with: | |
| files: ".ci_cd/ci_cd.json" | |
| - name: Set up JDK 21 and gradle cache | |
| id: java | |
| uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5 | |
| with: | |
| distribution: 'temurin' | |
| java-version: '21' | |
| cache: 'gradle' | |
| - name: Get version details | |
| id: get-version-details | |
| run: | | |
| set -o pipefail # exit build with error when pipes fail | |
| ./gradlew currentVersion | tee /tmp/currentVersion | |
| versionWithQualifier=$(grep "Project version" /tmp/currentVersion | sed 's#Project version: ##') | |
| version=$(sed -E 's#-.+##' <<< $versionWithQualifier) | |
| isRelease=false | |
| if [ "$version" == "$versionWithQualifier" ]; then | |
| isRelease=true | |
| fi | |
| echo "version=$version" >> $GITHUB_OUTPUT | |
| echo "version_with_qualifier=$versionWithQualifier" >> $GITHUB_OUTPUT | |
| echo "is_release=$isRelease" >> $GITHUB_OUTPUT | |
| - name: Process ci_cd.json file | |
| if: ${{ steps.check_cicd_json.outputs.files_exists == 'true' && (github.ref_type == 'tag' || (github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request')) }} | |
| id: ci-cd-output | |
| shell: python | |
| run: | | |
| import json | |
| import os | |
| eventName = os.getenv('GITHUB_EVENT_NAME') | |
| refName = os.getenv('GITHUB_REF_NAME') | |
| refType = os.getenv('GITHUB_REF_TYPE') | |
| repoOwner = os.getenv('GITHUB_REPOSITORY_OWNER') | |
| isMainRepo = os.getenv('IS_MAIN_REPO') == 'true' | |
| isRelease = os.getenv('IS_RELEASE') == 'true' | |
| managerVersion = os.getenv('MANAGER_VERSION') | |
| version = os.getenv('VERSION') | |
| deploys = None | |
| dockerPublishTags = None | |
| mavenPublishTag = None | |
| npmPublishTag = None | |
| deployEnvironment = None | |
| eventConfig = () | |
| f = open(".ci_cd/ci_cd.json") | |
| data = json.load(f) | |
| f.close() | |
| if isRelease: | |
| eventName = "release" | |
| if data is not None and eventName in data: | |
| eventConfig = data[eventName] | |
| if eventName == "push" and refType == "branch" and refName in eventConfig: | |
| eventConfig = eventConfig[refName] | |
| if eventConfig is not None: | |
| deploys = eventConfig['deploy'] if 'deploy' in eventConfig else None | |
| if 'distribute' in eventConfig: | |
| if 'docker' in eventConfig['distribute']: | |
| dockerPublishTags = eventConfig['distribute']['docker'] | |
| if 'maven' in eventConfig['distribute']: | |
| mavenPublishTag = eventConfig['distribute']['maven'] | |
| if 'npm' in eventConfig['distribute']: | |
| npmPublishTag = eventConfig['distribute']['npm'] | |
| if isMainRepo and ((eventName == "release" and isRelease) or (eventName != "release" and not isRelease)): | |
| if dockerPublishTags is not None: | |
| if "$version" in dockerPublishTags: | |
| firstDockerTag = version | |
| dockerPublishTags = dockerPublishTags.replace("$version", version) | |
| else: | |
| dockerPublishTags = dockerPublishTags.replace("$version", version) | |
| firstDockerTag = dockerPublishTags.split(",")[0] | |
| os.system(f"echo 'firstDockerTag={firstDockerTag}' >> $GITHUB_OUTPUT") | |
| os.system(f" echo 'Manager tags to push to docker hub: {dockerPublishTags}'") | |
| dockerPublishTags = " ".join(map(lambda t: f"-t openremote/manager:{t.strip()}", dockerPublishTags.split(","))) | |
| if repoOwner == 'openremote': | |
| os.system(f"echo 'dockerTags={dockerPublishTags}' >> $GITHUB_OUTPUT") | |
| if mavenPublishTag is not None and repoOwner == 'openremote': | |
| mavenPublishTag = mavenPublishTag.replace("$version", version) | |
| os.system(f" echo 'Maven publish version: {mavenPublishTag}'") | |
| os.system(f"echo 'mavenTag={mavenPublishTag}' >> $GITHUB_OUTPUT") | |
| if npmPublishTag is not None and repoOwner == 'openremote': | |
| npmPublishTag = npmPublishTag.replace("$version", version) | |
| os.system(f" echo 'npm publish version: {npmPublishTag}'") | |
| os.system(f"echo 'npmTag={npmPublishTag}' >> $GITHUB_OUTPUT") | |
| deployStr = None | |
| if deploys is not None and repoOwner == 'openremote': | |
| if not isinstance(deploys, list): | |
| deploys = [deploys] | |
| deployStr = "" | |
| managerTagDefault = '#ref' | |
| for deploy in deploys: | |
| if 'environment' in deploy: | |
| deployStr += deploy['environment'] | |
| deployStr += ":" | |
| if 'managerTag' in deploy: | |
| if isMainRepo: | |
| deployStr += deploy['managerTag'] | |
| else: | |
| # Custom projects manager docker image must match the openremoteVersion from gradle.properties | |
| deployStr += managerVersion | |
| else: | |
| if isMainRepo: | |
| os.system("echo 'Manager tag not specified so using commit SHA'") | |
| deployStr += managerTagDefault | |
| else: | |
| # Custom projects manager docker image must match the openremoteVersion from gradle.properties | |
| deployStr += managerVersion | |
| deployStr += ";" | |
| deployStr = deployStr.rstrip(";") | |
| if deployStr is not None and len(deployStr) > 0: | |
| print(f"Deployments to deploy: {deployStr}") | |
| os.system(f"echo 'deploys={deployStr}' >> $GITHUB_OUTPUT") | |
| env: | |
| IS_MAIN_REPO: ${{ steps.is_main_repo.outputs.value }} | |
| MANAGER_VERSION: ${{ steps.is_main_repo.outputs.ref }} | |
| IS_RELEASE: ${{ steps.get-version-details.outputs.is_release }} | |
| VERSION: ${{ steps.get-version-details.outputs.version }} | |
| - name: Sanitize deployments value | |
| id: deployments | |
| shell: python | |
| run: | | |
| import os | |
| import sys | |
| repoOwner = os.getenv('GITHUB_REPOSITORY_OWNER') | |
| isMainRepo = os.getenv('IS_MAIN_REPO') == 'true' | |
| deployments = os.getenv('DEPLOYMENTS') | |
| eventName = os.getenv('GITHUB_EVENT_NAME') | |
| refType = os.getenv('GITHUB_REF_TYPE') | |
| inputTag = os.getenv('INPUT_MANAGER_TAG') | |
| inputEnv = os.getenv('INPUT_ENVIRONMENT') | |
| managerVersion = os.getenv('MANAGER_VERSION') | |
| if eventName == 'workflow_dispatch' and refType != 'tag' and repoOwner == 'openremote': | |
| if isMainRepo: | |
| tag=inputTag | |
| if not inputTag: | |
| tag='#ref' | |
| else: | |
| tag=managerVersion | |
| deployments=f'{inputEnv}:{tag}' | |
| if not isMainRepo: | |
| if "-SNAPSHOT" in deployments: | |
| os.system("echo 'SNAPSHOT manager docker images not currently supported for custom project deployment'") | |
| sys.exit(1) | |
| os.system(f"echo 'value={deployments}' >> $GITHUB_OUTPUT") | |
| pushSnapshot = os.getenv('INPUT_PUSH_SNAPSHOT') | |
| if pushSnapshot == 'true' and repoOwner != 'openremote': | |
| os.system("echo 'Only Openremote organisation can push to ECR'") | |
| sys.exit(1) | |
| else: | |
| os.system(f"echo 'pushSnapshot={pushSnapshot}' >> $GITHUB_OUTPUT") | |
| env: | |
| IS_MAIN_REPO: ${{ steps.is_main_repo.outputs.value }} | |
| MANAGER_VERSION: ${{ steps.is_main_repo.outputs.ref }} | |
| DEPLOYMENTS: ${{ steps.ci-cd-output.outputs.deploys }} | |
| INPUT_ENVIRONMENT: ${{ steps.inputs-and-secrets.outputs.ENVIRONMENT }} | |
| INPUT_MANAGER_TAG: ${{steps.inputs-and-secrets.outputs.MANAGER_TAG }} | |
| INPUT_PUSH_SNAPSHOT: ${{ steps.inputs-and-secrets.outputs.PUSH_SNAPSHOT }} | |
| - name: Define backend test command | |
| id: test-backend-command | |
| if: ${{ steps.skip-backend-tests.outputs.value != 'true' }} | |
| run: echo "value=./gradlew test -x npmTest" >> $GITHUB_OUTPUT | |
| - name: Define UI component test command | |
| id: test-ui-components-command | |
| if: ${{ steps.skip-ui-component-tests.outputs.value != 'true' }} | |
| run: echo "value=./gradlew -p ui/component npmTest" >> $GITHUB_OUTPUT | |
| - name: Define UI app test command | |
| id: test-ui-apps-command | |
| run: echo "value=./gradlew -p ui/app npmTest" >> $GITHUB_OUTPUT | |
| - name: Define manager docker build command | |
| id: manager-docker-command | |
| if: ${{ steps.is_main_repo.outputs.value == 'true' }} | |
| shell: bash | |
| run: | | |
| buildPath="manager/build/install/manager" | |
| commitSha=$(git rev-parse HEAD) | |
| commitShaShort=$(git rev-parse --short HEAD) | |
| if [ "$(uname -m)" == "aarch64" ]; then | |
| PLATFORM=linux/aarch64 | |
| fi | |
| if [ -n "$MANAGER_TAGS" ] || [[ "$DEPLOYMENTS" == *"#ref"* ]] || [ -n "$TEST_UI_CMD" ]; then | |
| if [ -n "$MANAGER_TAGS" ]; then | |
| command="docker build --push --build-arg GIT_COMMIT=$commitSha --platform linux/amd64,linux/aarch64 $MANAGER_TAGS $buildPath && docker build --build-arg GIT_COMMIT=$commitSha --platform ${PLATFORM:-linux/amd64} --load -t openremote/manager:$commitShaShort $buildPath" | |
| echo "pushToDockerHub=true" >> $GITHUB_OUTPUT | |
| echo "managerDockerImage=openremote/manager:$FIRST_MANAGER_TAG" >> $GITHUB_OUTPUT | |
| else | |
| if [ "$PUSH_SNAPSHOT" == "true" ]; then | |
| command="docker build --push --build-arg GIT_COMMIT=$commitSha --platform linux/amd64,linux/aarch64 -t ${{ steps.inputs-and-secrets.outputs.OR_AWS_DEVELOPERS_ACCOUNT_ID }}.dkr.ecr.eu-west-1.amazonaws.com/openremote/manager:SNAPSHOT-$commitShaShort $buildPath && docker build --build-arg GIT_COMMIT=$commitSha --platform ${PLATFORM:-linux/amd64} --load -t openremote/manager:$commitShaShort $buildPath" | |
| else | |
| command="docker build --build-arg GIT_COMMIT=$commitSha --platform ${PLATFORM:-linux/amd64} --load -t openremote/manager:$commitShaShort $buildPath && docker build --build-arg GIT_COMMIT=$commitSha --platform ${OTHER_PLATFORM:-linux/aarch64} -t openremote/manager:$commitShaShort $buildPath" | |
| fi | |
| echo "managerDockerImage=openremote/manager:$commitShaShort" >> $GITHUB_OUTPUT | |
| fi | |
| echo "value=$command" >> $GITHUB_OUTPUT | |
| fi | |
| echo "buildPath=$buildPath" >> $GITHUB_OUTPUT | |
| echo "refTag=$commitShaShort" >> $GITHUB_OUTPUT | |
| env: | |
| FIRST_MANAGER_TAG: ${{ steps.ci-cd-output.outputs.firstDockerTag }} | |
| MANAGER_TAGS: ${{ steps.ci-cd-output.outputs.dockerTags }} | |
| DEPLOYMENTS: ${{ steps.deployments.outputs.value }} | |
| PUSH_SNAPSHOT: ${{ steps.deployments.outputs.pushSnapshot }} | |
| TEST_UI_CMD: ${{ steps.test-ui-apps-command.outputs.value }} | |
| - name: Define maven publish command | |
| id: maven-publish-command | |
| if: ${{ steps.ci-cd-output.outputs.mavenTag != '' }} | |
| shell: bash | |
| run: | | |
| command="./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository --no-parallel -PsigningKey=$MAVEN_SIGNING_KEY -PsigningPassword=$MAVEN_SIGNING_PASSWORD -PpublishUsername=$MAVEN_USERNAME -PpublishPassword=$MAVEN_PASSWORD" | |
| echo "value=$command" >> $GITHUB_OUTPUT | |
| env: | |
| MAVEN_TAG: ${{ steps.ci-cd-output.outputs.mavenTag }} | |
| MAVEN_SIGNING_PASSWORD: ${{ steps.inputs-and-secrets.outputs._TEMP_MAVEN_SIGNING_PASSWORD }} | |
| MAVEN_SIGNING_KEY: ${{ steps.inputs-and-secrets.outputs._TEMP_MAVEN_SIGNING_KEY }} | |
| MAVEN_USERNAME: ${{ steps.inputs-and-secrets.outputs._TEMP_MAVEN_USERNAME }} | |
| MAVEN_PASSWORD: ${{ steps.inputs-and-secrets.outputs._TEMP_MAVEN_PASSWORD }} | |
| - name: Define deployment docker build command | |
| id: deployment-docker-command | |
| shell: bash | |
| run: | | |
| if [ "$DEPLOYMENT_DOCKERFILE_EXISTS" == 'true' ]; then | |
| if [ "$DEPLOYMENT_GRADLE_EXISTS" != 'true' ]; then | |
| echo "Deployment must have a build.gradle file to prepare the deployment files in the deployment/build dir" | |
| exit 1 | |
| fi | |
| buildPath="deployment/build" | |
| commitSha=$(git rev-parse HEAD) | |
| commitShaShort=$(git rev-parse --short HEAD) | |
| echo "buildPath=$buildPath" >> $GITHUB_OUTPUT | |
| echo "refTag=$commitShaShort" >> $GITHUB_OUTPUT | |
| if [ -n "$DEPLOYMENTS" ]; then | |
| command="docker build --build-arg GIT_COMMIT=$commitSha --platform linux/amd64,linux/aarch64 -t openremote/deployment:$commitShaShort $buildPath" | |
| echo "value=$command" >> $GITHUB_OUTPUT | |
| fi | |
| fi | |
| env: | |
| DEPLOYMENTS: ${{ steps.deployments.outputs.value }} | |
| DEPLOYMENT_DOCKERFILE_EXISTS: ${{ steps.check_deployment_dockerfile.outputs.files_exists }} | |
| DEPLOYMENT_GRADLE_EXISTS: ${{ steps.check_deployment_gradle.outputs.files_exists }} | |
| - name: Define installDist command | |
| id: install-command | |
| shell: bash | |
| run: | | |
| if [ -n "$MANAGER_DOCKER_CMD" ]; then | |
| echo "value=./gradlew installDist" >> $GITHUB_OUTPUT | |
| elif [ -n "$DEPLOYMENT_DOCKER_CMD" ]; then | |
| echo "value=./gradlew -p deployment installDist" >> $GITHUB_OUTPUT | |
| fi | |
| env: | |
| MANAGER_DOCKER_CMD: ${{ steps.manager-docker-command.outputs.value }} | |
| DEPLOYMENT_DOCKER_CMD: ${{ steps.deployment-docker-command.outputs.value }} | |
| - name: Login to DockerHub | |
| if: ${{ steps.manager-docker-command.outputs.pushToDockerHub == 'true' }} | |
| uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 | |
| with: | |
| username: ${{ steps.inputs-and-secrets.outputs._TEMP_DOCKERHUB_USER }} | |
| password: ${{ steps.inputs-and-secrets.outputs._TEMP_DOCKERHUB_PASSWORD }} | |
| - name: Configure AWS Credentials | |
| if: ${{ steps.deployments.outputs.pushSnapshot == 'true' }} | |
| uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 # v5.0.0 | |
| with: | |
| aws-region: eu-west-1 | |
| role-to-assume: "arn:aws:iam::${{ steps.inputs-and-secrets.outputs.OR_AWS_DEVELOPERS_ACCOUNT_ID }}:role/${{ steps.inputs-and-secrets.outputs.OR_GH_AWS_ECR_PUSH_ROLE }}" | |
| - name: Login to private ECR | |
| if: ${{ steps.deployments.outputs.pushSnapshot == 'true' }} | |
| uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 | |
| - name: set up QEMU | |
| if: ${{ steps.manager-docker-command.outputs.value != '' || steps.deployment-docker-command.outputs.value != '' }} | |
| uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 | |
| with: | |
| platforms: linux/amd64,linux/aarch64 | |
| - name: install buildx | |
| if: ${{ steps.manager-docker-command.outputs.value != '' || steps.deployment-docker-command.outputs.value != '' }} | |
| id: buildx | |
| uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3 | |
| with: | |
| version: latest | |
| install: true | |
| - name: Get yarn cache directory path | |
| id: yarn-cache-dir-path | |
| run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT | |
| - name: Yarn cache | |
| uses: actions/cache@d4323d4df104b026a6aa633fdb11d772146be0bf # v4 | |
| if: steps.skip-cicd.outputs.value != 'true' | |
| id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) | |
| with: | |
| path: ${{ steps.yarn-cache-dir-path.outputs.dir }} | |
| key: ${{ runner.os }}-yarn---${{ hashFiles('**/yarn.lock') }} | |
| restore-keys: | | |
| ${{ runner.os }}-yarn--- | |
| - name: Output info | |
| if: steps.skip-cicd.outputs.value != 'true' | |
| run: | | |
| echo "************************************************************" | |
| echo "************** INFO *******************" | |
| echo "************************************************************" | |
| echo 'Trigger event: ${{ github.event_name }}' | |
| echo 'Is main repo: ${{ steps.is_main_repo.outputs.value == 'true' }}' | |
| echo 'Has deployment dockerfile: ${{ steps.check_deployment_dockerfile.outputs.files_exists == 'true' }}' | |
| echo 'OpenRemote version: ${{ steps.is_main_repo.outputs.ref }}' | |
| echo 'Manager commit SHA: ${{ steps.manager-docker-command.outputs.refTag }}' | |
| echo 'Deployment commit SHA: ${{ steps.deployment-docker-command.outputs.refTag }}' | |
| echo 'Deployments: ${{ steps.deployments.outputs.value }}' | |
| echo 'Test backend command: ${{ steps.test-backend-command.outputs.value }}' | |
| echo 'Test UI components command: ${{ steps.test-ui-components-command.outputs.value }}' | |
| echo 'End-To-End test command: ${{ steps.test-ui-apps-command.outputs.value }}' | |
| echo 'Manager docker build command: ${{ steps.manager-docker-command.outputs.value }}' | |
| echo 'Manager docker image: ${{ steps.manager-docker-command.outputs.managerDockerImage }}' | |
| echo 'Maven publish command: ${{ steps.maven-publish-command.outputs.value }}' | |
| echo 'Deployment docker build command: ${{ steps.deployment-docker-command.outputs.value }}' | |
| echo 'InstallDist command: ${{ steps.install-command.outputs.value }}' | |
| echo "Java version: $(java --version)" | |
| echo "Yarn version: $(yarn -v)" | |
| echo "Node version: $(node -v)" | |
| echo "************************************************************" | |
| echo "************************************************************" | |
| - name: Pull docker images | |
| if: ${{ steps.test-backend-command.outputs.value != '' || steps.test-ui-apps-command.outputs.value != '' }} | |
| run: | | |
| # Only need keycloak and postgres services for backend and end-to-end testing | |
| docker compose -f profile/dev-testing.yml pull | |
| # Use docker layer caching to speed up image building | |
| - uses: jpribyl/action-docker-layer-caching@c632825d12ec837065f49726ea27ddd40bcc7894 # v0.1.1 | |
| if: ${{ steps.manager-docker-command.outputs.value != '' || steps.deployment-docker-command.outputs.value != '' }} | |
| # Ignore the failure of a step and avoid terminating the job. | |
| continue-on-error: true | |
| - name: Update version in package.json files | |
| id: update-package-json-files | |
| run: | | |
| VERSION=$(awk '{print tolower($0)}' <<< $VERSION) | |
| QUALIFIER=$(sed -E 's/.+-([a-z]+).*/\1/g' <<< $VERSION) | |
| if [ "$QUALIFIER" == "$VERSION" ]; then | |
| QUALIFIER="" | |
| fi | |
| if [ "$QUALIFIER" == "" ]; then | |
| TAG="latest" | |
| else | |
| if [ "$QUALIFIER" == "snapshot" ]; then | |
| # Add a timestamp to snapshot versions | |
| VERSION="$VERSION.$(date +'%Y%m%d%H%M%S')" | |
| else | |
| # Separate qualifiers from numbers of alpha, beta, rc versions | |
| VERSION=$(sed "s/$QUALIFIER/$QUALIFIER./g" <<< $VERSION) | |
| fi | |
| TAG="$QUALIFIER" | |
| fi | |
| find ui -maxdepth 3 -name package.json | xargs -I{} sed -i -E "s#\"version\": \".+\",#\"version\": \"$VERSION\",#" {} | |
| echo "tag=$TAG" >> $GITHUB_OUTPUT | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| env: | |
| VERSION: ${{ steps.get-version-details.outputs.version_with_qualifier }} | |
| - name: Check for outdated translations | |
| if: steps.is_main_repo.outputs.value == 'true' | |
| run: | | |
| set -e | |
| # Run the tests | |
| ./gradlew -p ui/app/shared processTranslations --no-parallel | |
| out=$(git status --porcelain | grep "ui/app/shared/locales" || true) | |
| if [[ -z "$out" ]]; then | |
| echo "✅ No pipeline changes detected." | |
| else | |
| echo "❌ Pipeline files have changed. Please run './gradlew -p ui/app/shared processTranslations --no-parallel'" | |
| echo "$out" | |
| exit 1 | |
| fi | |
| timeout-minutes: 20 | |
| - name: Run backend tests | |
| id: run-backend-tests | |
| if: ${{ steps.test-backend-command.outputs.value != '' }} | |
| run: | | |
| composeProfile='profile/dev-testing.yml' | |
| # Make temp dir and set mask to 777 as docker seems to run as root | |
| mkdir -p tmp | |
| chmod 777 tmp | |
| # Define cleanup command | |
| echo "cleanup=docker compose -f $composeProfile down" >> $GITHUB_OUTPUT | |
| # Start the stack | |
| echo "docker compose -f ${composeProfile} up -d --no-build" | |
| docker compose -f ${composeProfile} up -d --no-build | |
| # Run the tests | |
| ${{ steps.test-backend-command.outputs.value }} | |
| timeout-minutes: 20 | |
| #continue-on-error: true | |
| - name: Archive backend test results | |
| if: always() | |
| uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 | |
| with: | |
| name: backend-test-results | |
| path: test/build/reports/tests | |
| - name: Archive coverage report | |
| uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 | |
| with: | |
| name: coverage-report | |
| path: test/build/reports/jacoco/test/html | |
| - name: Cleanup backend tests | |
| if: ${{ steps.test-backend-command.outputs.value != '' }} | |
| run: ${{ steps.run-backend-tests.outputs.cleanup }} | |
| - name: Run install dist | |
| if: steps.install-command.outputs.value != '' | |
| shell: python | |
| run: | | |
| import json | |
| import os | |
| import sys | |
| import subprocess | |
| inputsAndSecrets = json.loads(os.getenv("INPUTS_AND_SECRETS")) | |
| # Output inputs and secrets as environment variables for build | |
| for key, value in inputsAndSecrets.items(): | |
| if "." in key: | |
| continue | |
| # Look for temp and env prefixed keys | |
| if key.startswith("_"): | |
| if key.startswith("_TEMP_"): | |
| key = key.replace("_TEMP_", "") | |
| else: | |
| continue | |
| os.system(f"echo 'Setting environment variable {key}...'") | |
| os.putenv(key, value) | |
| buildCmd = os.getenv("CMD") | |
| result = subprocess.run(f"{buildCmd}", shell=True) | |
| if result.returncode != 0: | |
| os.system("echo 'installDist failed'") | |
| sys.exit(result.returncode) | |
| env: | |
| CMD: ${{ steps.install-command.outputs.value }} | |
| INPUTS_AND_SECRETS: ${{ toJSON(steps.inputs-and-secrets.outputs) }} | |
| timeout-minutes: 20 | |
| - name: Run NPM publish dry run | |
| if: steps.is_main_repo.outputs.value == 'true' && github.ref != 'refs/heads/master' | |
| run: yarn workspaces foreach --all --no-private --topological npm publish --dry-run | |
| timeout-minutes: 20 | |
| - name: Run install playwright browser(s) | |
| if: (steps.test-ui-components-command.outputs.value != '' || steps.test-ui-apps-command.outputs.value != '') && steps.is_main_repo.outputs.value == 'true' | |
| run: npx playwright install --with-deps chromium | |
| timeout-minutes: 20 | |
| - name: Run UI component tests | |
| if: steps.test-ui-components-command.outputs.value != '' && steps.is_main_repo.outputs.value == 'true' | |
| run: ${{ steps.test-ui-components-command.outputs.value }} | |
| timeout-minutes: 20 | |
| # continue-on-error: true | |
| - name: Archive UI component test results | |
| if: always() | |
| uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 | |
| with: | |
| name: ui-component-test-results | |
| path: ui/component/*/component-test-report/** | |
| - name: Run manager docker build command | |
| if: steps.manager-docker-command.outputs.value != '' | |
| run: | | |
| ${{ steps.manager-docker-command.outputs.value }} | |
| - name: Run UI app tests | |
| # Skip UI app tests for custom projects until we want to use them there. | |
| # .ci_cd/host_init/healthy.sh is missing initially for custom projects .ci_cd/host_init/healthy.sh | |
| if: steps.test-ui-apps-command.outputs.value != '' && steps.is_main_repo.outputs.value == 'true' | |
| run: | | |
| composeProfile='profile/dev-ui.yml' | |
| if [ $IS_MAIN_REPO == 'false' ]; then | |
| composeProfile="openremote/$composeProfile" | |
| fi | |
| # Start the stack | |
| MANAGER_VERSION=$MANAGER_TAG docker compose -f $composeProfile up -d --no-build | |
| # Wait for services to be healthy | |
| bash .ci_cd/host_init/healthy.sh profile | |
| # Run the tests | |
| ${{ steps.test-ui-apps-command.outputs.value }} | |
| env: | |
| IS_MAIN_REPO: ${{ steps.is_main_repo.outputs.value }} | |
| MANAGER_TAG: ${{ steps.manager-docker-command.outputs.refTag }} | |
| timeout-minutes: 20 | |
| # continue-on-error: true | |
| - name: Archive UI app test results | |
| if: always() | |
| uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 | |
| with: | |
| name: ui-app-test-results | |
| path: ui/app/*/app-test-report/** | |
| - name: Scan manager docker image | |
| if: steps.manager-docker-command.outputs.pushToDockerHub == 'true' | |
| uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2 | |
| id: manager-anchore-scan | |
| with: | |
| image: ${{ steps.manager-docker-command.outputs.managerDockerImage }} | |
| fail-build: false | |
| severity-cutoff: critical | |
| - name: Upload Anchore scan SARIF report | |
| if: | | |
| !cancelled() && | |
| steps.manager-docker-command.outputs.pushToDockerHub == 'true' | |
| uses: github/codeql-action/upload-sarif@c8e3174949dcd2ceb71718aeaa53fee4dc9052f2 # v4.31.7 | |
| with: | |
| sarif_file: ${{ steps.manager-anchore-scan.outputs.sarif }} | |
| - name: Inspect Anchore scan SARIF report | |
| if: | | |
| !cancelled() && | |
| steps.manager-docker-command.outputs.pushToDockerHub == 'true' | |
| run: cat ${{ steps.manager-anchore-scan.outputs.sarif }} | |
| - name: Run maven publish command | |
| if: steps.maven-publish-command.outputs.value != '' | |
| run: | | |
| ${{ steps.maven-publish-command.outputs.value }} | |
| - name: Run npm publish command | |
| if: ${{ steps.ci-cd-output.outputs.npmTag != '' }} | |
| run: | | |
| echo 'npmRegistries:' >> .yarnrc.yml | |
| echo ' "https://registry.yarnpkg.com":' >> .yarnrc.yml | |
| echo " npmAuthToken: $NPM_AUTH_TOKEN" >> .yarnrc.yml | |
| yarn workspaces foreach --all --no-private --topological npm publish --tag $TAG | |
| env: | |
| NPM_AUTH_TOKEN: ${{ steps.inputs-and-secrets.outputs._TEMP_NPM_AUTH_TOKEN }} | |
| TAG: ${{ steps.update-package-json-files.outputs.tag }} | |
| - name: Run deployment docker command | |
| if: steps.deployment-docker-command.outputs.value != '' | |
| run: | | |
| ${{ steps.deployment-docker-command.outputs.value }} | |
| - name: Do deployments | |
| if: steps.deployments.outputs.value != '' | |
| shell: python | |
| run: | | |
| import json | |
| import os | |
| import sys | |
| import subprocess | |
| deployments = os.getenv("DEPLOYMENTS") | |
| deployments = deployments.split(";") | |
| managerRef = os.getenv("MANAGER_REF") | |
| deploymentRef = os.getenv("DEPLOYMENT_REF") | |
| isMainRepo = os.getenv("IS_MAIN_REPO") == 'true' | |
| inputsAndSecrets = json.loads(os.getenv("INPUTS_AND_SECRETS")) | |
| ipv4 = os.getenv("IPV4") | |
| ipv6 = os.getenv("IPV6") | |
| failure = False | |
| # Clean-up AWS credentials related variable that might have been set by Configure AWS Credentials for ECR upload | |
| os.environ.pop("AWS_ACCESS_KEY_ID", None) | |
| os.environ.pop("AWS_SECRET_ACCESS_KEY", None) | |
| os.environ.pop("AWS_SESSION_TOKEN", None) | |
| # Determine deploy script to use | |
| deployScript = ".ci_cd/deploy.sh" | |
| if not os.path.exists(deployScript) and not isMainRepo: | |
| deployScript = "openremote/.ci_cd/deploy.sh" | |
| if not os.path.exists(deployScript): | |
| os.system(f"Deploy script not found '{deployScript}'") | |
| sys.exit(1) | |
| for deployment in deployments: | |
| dep = deployment.split(":") | |
| env = dep[0] | |
| managerTag = dep[1] | |
| managerTagFound = True | |
| os.putenv("MANAGER_TAG", managerTag) | |
| os.putenv("ENVIRONMENT", env) | |
| # Clean stale ssh credentials and temp files | |
| os.system("rm temp.env 2>/dev/null") | |
| os.system("rm ssh.key 2>/dev/null") | |
| os.system("rm -r temp 2>/dev/null") | |
| os.system("mkdir temp") | |
| # ------------------------------------------------------ | |
| # Output env variables to temp env file for POSIX shell | |
| # ------------------------------------------------------ | |
| # Output inputs and secrets (spacial handling for SSH_KEY and some other variables) | |
| # _$ENV_ prefixed keys are output last (to override any non env specific keys) | |
| environment = (env if env else "").upper() | |
| prefix = "_" + environment + "_" | |
| for key, value in inputsAndSecrets.items(): | |
| if "." in key: | |
| continue | |
| envFile = "temp/env" | |
| # Look for temp and env prefixed keys | |
| if key.startswith("_"): | |
| if key.startswith("_TEMP_"): | |
| key = key.replace("_TEMP_", "") | |
| envFile = "temp.env" | |
| elif key.startswith(prefix): | |
| key = key.replace(prefix, "") | |
| else: | |
| continue | |
| if key == "github_token": | |
| continue | |
| else: | |
| os.system(f"echo 'Secret found {key}...'") | |
| if key == "SSH_KEY": | |
| os.system(f"echo \"{value}\" > ssh.key") | |
| else: | |
| lines = len(value.split("\n")) | |
| if lines > 1: | |
| os.system(f"echo '{key}='\"'\"'' >> {envFile}") | |
| os.system(f"echo '{value}'\"'\"'' >> {envFile}") | |
| else: | |
| os.system(f"echo '{key}='\"'\"'{value}'\"'\"'' >> {envFile}") | |
| # Output new line | |
| os.system(f"echo '\n' >> {envFile}") | |
| # Output env file if exists | |
| if os.path.exists(".ci_cd/env/.env"): | |
| os.system(f"echo 'Outputting .ci_cd/env/.env to temp/env'") | |
| os.system("cat .ci_cd/env/.env >> temp/env") | |
| # Output new line | |
| os.system(f"echo '\n' >> {envFile}") | |
| # Output environment specific env file if exists | |
| if env is not None and env != '' and os.path.exists(f".ci_cd/env/{env}.env"): | |
| os.system(f"echo 'Outputting .ci_cd/env/{env}.env to temp/env'") | |
| os.system(f"cat .ci_cd/env/{env}.env >> temp/env") | |
| # Output new line | |
| os.system(f"echo '\n' >> {envFile}") | |
| # Set CIDR environment variable | |
| if ipv4 is not None and ipv4 != '': | |
| os.putenv("CIDR", ipv4 + '/32') | |
| elif ipv6 is not None and ipv6 != '': | |
| os.putenv("CIDR", ipv6 + '/64') | |
| # Execute deploy script | |
| os.system(f"echo 'Executing deploy script for deployment: managerTag={managerTag} deploymentTag={deploymentRef} environment={env}'") | |
| # Uncomment this in combination with the SSH debug step afterwards to debug deployment script | |
| #sys.exit(0) | |
| result = subprocess.run(f"bash {deployScript}", shell=True) | |
| if result.returncode != 0: | |
| os.system(f"echo 'Deployment failed: managerTag={managerTag} deploymentTag={deploymentRef} environment={env}'") | |
| failure = True | |
| continue | |
| if failure == True: | |
| os.system("echo 'One or more deployments failed'") | |
| sys.exit(1) | |
| env: | |
| IS_MAIN_REPO: ${{ steps.is_main_repo.outputs.value }} | |
| DEPLOYMENTS: ${{ steps.deployments.outputs.value }} | |
| MANAGER_DOCKER_BUILD_PATH: ${{ steps.manager-docker-command.outputs.buildPath }} | |
| DEPLOYMENT_DOCKER_BUILD_PATH: ${{ steps.deployment-docker-command.outputs.buildPath }} | |
| MANAGER_REF: ${{ steps.manager-docker-command.outputs.refTag }} | |
| DEPLOYMENT_REF: ${{ steps.deployment-docker-command.outputs.refTag }} | |
| INPUTS_AND_SECRETS: ${{ toJSON(steps.inputs-and-secrets.outputs) }} | |
| IPV4: ${{ steps.ip-address.outputs.ipv4 }} | |
| IPV6: ${{ steps.ip-address.outputs.ipv6 }} | |
| # - name: Setup upterm session | |
| # uses: lhotari/action-upterm@b0357f23233f5ea6d58947c0c402e0631bab7334 # v1 | |
| # with: | |
| # ## limits ssh access and adds the ssh public keys of the listed GitHub users | |
| # limit-access-to-actor: true |