GitHub Actions. Начало работы, часть 2 из 2

19.06.2022

Теги: GitGitHubLinuxWeb-разработкаКонфигурацияСервер

Простой проект

Хорошо, общее представление о GitHub Actions у нас есть, теперь хотелось бы применить эти знания на практике. Давайте создадим небольшой проект, выложим его на GitHub, создадим для него workflow-файл, который будет запускать проверку кода линтером, выполнять сборку и деплоить на production сервер.

1. Деплой без GitHub

Итак, в директории проекта есть файл index.html и несколько css-файлов. Установлен npm-пакет browser-sync, чтобы сразу видеть изменения страницы при редактировании html и css файлов + пакеты для сборки проекта в директорию dist с минификацией html и css файлов. Файл package.json:

{
    "scripts": {
        "start": "browser-sync start --server src --no-notify --no-ui --cwd src --files index.html,css/**/*",
        "lint": "editorconfig-checker --exclude node_modules",
        "html": "html-minifier --remove-comments --collapse-whitespace --input-dir src --output-dir dist --file-ext html",
        "css": "postcss src/css/index.css --use postcss-import --use postcss-csso --no-map --output dist/css/index.css",
        "build": "npm run html && npm run css",
        "deploy": "rsync --archive --delete ./dist/ timeweb-hosting:/home/c/ca12345/github-workflow-example/public_html"
    },
    "devDependencies": {
        "browser-sync": "^2.27.10",
        "editorconfig-checker": "^4.0.2",
        "html-minifier": "^4.0.0",
        "postcss-cli": "^9.1.0",
        "postcss-csso": "^6.0.0",
        "postcss-import": "^14.1.0"
    }
}

Для деплоя проекта на хостинг TimeWeb используется утилита rsync. Строка timeweb-hosting определена в файле конфигурации ssh-клиента ~/.ssh/config.

Host timeweb-hosting
  User ca12345
  HostName tokmakov.msk.ru
  Port 22
  IdentityFile ~/.ssh/timeweb-hosting

Подключение к серверу с использованием ключей, которые предварительно надо создать и скопировать на сервер:

$ cd ~/.ssh/
$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/c/Users/Evgeniy/.ssh/id_rsa): timeweb-hosting
Enter passphrase (empty for no passphrase): Enter
Enter same passphrase again: Enter
Your identification has been saved in timeweb-hosting
Your public key has been saved in timeweb-hosting.pub
The key fingerprint is:
SHA256:Lo+iUk+SHnxMwPfJkeMny6vrYDPpNxArrLqrWpjwNm8 Evgeniy@TKMCOMP
$ ssh-copy-id -i ~/.ssh/timeweb-hosting.pub ca12345@tokmakov.msk.ru
/usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/c/Users/Evgeniy/.ssh/timeweb-hosting.pub"
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
ca12345@tokmakov.msk.ru's password: пароль

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh ca12345@tokmakov.msk.ru"
and check to make sure that only the key(s) you wanted were added.
$ ssh timeweb-hosting

##########################
Welcome to TimeWeb server!
##########################

Last login: Sat Jun 18 11:35:54 2022 from 123.123.123.123

Для запуска линтера, сборки проекта, деплоя на сервер:

$ npm run lint
$ npm run build
$ nmp run deploy
Внимательный читатель возможно уже заметил, что работа идет под Windows 10. Тогда откуда у меня утилита rsync? При установке Git для Windows устанавливается терминал, позволяющий работать с git из командной строки. Заодно из терминала можно запускать некоторые Linux-команды. Но можно расширить возможности терминала, добавив другие полезные утилиты. Как это сделать — читайте здесь.

2. GitHub Actions

Создаем новый репозиторий на GitHub, на локальном компьютере в директории проекта выполняем команды:

$ git init # создаем пустой репозиторий
$ git add --all  # добавляем все файлы проекта
$ git commit -m "Initial сommit" # первый коммит

Добавляем себе ссылку (pointer) на удаленный репозиторий:

$ git remote add origin git@github.com:tokmakov/github-workflow-example.git

Теперь удаленный репозиторий доступен как origin:

