はい、GitLab CIはURL短縮サービスとして使えます。curlと1つのAPIキーを使うジョブがあれば、マージリクエストごとにショートリンクを作成してレビューアプリへ向け、タグをプッシュしたときに安定したlatestリンクを新しいリリースへ移せます。YAMLは約40行です。今すぐ動作します。
多くの人が間違えるのはHTTPリクエストではありません。キーの扱いです。どこに置くか、どのパイプラインが読めるか、ブランチのジョブから漏れた場合にどれほどの被害が出るかが問題になります。そのためこのガイドでは、.gitlab-ci.ymlそのものと同じくらい変数とスコープに時間をかけます。長期間使うリンクを宣言的に管理したい場合は、ショートリンクのTerraform方式の方が適しています。パイプラインは、コードとともに作成・廃止されるリンクに向いています。
最初にステータスを1点お伝えします。ElidoのGitLabネイティブ統合は準備中で、まだ稼働しておらず、以下の内容は何も依存していません。管理されたバージョンを使いたいですか?GitLab統合ページにウェイトリストがあります。
GitLab CIのURL短縮サービスジョブの役割
パイプラインの短縮サービスが行うことは3つだけです。スラッグが存在しないときにリンクを作成し、存在するときに遷移先を更新し、リンク先の対象がなくなったときにリンクを無効にします。アナリティクスとQRコードはElido側に残ります。
APIの範囲は小さなものです。リンクは/v1/workspaces/{workspace_id}/linksの下にあり、POSTで作成するにはdomain_idとdestination_urlが必要です。PATCH /links/{link_id}は既存リンクのフィールドを変更し、GET /links?q=はスラッグ、遷移先、タイトルで検索します。認証は1つのヘッダーだけです:Authorization: Bearer elido_...。キーはダッシュボードのAPI keysページから取得します。
契約はこれですべてです。APIとSDKの概要には残りのエンドポイントも載っていますが、パイプラインで必要になるのは通常この3つを超えません。
キーをマスクして保護された変数として保存する
ここで重要になるスイッチはGitLabに2つあり、それぞれ役割が異なります。マスクはジョブログ内の値を隠します。保護は、そもそもどのパイプラインが値を受け取れるかを制御します。
変数を作成するときは、Settings、CI/CD、VariablesでMasked and hiddenを選びます。HiddenはGitLab 17.6以降で一般提供されている機能で、後から設定ページで誰も値を表示できないことを意味します。認証情報にはこの設定が必要です。GitLab CI/CD変数のドキュメントには、マスクした値の要件として、1行であること、空白がないこと、8文字以上であることが記載されています。Elidoのキーはelido_にbase32が続く形式なので条件を満たします。
同じページには制限も率直に書かれています。マスクは「悪意のあるユーザーによる変数値へのアクセスを防ぐ保証された方法ではありません」。変数をbase64でエンコードして表示するジョブなら、マスクをそのまま通り抜けます。マスクはアクセス制御ではなく、ログを衛生的に保つためのものと考えてください。
アクセス制御に当たるのが保護です。保護された変数は保護されたブランチまたはタグのパイプラインにだけ届きます。そのため、どのチームも最初の週に同じ問題に遭遇します。マージリクエストのパイプラインは機能ブランチで実行されるため、保護されたキーが空文字列で届き、ジョブが入力ミスのように見える401で失敗するのです。
私は1つのキーを弱める代わりに、2つのキーで解決します。私なら次のように設定します。
| 変数 | 表示 | 保護 | 読み取り元 |
|---|---|---|---|
ELIDO_PREVIEW_KEY | Masked and hidden | No | マージリクエストのパイプライン |
ELIDO_RELEASE_KEY | Masked and hidden | Yes | 保護されたタグ上のパイプライン |
ELIDO_PREVIEW_WS, ELIDO_RELEASE_WS | Visible | No | 任意のジョブ(IDは秘密ではありません) |
ELIDO_DOMAIN_ID, SHORT_HOST | Visible | No | 任意のジョブ |
プレビューキーは、レビューリンクだけを保持する別のワークスペースに置きます。ブランチをプッシュできる人なら、原理上は保護されていない変数を持ち出せます。そのため、到達できる最悪の範囲が使い捨てのmr-142リンクの山に収まるようにし、リリースキーは実際のワークスペースに置いて、保護したタグでのみ実行されるようにしてください。
両方のキーにEditorロールと有効期限を設定します。プレビュー用は90日が適しています。Editorはリンクを書き込める最も低いプリセットで、削除もできます。APIキーはプリセットのロールから1つを選ぶ仕組みで、この用途にぴったりの作成・更新専用プリセットが欲しいところですが、まだありません。実際に影響範囲を制限するのはワークスペースの分離です。
ショートリンクを作成する動作する.gitlab-ci.ymlジョブ
共有する部分はこれです。スラッグを検索し、なければリンクを作成し、あればパッチするupsertです。非表示ジョブに入れて、そこからextendします。
.elido_upsert:
image: alpine:3.20
before_script:
- apk add --no-cache curl jq
script:
- API="https://api.elido.app/v1/workspaces/${ELIDO_WS}"
- AUTH="Authorization: Bearer ${ELIDO_KEY}"
- |
find_id() {
curl -sS --fail-with-body -H "$AUTH" "$API/links?q=${SLUG}&limit=50" |
jq -r --arg s "$SLUG" --argjson d "$ELIDO_DOMAIN_ID" \
'.items[] | select(.slug == $s and .domain_id == $d) | .id' | head -n1
}
ID="$(find_id)"
if [ -z "$ID" ]; then
CODE=$(curl -sS -o resp.json -w '%{http_code}' -X POST "$API/links" \
-H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: ${CI_PIPELINE_ID}-${SLUG}" \
-d "$(jq -n --arg s "$SLUG" --arg u "$TARGET" --argjson d "$ELIDO_DOMAIN_ID" \
'{domain_id: $d, slug: $s, destination_url: $u, tags: ["ci"]}')")
case "$CODE" in
201) ;;
409) ID="$(find_id)" ;; # another pipeline created it first
*) cat resp.json; exit 1 ;;
esac
fi
if [ -n "$ID" ]; then
curl -sS --fail-with-body -X PATCH "$API/links/$ID" \
-H "$AUTH" -H "Content-Type: application/json" \
-d "$(jq -n --arg u "$TARGET" '{destination_url: $u, status: "active"}')"
fi
- echo "SHORT_URL=https://${SHORT_HOST}/${SLUG}" >> link.env
artifacts:
reports:
dotenv: link.env
q検索は部分一致なので、jqフィルターで完全に一致するスラッグとドメインに絞り込んでいます。これがないと、web-mr-14を検索したときにweb-mr-142が何食わぬ顔で返ります。GET /v1/workspaces/{id}/domainsで一度domain_idを取得し、通常の変数として保存してください。カスタムドメインで設定したブランドホストの方が、マージリクエストでは汎用ホストより見栄えがします。
マージリクエストごとのレビューアプリ用ショートリンク
レビューアプリは、ブランチまたはマージリクエストごとの一時環境を指すGitLabの呼び名で、レビューアプリのドキュメントでは動的環境を使って構築します。URLはハッシュ、名前空間、クラウドプロバイダーのホスト名などで、見栄えがしないことが多いものです。go.example.com/web-mr-142のようなショートリンクなら、スタンドアップで口に出して伝えられます。
review_link:
extends: .elido_upsert
stage: deploy
needs: [deploy_review]
variables:
ELIDO_KEY: $ELIDO_PREVIEW_KEY
ELIDO_WS: $ELIDO_PREVIEW_WS
SLUG: "${CI_PROJECT_NAME}-mr-${CI_MERGE_REQUEST_IID}"
TARGET: "https://${CI_ENVIRONMENT_SLUG}.review.example.com"
environment:
name: review/$CI_COMMIT_REF_SLUG
url: $SHORT_URL
on_stop: stop_review_link
auto_stop_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
ポイントはdotenvレポートです。upsertがlink.envにSHORT_URLを書き込み、GitLabがそれを読み戻すことで、environment:urlがショートリンクになります。そのためマージリクエストのView appボタンを押すと、生のホスト名ではなくweb-mr-142が開きます。環境のドキュメントで、この動的URLパターンを説明しています。
CI_MERGE_REQUEST_IIDはプロジェクトごとに一意で、マージリクエストが存続する間は変わりません。そのため同じMRへのプッシュはすべて同じスラッグになり、upsertが重複作成ではなくパッチします。別のキーが必要なら、定義済み変数のリファレンスに全リストがあります。
クリーンアップはaction: stopを使うジョブで行います。GitLabが自動的に起動できるよう、開始ジョブと同じrulesを共有する必要があります。
stop_review_link:
image: alpine:3.20
stage: deploy
variables:
GIT_STRATEGY: none
SLUG: "${CI_PROJECT_NAME}-mr-${CI_MERGE_REQUEST_IID}"
script:
- apk add --no-cache curl jq
- API="https://api.elido.app/v1/workspaces/${ELIDO_PREVIEW_WS}"
- ID=$(curl -sS -H "Authorization:
Bearer ${ELIDO_PREVIEW_KEY}" "$API/links?q=${SLUG}" |
jq -r --arg s "$SLUG" --argjson d "$ELIDO_DOMAIN_ID" '.items[] | select(.slug == $s and .domain_id == $d) | .id' | head -n1)
- '[ -z "$ID" ] || curl -sS --fail-with-body -X PATCH "$API/links/$ID" -H "Authorization: Bearer ${ELIDO_PREVIEW_KEY}" -H "Content-Type: application/json" -d "{\"status\":\"disabled\"}"'
environment:
name: review/$CI_COMMIT_REF_SLUG
action: stop
when: manual
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
私は削除ではなく無効化します。無効なリンクならクリック履歴が残り、誰かがMRを再オープンした場合も、次のパイプラインが同じupsertを通じてactiveに戻せます。GIT_STRATEGY: noneを指定しているのは、その時点でブランチがなくなっている可能性があるためです。
レビューアプリがリリースの10倍ある場合、ここでプランの上限が効いてきます。混雑したモノレポに組み込む前に料金ページでリンク許容量を確認し、テスト中はプレビュー用の無料ワークスペースを始めてください。
タグパイプラインで安定したlatestリンクの遷移先を付け替える
2つ目のパターンはタグで実行し、レビューリンクとは逆のことをします。変わらない1つのスラッグを使い、リリースごとに遷移先を先へ進めます。READMEはgo.example.com/cli-latestを永遠に指せます。
latest_link:
extends: .elido_upsert
stage: release
variables:
ELIDO_KEY: $ELIDO_RELEASE_KEY
ELIDO_WS: $ELIDO_RELEASE_WS
SLUG: "cli-latest"
TARGET: "${CI_PROJECT_URL}/-/releases/${CI_COMMIT_TAG}"
rules:
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
このルールをv*のような保護されたタグパターンと組み合わせ、トリガーとなるタグを作成できるのはメンテナーだけにします。そうしないと保護されたキーが存在せず、ジョブはクローズドに失敗します。これは望ましい動作です。バージョンごとの恒久的なリンクも必要なら、SLUG: "cli-${CI_COMMIT_REF_SLUG}"として同じジョブを2回目に実行します。v1.4.0はcli-v1-4-0になります。
latestリンクにredirect_statusを301で設定しないでください。301はブラウザーがキャッシュしてよい恒久的な約束であり、latestリンクはリリースのたびにその約束を破るからです。Elidoはフィールドを省略すると302をデフォルトにします。301と302のリダイレクトの記事では、この選択が実際に問題になるケースを説明しています。
正直な注意点が1つあります。変更した遷移先がすべてのエッジロケーションに届くまで数分かかることがあるため、直後の1秒でショートリンクにcurlするスモークテストでは、まだ前のリリースが返る可能性があります。代わりにAPIレスポンスを検証するか、Locationヘッダーを確認する前に待ってください。
冪等性、リトライ、レート制限
パイプラインはリトライされます。ジョブの途中でRunnerが停止したり、誰かが赤いジョブのRetryをクリックしたり、30秒間隔で2回のプッシュが到着して競合したりします。上のupsertはこの3つすべてに耐えます。その理由を知っておく価値があります。
Idempotency-Keyヘッダーによって、リトライされたPOSTが安全になります。APIは成功したレスポンスを24時間キャッシュし、同じキーにはそれをリプレイするため、同じパイプラインのリトライはエラーではなく元のリンクを受け取ります。CI_PIPELINE_IDとスラッグからキーを作ることで、パイプライン内のリトライはリプレイになり、新しいパイプラインでは新しい試行になります。409の分岐は異なる2つのパイプライン間の競合に対応し、検索してからパッチする経路によって2回目の実行は実質的に何もしません。
レート制限はキーごとに適用され、ワークスペース単位の制限もその上にあります。また、新しいワークスペースには評価実績が積み上がるまで、1日あたりのリンク作成上限が低く設定されます。少数のマージリクエストなら気づきません。レビューアプリを一度に40個起動するモノレポでは影響する可能性があるため、429はGitLabのretryキーワードでリトライ可能として扱い、402は一時的なエラーではなくプラン上限を意味するので明確に失敗させてください。短縮サービスAPIのレート制限と冪等性では、CIジョブに必要な範囲を超えてバックオフを詳しく説明しています。
完全一致のjqフィルターを省くと、MR 14のパイプラインがMR 142のリンクをひそかにパッチします。最初の兆候は、たいてい困惑したデザイナーです。フィルターは残してください。
YAML内のシェルが扱いにくくなったら、同じ呼び出しをリポジトリにコミットするスクリプトにきれいにまとめられます。URL短縮サービスのCLIガイドでその形を紹介しています。
コーナーストーンを読む → Terraformでショートリンクを管理する:リンクをコードとして扱う
ブログの関連記事
- URL短縮サービスAPIのクイックスタート - 上記の各ジョブが呼び出すRESTの範囲を説明します。
- 短縮サービスAPIのレート制限と冪等性 - リトライのロジックがこの形になる理由を説明します。
- コマンドラインから使うURL短縮サービス - 同じ呼び出しを再利用可能なスクリプトにしたものです。
- 301と302のリダイレクト - 変化するリンクに一時的なリダイレクトが必要な理由を説明します。
- SentryとDatadogでリンクのリダイレクトを監視する - パイプラインが成功した後に壊れた遷移先を検出します。
- GitHub Actionsからショートリンクを作成する - シークレットと同時実行制御を扱うGitHub版です。
よくある質問
GitLab CIでショートリンクを作成できますか?
はい。curlを実行できるジョブなら、どのURL短縮サービスのREST APIでも呼び出せるため、GitLab CIのジョブでショートリンクの作成、遷移先の更新、無効化ができます。APIキーはマスクしたCI/CD変数に保存し、ジョブからBearerトークンとして送信します。このためにGitLabのネイティブ統合は必要ありません。
GitLab CIでAPIキーを安全に保存するにはどうすればよいですか?
Settings、CI/CD、Variablesで、VisibilityをMasked and hiddenにして追加します。保護されたブランチまたはタグだけが読み取れるようにする場合は、Protect variableにもチェックを入れてください。マスクすると値はジョブログに表示されなくなりますが、GitLab自身のドキュメントにも保証された防御ではないとあるため、キー自体の権限を必要最小限に絞ってください。
マージリクエストのパイプラインで保護された変数が空になるのはなぜですか?
保護された変数は、保護されたブランチまたは保護されたタグで実行されるパイプラインにだけ渡されます。機能ブランチからのマージリクエストパイプラインはデフォルトでは条件を満たさないため、変数は空で届きます。レビュー用ジョブには権限を下げた別の保護されていないキーを使うか、保護されたキーをタグパイプライン専用にしてください。
GitLabの各レビューアプリにショートリンクを割り当てるにはどうすればよいですか?
マージリクエストのパイプラインでジョブを実行し、プロジェクト名とCI_MERGE_REQUEST_IIDから作ったスラッグを、レビューアプリのURLに向けてupsertします。生成された短縮URLをdotenvレポートに書き込み、environment:urlとして使うと、マージリクエストのウィジェットから直接リンクできます。環境が停止したときは、停止ジョブでリンクを無効にします。
最新リリースのショートリンクには301と302のどちらのリダイレクトを使うべきですか?
302を使ってください。latestリンクはリリースのたびに遷移先が変わりますが、301は移動が恒久的だとブラウザーやキャッシュに伝えるため、一部のクライアントは古いバージョンへ送り続けます。redirect_statusを設定しない場合、Elidoは新しいリンクを302にするため、ここでは適切な選択です。
ElidoにはGitLabのネイティブ統合がありますか?
まだありません。GitLabのネイティブ統合は準備中で、GitLab統合ページからウェイトリストに登録できます。このガイドの内容はすべて、パイプラインジョブから公開REST APIを通じて今すぐ動作し、GitLab側でインストールするものはCI/CD変数以外にありません。
Elidoを試す
URLを貼り付けて短縮リンクを取得
登録不要。リンクは30日間有効。永久に保存するには登録してください。
Free、登録不要 · 1日あたり2件