Шаг сокращения URL в GitHub Actions - это несколько строк shell-кода: прочитать API-ключ из зашифрованного секрета, проверить, существует ли уже слаг, а затем либо обновить адрес назначения, либо создать ссылку. Запускайте его при каждом push, и одна и та же короткая ссылка всегда будет вести на самый новый preview, сборку документации или артефакт. Marketplace action не нужен. curl и jq есть на каждом Ubuntu-раннере GitHub-hosted.
Это весь ответ, а остальная часть статьи объясняет, как сделать решение надежным при нескольких сотнях запусков workflow. Те, кто ищет способ создать короткую ссылку в GitHub Actions, обычно доходят до одного POST-запроса, и он работает ровно до второго push в тот же pull request, когда запрос создания возвращает конфликт, а задание завершается с ошибкой. Исправление заключается в том, чтобы считать этот шаг upsert-операцией, а не созданием ссылки. Еще одна проблема - сам ключ: часто это персональный токен с гораздо более широкими правами, чем нужно CI-заданию.
Если вы уже управляете ссылками как кодом, короткие ссылки в Terraform - это декларативная версия той же идеи и лучший вариант для ссылок, которые меняются по решению человека. Шаг workflow выигрывает, когда адрес назначения появляется только после завершения сборки.
Как работает шаг сокращения URL в GitHub Actions
Каждый запуск выполняет одни и те же три действия через REST API по адресу https://api.elido.app/v1. Сначала он получает ссылки воркспейса с фильтрацией по слагу. Затем отправляет PATCH найденной ссылке или POST, если ничего не нашел. После этого он записывает короткий URL в $GITHUB_OUTPUT, чтобы его мог использовать следующий шаг.
Почему бы не позволить сокращателю сгенерировать случайный слаг? Потому что тогда вы не сможете снова найти ссылку. Слаг должен исходить из значения, известного workflow при каждом запуске: номера pull request, имени ветки, фиксированного слова вроде latest. Стабильный слаг означает стабильный короткий URL, и именно это важно для ревьюеров, которые добавляют его в закладки, или менеджеров продукта, которые вставляют его в задачу.
Хранение API-ключа в виде зашифрованного секрета
Создайте ключ в дашборде, скопируйте его один раз (он показывается ровно один раз и начинается с elido_) и сохраните в разделе Settings, затем Secrets and variables, затем Actions под именем ELIDO_API_KEY. В руководстве GitHub по использованию секретов в GitHub Actions описаны уровни репозитория, окружения и организации. Для всего, что выполняет развертывание, я бы поместил ключ в окружение с обязательными ревьюерами, чтобы случайная ветка не могла его использовать.
Три значения не являются секретами и должны храниться в переменных конфигурации, откуда их можно прочитать: ELIDO_WORKSPACE_ID, ELIDO_DOMAIN_ID и ELIDO_HOST. ID домена важен, потому что он необходим для запроса создания. Получить его можно один раз через GET /v1/workspaces/{workspace_id}/domains: этот запрос возвращает id и hostname каждого домена.
Передавайте секрет только шагу, который вызывает API, а не всему заданию. env на уровне шага не даёт секрету попасть в остальные процессы, запускаемые заданием, включая сторонние actions, которые написали не вы.
Рабочий workflow для сокращения URL каждого pull request
Ниже приведен полный файл для самого распространенного случая сокращения URL в GitHub workflow: отдельная preview-ссылка для каждого pull request. Поместите его в .github/workflows/preview-link.yml и измените строку DEST, указав адрес, по которому будет доступно ваше preview-развертывание.
name: Preview short link
on:
pull_request:
types: [opened, reopened, synchronize]
permissions:
contents: read
pull-requests: write
concurrency:
group: preview-link-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
short-link:
# Forks get no secrets; skip them instead of failing.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
API: https://api.elido.app/v1/workspaces/${{ vars.ELIDO_WORKSPACE_ID }}
DOMAIN_ID: ${{ vars.ELIDO_DOMAIN_ID }}
HOST: ${{ vars.ELIDO_HOST }}
SLUG: pr-${{ github.event.pull_request.number }}-myapp
DEST: https://pr-${{ github.event.pull_request.number }}.preview.example.com
steps:
- name: Create or update the short link
id: link
env:
ELIDO_API_KEY: ${{ secrets.ELIDO_API_KEY }}
run: |
set -euo pipefail
auth=(-H "Authorization: Bearer $ELIDO_API_KEY" -H "Content-Type: application/json")
# 1. Find an existing link with exactly this slug on this domain.
link_id=$(curl -sS --fail-with-body "${auth[@]}" "$API/links?q=$SLUG&limit=100" \
| jq -r --arg s "$SLUG" --argjson d "$DOMAIN_ID" \
'.items[] | select(.slug == $s and .domain_id == $d) | .id' | head -n1)
if [ -n "$link_id" ]; then
# 2a. Found: point it at the new destination.
curl -sS --fail-with-body -X PATCH "${auth[@]}" "$API/links/$link_id" \
-d "$(jq -n --arg u "$DEST" '{destination_url: $u, status: "active"}')" > /dev/null
else
# 2b. Not found: create it. The key makes curl's retries safe.
curl -sS --fail-with-body --retry 3 -X POST "${auth[@]}" "$API/links" \
-H "Idempotency-Key: $GITHUB_REPOSITORY-$SLUG-$GITHUB_RUN_ID" \
-d "$(jq -n --arg u "$DEST" --arg s "$SLUG" --argjson d "$DOMAIN_ID" \
'{domain_id: $d, slug: $s, destination_url: $u, tags: ["ci", "preview"]}')" > /dev/null
fi
echo "url=https://$HOST/$SLUG" >> "$GITHUB_OUTPUT"
- name: Comment once, when the pull request opens
if: github.event.action == 'opened'
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr comment "${{ github.event.pull_request.number }}" \
--repo "${{ github.repository }}" \
--body "Preview: ${{ steps.link.outputs.url }}"
Несколько строк заслуживают пояснения. --fail-with-body превращает ответ 4xx или 5xx в ошибку шага и при этом выводит тело ошибки, чего не делает обычный curl -s; при ответе 401 он завершает работу с кодом 0, и задание спокойно продолжает выполняться. Тела запросов формируются через jq -n, а не строковой интерполяцией, поэтому кавычки или амперсанд в адресе назначения не повредят JSON. А шаг с комментарием запускается только при opened. Поскольку короткий URL не меняется, одного комментария достаточно на весь срок жизни PR, и при каждом push никто не получает новое уведомление.
Блок concurrency - не декоративный элемент. Иначе два быстрых push запустили бы два процесса, оба увидели бы «ссылка ещё не создана» и оба попытались бы её создать. В документации GitHub по управлению параллельностью workflow объясняется группировка; здесь старый запуск отменяется, и гонка не возникает.
Идемпотентность: обновляйте короткую ссылку, а не дублируйте её
Под словом idempotent скрываются две разные проблемы, и workflow обрабатывает их раздельно. Первая - повторный запуск: второй push, ручной «Re-run jobs», повторно открытый PR. Для этого предназначена ветка поиска с последующим PATCH. Вторая - повторная отправка запроса: curl отправляет POST, сеть обрывается до получения ответа, и curl отправляет его снова. От этой проблемы защищает заголовок Idempotency-Key. Elido хранит первый успешный ответ по этому ключу 24 часа и возвращает его при совпадающем повторе, поэтому создание выполняется один раз; полное описание механизма приведено в материале лимиты запросов, повторы и идемпотентность.
Коллизии слагов - вот что часто упускают. Слаги уникальны в пределах домена перенаправления, а не воркспейса. На общем домене все остальные клиенты Elido находятся в том же пространстве имён, и такой простой слаг, как pr-12, скорее всего, уже кем-то занят. Поиск не увидит эту ссылку, потому что показывает только ваш воркспейс, поэтому POST будет отправлен и вернется с ошибкой 409 slug already exists for this domain. Есть два решения: добавить слово проекта в слаг или разместить CI-ссылки на собственном пользовательском домене, где пространство имён принадлежит только вам. Я бы сделал и то и другое.
Есть и вторая, менее очевидная причина, по которой имя репозитория стоит в конце слага, а не в начале. Параметр q выполняет поиск подстроки в слаге, адресе назначения и заголовке. Для myapp-pr-1 поиск также вернет myapp-pr-10 - myapp-pr-199, то есть больше 100 результатов, которые возвращает одна страница, и нужная ссылка, как самая старая, окажется за её пределами. pr-1-myapp совпадает только с самой собой. Мелочь, но мне понадобился мучительно долгий день с вопросом «почему PR #1 постоянно получает 409», чтобы это заметить.
Три задания CI с короткими ссылками, которые стоит автоматизировать
Workflow для preview - один из шаблонов. Измените триггер, слаг и адрес назначения, и тот же шаг покроет большинство задач, которые команды действительно автоматизируют. (Примечания к релизу - отдельная тема, она разобрана в статье о сокращении ссылок в примечаниях к релизу.)
| Сценарий | Триггер | Слаг | Действие шага |
|---|---|---|---|
| Preview-развертывание для каждого PR | pull_request | pr-42-myapp | Upsert при каждом push, удаление при закрытии |
| Развертывание документации | push в main | docs-myapp | PATCH на только что развернутый URL документации |
| Последний билд | push в main или тег | latest-myapp | PATCH по сохраненному ID ссылки, без поиска |
| Ночной артефакт | schedule | nightly-myapp | PATCH на URL новейшего артефакта |
Сценарий с последним билдом - самый простой из всех. Один раз создайте ссылку вручную, сохраните её числовой ID в переменной, и задание сократится до одного вызова:
- name: Point the latest link at this build
env:
ELIDO_API_KEY: ${{ secrets.ELIDO_API_KEY }}
API: https://api.elido.app/v1/workspaces/${{ vars.ELIDO_WORKSPACE_ID }}
DEST: https://builds.example.com/${{ github.sha }}/
run: |
curl -sS --fail-with-body -X PATCH \
-H "Authorization: Bearer $ELIDO_API_KEY" -H "Content-Type: application/json" \
"$API/links/${{ vars.ELIDO_LATEST_LINK_ID }}" \
-d "$(jq -n --arg u "$DEST" '{destination_url: $u}')"
Для таких меняющихся ссылок используйте 302, это значение по умолчанию, если не задан redirect_status. 301 сообщает браузерам, что ответ можно кэшировать, поэтому те, кто кликнул вчера, продолжат попадать на вчерашний билд; подробности приведены в материале о перенаправлениях 301 и 302.
Для preview-ссылок выполняйте очистку после закрытия PR. Добавьте closed в типы триггера, повторно используйте поиск и отправьте DELETE /v1/workspaces/{workspace_id}/links/{link_id}. Удаленный слаг снова становится свободным. Если вы предпочитаете сохранить историю кликов, вместо этого отправьте PATCH с {"status": "disabled"}; приведенная выше upsert-операция при каждом запуске устанавливает status: "active", поэтому повторно открытый PR вернет ссылку.
Готовы попробовать на одном репозитории? Создайте бесплатный воркспейс, выпустите ключ, и приведенный выше workflow заработает без изменений после задания трех переменных.
API-ключи CI с минимальными привилегиями
Ключ в секрете CI должен уметь ровно то, что делает workflow, и ничего сверх этого. Это сложнее, чем кажется, из-за особенностей персональных ключей.
Персональный API-ключ проходит аутентификацию от имени создавшего его человека. Все, что может этот человек, может и ключ, а когда сотрудник покидает компанию, ключ уходит вместе с его учетной записью. Для CI я бы использовал вместо него машинного пользователя: сервисную учетную запись, принадлежащую одному воркспейсу, со своей ролью и токенами, которые может выпустить или отозвать только вошедший в систему администратор-человек. Создайте её в разделе Machine users в дашборде с ролью редактора, это минимальная встроенная роль, позволяющая создавать, изменять и удалять ссылки, а затем выпустите для неё токен со сроком действия. Отключение машинного пользователя сразу аннулирует все принадлежащие ему токены, и именно такая кнопка нужна в день утечки секрета.
Еще четыре полезные привычки ничего не стоят:
- Один токен на репозиторий, названный по его имени, чтобы в журнале аудита было видно, какой репозиторий создал какую ссылку.
- Секреты окружения с обязательными ревьюерами для любого workflow, который меняет ссылку, от которой зависят люди.
- Явно задавайте
permissions:в верхней части workflow, как в примере, чтобыGITHUB_TOKENполучал только необходимые заданию права. - Никогда не используйте
pull_request_target, чтобы получить секрет из PR форка. В статье GitHub Security Lab о предотвращении pwn-запросов показано, почему запуск непроверенного кода рядом с токеном записи плохо заканчивается.
Воркспейсы также могут ограничивать доступ к API списком разрешенных IP-адресов. Это надежная мера для self-hosted раннеров с фиксированным исходящим адресом и почти бесполезная для GitHub-hosted раннеров, чьи адреса берутся из большого постоянно меняющегося пула. Собственное руководство GitHub по безопасному использованию стоит изучить, если ваши workflow затрагивают production.
Что ломается на практике
Большинство сбоев происходит по четырем причинам, и каждая отображается в виде понятной ошибки, если включен --fail-with-body. Ответ 404 на каждый запрос обычно означает, что переменная с ID воркспейса указана неверно или ключ принадлежит другому воркспейсу. Ответ 400 с текстом domain_id is required означает, что переменная пуста, обычно потому, что она задана в другом окружении, чем то, которое использует задание. Ответ 409 - это коллизия общего пространства имён из раздела об идемпотентности. А 429 означает превышение лимита запросов для ключа, чего не случится при одном upsert на запуск, но может произойти при матрице из пятидесяти заданий.
Есть и ситуация, которая вообще не является ошибкой. После PATCH посетитель ещё некоторое время может попадать на старый адрес назначения, потому что перенаправления кэшируются ближе к посетителю, чтобы работать быстрее. Smoke-тест, который сразу после обновления проверяет новый адрес назначения, будет работать нестабильно. Выполняйте проверку с короткими паузами между попытками или проверяйте ответ API.
Если вы хотите, чтобы шаг сообщал о событиях во внешние системы, объедините его с webhook для событий ссылок, который срабатывает при изменении ссылки, или с шаблонами curl и jq из руководства по CLI для локального тестирования перед фиксацией workflow. В справочнике API и SDK перечислены все поля, которые принимают эндпоинты ссылок.
Читайте основную статью → Управляйте короткими ссылками как Terraform
Другие статьи в блоге
- API сокращателя URL: лимиты запросов, повторы и идемпотентность - правила защиты от дублей при повторах, на которых основан этот workflow.
- CLI сокращателя URL - те же вызовы curl и jq из терминала.
- Бесплатный API сокращателя URL - запрос создания на curl, JavaScript, Python и Go.
- Сокращатели URL для разработчиков - короткие ссылки в докладах, README и проектах с открытым исходным кодом.
- Webhook для событий ссылок - реагируйте, когда задание CI меняет ссылку.
- Короткие ссылки в GitLab CI - тот же шаблон upsert в задании .gitlab-ci.yml.
Частые вопросы
Может ли GitHub Actions создавать короткие ссылки?
Да. Шаг workflow может вызвать REST API любого сервиса сокращения ссылок с помощью curl, который вместе с jq предустановлен на раннерах GitHub-hosted. Шаг считывает API-ключ из зашифрованного секрета, передаёт URL назначения и записывает полученный короткий URL в output шага, чтобы последующие шаги могли опубликовать его в комментарии к pull request или в сводке задания.
Как хранить API-ключ сокращателя URL в GitHub Actions?
Сохраните его как зашифрованный секрет репозитория или окружения, а затем передайте его только нужному шагу через запись env, например ELIDO_API_KEY: secrets.ELIDO_API_KEY внутри синтаксиса выражений. GitHub маскирует это значение в логах. Несекретные значения, такие как ID воркспейса и ID домена, храните в переменных конфигурации, чтобы их можно было читать.
Как не создавать дубликаты коротких ссылок при каждом запуске workflow?
Сделайте шаг upsert-операцией. Получайте слаг из стабильного значения, например номера pull request, сначала ищите его, а если он уже существует, отправляйте PATCH для изменения адреса назначения. Создавайте ссылку только если поиск ничего не вернул. Заголовок Idempotency-Key в запросе создания покрывает отдельный случай повторной отправки после сетевого тайм-аута.
Почему при создании короткой ссылки workflow получает ошибку 409?
Этот слаг уже занят на данном домене. В Elido слаги уникальны в пределах домена перенаправления, а общий домен используется всеми остальными воркспейсами, поэтому такой общий слаг, как pr-12, скорее всего, уже существует. Добавьте к слагу префикс или суффикс проекта либо используйте собственный пользовательский домен, где всё пространство имён принадлежит вам.
Работают ли шаги создания коротких ссылок для pull request из форков?
Не при обычном триггере pull_request, поскольку GitHub не передаёт секреты репозитория workflow, запущенным форком. Пропускайте задание для форков с условием if по репозиторию head. Переключаться на pull_request_target ради получения секрета рискованно: такой workflow запускается с правом записи рядом с кодом, который вы не проверяли.
Должна ли ссылка на последний билд использовать перенаправление 301 или 302?
Используйте 302 или 307. Браузеры могут кэшировать 301 на неопределенный срок, поэтому вернувшиеся посетители будут попадать на старый билд после того, как workflow переместит ссылку. Для ссылок Elido по умолчанию используется 302, если не задан redirect_status, и это правильный выбор для любой ссылки, адрес назначения которой меняется вместе с pipeline.
Попробуйте Elido
Вставьте URL - получите короткую ссылку
Без регистрации. Ссылка живёт 30 дней. Зарегистрируйтесь, чтобы оставить её навсегда.
Бесплатно, без регистрации · 2 в день