$ git remote
origin

Отправляем текущую ветку master (которая у нас сейчас всего одна) в удаленную ветку master репозитория origin:

$ git push origin master

Настроим текущую ветку master на отслеживание удаленной ветки master:

$ git branch -u origin/master
Branch master set up to track remote branch master from origin.

Теперь самое интересное — создаем локально yml-файл и выкладываем его на GitHub:

name: Lint and deploy

on:
  push:

jobs:
  lint: # проверка кода линтером
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16

      - name: Lint new code
        run: |
          npm ci
          npm run lint

  deploy: # деплой на prod сервер
    # на сервер выкладываем только ветку master
    if: ${{ github.ref == 'refs/heads/master' }}
    runs-on: ubuntu-latest
    # выкладываем, только если проверка успешна
    needs: [lint]
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 16

      - name: Build project
        run: |
          npm ci
          npm run build
      - name: Add ssh key
        run: |
          mkdir ~/.ssh
          echo "${{ secrets.KEY }}" > ~/.ssh/key
          chmod 600 ~/.ssh/key
      - name: Deploy project
        env:
          options: ssh -i ~/.ssh/key -o StrictHostKeyChecking=no
        run: |
          rsync -e "$options" --archive --delete ./dist/ ${{ secrets.USER }}@${{ secrets.HOST }}:${{ secrets.WWW }}

$ git add .github/workflows/deploy.yml
$ git commit -m "Add workflow file deploy.yml"
$ git push

Теперь, каждый раз, когда мы выполняем на своем компе команду git push, будет запускаться задание lint, которое проверит наш код. И если это задание отработает успешно — будет запущено задание deploy, которое соберет проект и выложит все файлы из директории dist на удаленный веб-сервер.

В workflow-файле используется контекст secrets — приватный ssh-ключ, имя пользователя, ip-адрес (домен) сервера, корневая директория веб-сервера. Так что все эти переменные контекста надо создать — на GitHub идем в Settings репозитория, слева находим ссылку Secrets, дальше Actions.

Чтобы получить приватный ключ, выполняем команду cat ~/.ssh/timeweb-hosting и копируем вывод в буфер обмена.

Кастомный action

В GitHub Marketplace есть отдельный раздел, посвященный Actions. Некоторые из них созданы известными компаниями, но бóльшая часть публикуется разработчиками. Но допустим, не удалось найти готовое решение для решения задачи. В таком случае можно воспользоваться инструкцией и содать кастомный action. Код action может написан на языке JavaScript или быть собран в виде Docker-образа.

Общий принцип создания action — нужен отдельный репозиторий, который будет содержать файл action.yml и все прочие файлы, необходимые для работы action.

name: Название action
author: Автор action
description: Описание action

inputs:
  # входные данные
outputs:
  # выходные данные

runs:
  # директивы запуска

Кроме того, action можно создать в отдельной директории внутри .github/actions. Эта директория должна содержать файл action.yml и все прочие файлы, необходимые для работы action. В этом случае обращение к этому action из workflow-файла будет иметь вид ./path/to/dir. Также необходимо скопировать файлы репозитрия внутрь временного сервера, где будет работать workflow.

|-- hello-world (repository)
|   |__ .github
|       └── workflows
|           └── my-first-workflow.yml
|       └── actions
|           |__ hello-world-action
|               └── action.yml 
jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ./.github/actions/hello-world-action

1. Action как Docker-образ

Наш кастомный action будет выполнять простой bash-скрипт. Этот скрипт будет обращаться к API сервиса JSON Placeholder и получать имя пользователя по идентификатору. Пользователей всего 10, идентификаторы — числа от 1 до 10.

https://jsonplaceholder.typicode.com/users
[
  {
    "id": 1,
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
      "street": "Kulas Light",
      "suite": "Apt. 556",
      "city": "Gwenborough",
      "zipcode": "92998-3874",
      "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
      }
    },
    "phone": "1-770-736-8031 x56442",
    "website": "hildegard.org",
    "company": {
      "name": "Romaguera-Crona",
      "catchPhrase": "Multi-layered client-server neural-net",
      "bs": "harness real-time e-markets"
    }
  },
  {
    "id": 2,
    "name": "Ervin Howell",
    "username": "Antonette",
    "email": "Shanna@melissa.tv",
    "address": {
      "street": "Victor Plains",
      "suite": "Suite 879",
      "city": "Wisokyburgh",
      "zipcode": "90566-7771",
      "geo": {
        "lat": "-43.9509",
        "lng": "-34.4618"
      }
    },
    "phone": "010-692-6593 x09125",
    "website": "anastasia.net",
    "company": {
      "name": "Deckow-Crist",
      "catchPhrase": "Proactive didactic contingency",
      "bs": "synergize scalable supply-chains"
    }
  },
  {
    "id": 3,
    "name": "Clementine Bauch",
    "username": "Samantha",
    "email": "Nathan@yesenia.net",
    "address": {
      "street": "Douglas Extension",
      "suite": "Suite 847",
      "city": "McKenziehaven",
      "zipcode": "59590-4157",
      "geo": {
        "lat": "-68.6102",
        "lng": "-47.0653"
      }
    },
    "phone": "1-463-123-4447",
    "website": "ramiro.info",
    "company": {
      "name": "Romaguera-Jacobson",
      "catchPhrase": "Face to face bifurcated interface",
      "bs": "e-enable strategic applications"
    }
  },
  {
    "id": 4,
    "name": "Patricia Lebsack",
    "username": "Karianne",
    "email": "Julianne.OConner@kory.org",
    "address": {
      "street": "Hoeger Mall",
      "suite": "Apt. 692",
      "city": "South Elvis",
      "zipcode": "53919-4257",
      "geo": {
        "lat": "29.4572",
        "lng": "-164.2990"
      }
    },
    "phone": "493-170-9623 x156",
    "website": "kale.biz",
    "company": {
      "name": "Robel-Corkery",
      "catchPhrase": "Multi-tiered zero tolerance productivity",
      "bs": "transition cutting-edge web services"
    }
  },
  {
    "id": 5,
    "name": "Chelsey Dietrich",
    "username": "Kamren",
    "email": "Lucio_Hettinger@annie.ca",
    "address": {
      "street": "Skiles Walks",
      "suite": "Suite 351",
      "city": "Roscoeview",
      "zipcode": "33263",
      "geo": {
        "lat": "-31.8129",
        "lng": "62.5342"
      }
    },
    "phone": "(254)954-1289",
    "website": "demarco.info",
    "company": {
      "name": "Keebler LLC",
      "catchPhrase": "User-centric fault-tolerant solution",
      "bs": "revolutionize end-to-end systems"
    }
  },
  {
    "id": 6,
    "name": "Mrs. Dennis Schulist",
    "username": "Leopoldo_Corkery",
    "email": "Karley_Dach@jasper.info",
    "address": {
      "street": "Norberto Crossing",
      "suite": "Apt. 950",
      "city": "South Christy",
      "zipcode": "23505-1337",
      "geo": {
        "lat": "-71.4197",
        "lng": "71.7478"
      }
    },
    "phone": "1-477-935-8478 x6430",
    "website": "ola.org",
    "company": {
      "name": "Considine-Lockman",
      "catchPhrase": "Synchronised bottom-line interface",
      "bs": "e-enable innovative applications"
    }
  },
  {
    "id": 7,
    "name": "Kurtis Weissnat",
    "username": "Elwyn.Skiles",
    "email": "Telly.Hoeger@billy.biz",
    "address": {
      "street": "Rex Trail",
      "suite": "Suite 280",
      "city": "Howemouth",
      "zipcode": "58804-1099",
      "geo": {
        "lat": "24.8918",
        "lng": "21.8984"
      }
    },
    "phone": "210.067.6132",
    "website": "elvis.io",
    "company": {
      "name": "Johns Group",
      "catchPhrase": "Configurable multimedia task-force",
      "bs": "generate enterprise e-tailers"
    }
  },
  {
    "id": 8,
    "name": "Nicholas Runolfsdottir V",
    "username": "Maxime_Nienow",
    "email": "Sherwood@rosamond.me",
    "address": {
      "street": "Ellsworth Summit",
      "suite": "Suite 729",
      "city": "Aliyaview",
      "zipcode": "45169",
      "geo": {
        "lat": "-14.3990",
        "lng": "-120.7677"
      }
    },
    "phone": "586.493.6943 x140",
    "website": "jacynthe.com",
    "company": {
      "name": "Abernathy Group",
      "catchPhrase": "Implemented secondary concept",
      "bs": "e-enable extensible e-tailers"
    }
  },
  {
    "id": 9,
    "name": "Glenna Reichert",
    "username": "Delphine",
    "email": "Chaim_McDermott@dana.io",
    "address": {
      "street": "Dayna Park",
      "suite": "Suite 449",
      "city": "Bartholomebury",
      "zipcode": "76495-3109",
      "geo": {
        "lat": "24.6463",
        "lng": "-168.8889"
      }
    },
    "phone": "(775)976-6794 x41206",
    "website": "conrad.com",
    "company": {
      "name": "Yost and Sons",
      "catchPhrase": "Switchable contextually-based project",
      "bs": "aggregate real-time technologies"
    }
  },
  {
    "id": 10,
    "name": "Clementina DuBuque",
    "username": "Moriah.Stanton",
    "email": "Rey.Padberg@karina.biz",
    "address": {
      "street": "Kattie Turnpike",
      "suite": "Suite 198",
      "city": "Lebsackbury",
      "zipcode": "31428-2261",
      "geo": {
        "lat": "-38.2386",
        "lng": "57.2232"
      }
    },
    "phone": "024-648-3804",
    "website": "ambrose.net",
    "company": {
      "name": "Hoeger LLC",
      "catchPhrase": "Centralized empowering task-force",
      "bs": "target end-to-end models"
    }
  }
]
https://jsonplaceholder.typicode.com/users/5
{
  "id": 5,
  "name": "Chelsey Dietrich",
  "username": "Kamren",
  "email": "Lucio_Hettinger@annie.ca",
  "address": {
    "street": "Skiles Walks",
    "suite": "Suite 351",
    "city": "Roscoeview",
    "zipcode": "33263",
    "geo": {
      "lat": "-31.8129",
      "lng": "62.5342"
    }
  },
  "phone": "(254)954-1289",
  "website": "demarco.info",
  "company": {
    "name": "Keebler LLC",
    "catchPhrase": "User-centric fault-tolerant solution",
    "bs": "revolutionize end-to-end systems"
  }
}

Как всегда, создаем директорию проекта, размещаем в ней файлы Dockerfile, entrypoint.sh и action.yml:

# Base Docker image 
FROM alpine:latest

# installes required packages for our script
RUN apk add --no-cache \
  bash \
  ca-certificates \
  curl \
  jq

# copy bash script to filesystem alpine OS
COPY entrypoint.sh /entrypoint.sh

# change permission to execute our script
RUN chmod +x /entrypoint.sh

# execute bash script when container start
ENTRYPOINT ["/entrypoint.sh"]
#!/bin/bash
set -e

api_url="https://jsonplaceholder.typicode.com/users/${INPUT_USER_ID}"
echo $api_url

user_name=$(curl "${api_url}" | jq ".name")
echo $user_name

echo "::set-output name=user_name::$user_name"
name: Custom Github Docker Action
description: Call API and get user name

inputs:
  user_id:
    description: User ID, from 1 to 10
    required: true
    default: 1
outputs:
  user_name:
    description: Getted user name

runs:
  using: docker
  image: Dockerfile
  args:
    - ${{ inputs.user_id }}

Чтобы проверить наш новый action — создаем workflow-файл, который использует этот action в работе:

name: Test custom docker action

on: [push]

jobs:
  get_user_name_job:
    runs-on: ubuntu-latest
    # задаем значение output для этого задания
    outputs:
      user_name: ${{ steps.get_user_name_step.outputs.user_name }}
    steps:
      - name: Get user name
        id: get_user_name_step
        uses: tokmakov/custom-docker-action@master
        with:
          user_id: 5
      - name: Echo user name
        # получаем доступ к output предыдущего шага
        run: echo ${{ steps.get_user_name_step.outputs.user_name }}

  use_user_name_job:
    runs-on: ubuntu-latest
    needs: get_user_name_job
    steps:
      - name: Echo user name
        # получаем доступ к output предыдущего задания
        run: echo ${{needs.get_user_name_job.outputs.user_name}}

Создаем репозиторий на GitHub, выкладываем файлы проекта и смотрим результат работы нового action:

2. Action на JavaScript

Создаем директорию проекта, размещаем в ней файлы index.js и action.yml:

const core = require('@actions/core')
const fetch = require('node-fetch')

const fetchUser = async (id) => {
    const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
    const data = await response.json()
    return data
}

try {
    const id = core.getInput('user_id') // входные данные
    fetchUser(id).then(user => {
        console.log(user.name)
        core.setOutput('user_name', user.name) // выходные данные
    })
} catch(error) {
    core.setFailed(error.message)
}
name: Custom Github JavaScript Action
description: Call API and get user name

inputs:
  user_id:
    description: User ID, from 1 to 10
    required: true
    default: 1
outputs:
  user_name:
    description: Getted user name

runs:
  using: node16
  main: index.js

Инициализируем проект, устанавливаем пакеты @actions/core и node-fetch (2-ой версии, чтобы использовать require, а не import):

$ npm init -y
$ npm install @actions/core
$ npm install node-fetch@2

Чтобы проверить наш новый action — создаем workflow-файл, который использует этот action в работе:

name: Test custom javascript action

on: [push]

jobs:
  get_user_name_job:
    runs-on: ubuntu-latest
    # задаем значение output для этого задания
    outputs:
      user_name: ${{ steps.get_user_name_step.outputs.user_name }}
    steps:
      - name: Get user name
        id: get_user_name_step
        uses: tokmakov/custom-javascript-action@master
        with:
          user_id: 5
      - name: Echo user name
        # получаем доступ к output предыдущего шага
        run: echo ${{ steps.get_user_name_step.outputs.user_name }}

  use_user_name_job:
    runs-on: ubuntu-latest
    needs: get_user_name_job
    steps:
      - name: Echo user name
        # получаем доступ к output предыдущего задания
        run: echo ${{needs.get_user_name_job.outputs.user_name}}

Создаем новый репозиторий на GitHub, выкладываем все файлы (включая node_modules) в этот репозиторий:

$ git init # создаем пустой репозиторий
$ git add --all  # добавляем все файлы проекта
$ git commit -m "Initial сommit" # первый коммит
$ git remote add origin git@github.com:tokmakov/custom-javascript-action.git
$ git push origin master # выкладываем проект на GitHub
$ git branch -u origin/master # отслеживаем удаленную ветку

Не очень хорошо, что мы выкладываем на GitHub директорию node_modules. Этого можно избежать, если использовать пакет @vercel/ncc. Он позволяет упаковать наш код вместе с зависимостями в один файл.

$ npm i -g @vercel/ncc # установка пакета ncc
$ ncc build index.js # будет создан dist/index.js

Теперь в action.yml изменяем значение директивы main на dist/index.js.

$ rm -rf node_modules/*
$ git add action.yml dist/index.js node_modules/*
$ git commit -m "Use vercel/ncc"
$ git push

Кроме пакета @actions/core можно еще использовать @actions/github для взаимодействия с GitHub, но об этом как-нибудь в другой раз.

Еще два action

Мы создали два action, каждый в отдельном репозитории, подразумевая их многократное использование или даже публикацию на Marketplace. Теперь создадим два action, с использованием Docker образа и javascript-кода, которые будут размещаться локально. Эти два action будут отправлять сообщение в Telegram по результатам работы задания — success, failure, skipped или cancelled.

1. Action как Docker-образ

Workflow файл .github/workflows/test-docker-action.yml:

name: Test send telegram message

on: [push]

jobs:
  some-job:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "do something"
          sleep 5
          echo "job complete"
          exit $(( $RANDOM % 2 ))

  report:
    needs: some-job
    if: ${{ always() }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ./.github/actions/telegram-message
        with:
          chat: ${{ secrets.CHAT }}
          token: ${{ secrets.TOKEN }}
          result: ${{ needs.some-job.result }}

Action файл .github/actions/telegram-message/action.yml:

name: Send telegram message
description: Send message to telegram (Docker image)

inputs:
  chat:
    description: Telegram chat ID
    required: true
  token:
    description: Telegram API token
    required: true
  result:
    description: Result (success, failure, skipped, failure)
    required: true
    default: success

runs:
  using: docker
  image: Dockerfile
  args:
    - ${{ inputs.chat }}
    - ${{ inputs.token }}

Файлы .github/actions/telegram-message/Dockerfile и .github/actions/telegram-message/entrypoint.sh:

# Base Docker image 
FROM alpine:latest

# installes required packages for our script
RUN apk add --no-cache \
  bash \
  ca-certificates \
  curl

# copy bash script to filesystem alpine OS
COPY entrypoint.sh /entrypoint.sh

# change permission to execute our script
RUN chmod +x /entrypoint.sh

# execute bash script when container start
ENTRYPOINT ["/entrypoint.sh"]
#!/bin/bash
set -e

api_url="https://api.telegram.org/bot${INPUT_TOKEN}/sendMessage"
header='Content-Type: application/json; charset=utf-8'

message='Задание завершилось успешно'
[[ $INPUT_RESULT == 'failure' ]] && message='Задание завершилось ошибкой'
[[ $INPUT_RESULT == 'skipped' ]] && message='Задание было пропущено'
[[ $INPUT_RESULT == 'cancelled' ]] && message='Задание было отменено'

json='{\"chat_id\":\"%s\",\"text\":\"%s\"}'
printf -v data "$json" "$INPUT_CHAT" "$message"

curl -X POST -H "$header" -d "$data" $api_url > /dev/null 2>&1

2. Action на JavaScript

Workflow файл .github/workflows/test-local-action.yml:

name: Test send telegram message

on: [push]

jobs:
  some-job:
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo "do something"
          sleep 5
          echo "job complete"
          exit $(( $RANDOM % 2 ))

  report:
    needs: some-job
    if: ${{ always() }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ./.github/actions/telegram-message
        with:
          chat: ${{ secrets.CHAT }}
          token: ${{ secrets.TOKEN }}
          result: ${{ needs.some-job.result }}

Action файл .github/actions/telegram-message/action.yml:

name: Send telegram message
description: Send message to telegram (JavaScript code)

inputs:
  chat:
    description: Telegram chat ID
    required: true
  token:
    description: Telegram API token
    required: true
  result:
    description: Result (success, failure, skipped, cancelled)
    required: true
    default: success

runs:
  using: node16
  main: dist/index.js

Файлы .github/actions/telegram-message/index.js и .github/actions/telegram-message/package.json:

const core = require('@actions/core')
const fetch = require('node-fetch')

try {
    const chat = core.getInput('chat')
    const token = core.getInput('token')
    const result = core.getInput('result')

    let message = 'Задание завершилось успешно'
    if (result == 'failure') message = 'Задание завершилось ошибкой'
    if (result == 'skipped') message = 'Задание было пропущено'
    if (result == 'cancelled') message = 'Задание было отменено'

    fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json; charset=utf-8'
        },
        body: JSON.stringify({
            chat_id: chat,
            text: message
        })
    })
} catch(error) {
    core.setFailed(error.message)
}
{
  "name": "telegram-javascript-action",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@actions/core": "^1.9.0",
    "node-fetch": "^2.6.7"
  }
}

Здесь все сделано по аналогии с первым javascript action — переходим в директорию .github/actions/telegram-message, устанавливаем пакеты @actions/core и node-fetch, потом запускаем ncc build index.js. Удаляем директорию node_modules, переходим на три уровня выше cd ../../.., создаем репозиторий git init, добавляем в него все файлы, выкладываем на GitHub.

Исходные коды примеров здесь.

Поиск: Git • GitHub • Linux • Web-разработка • Конфигурация • Сервер

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.