Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save hassaku63/3dbaf35fa8e2dafd7807b8abf189e943 to your computer and use it in GitHub Desktop.

Select an option

Save hassaku63/3dbaf35fa8e2dafd7807b8abf189e943 to your computer and use it in GitHub Desktop.
CDK で PR ごとの使い捨てプレビュー環境を一発構築するプロンプト

CDK で「PR ごとの使い捨てプレビュー環境」を一発構築するプロンプト

使い方

  • 対象: AWS CDK v2 (TypeScript) でインフラを管理し、GitHub でホストしているアプリケーションのリポジトリ
  • コーディングエージェント (Claude Code / Codex / Devin など) をリポジトリのルートで起動し、区切り線から下を丸ごと貼り付けてください
  • 事前にあるとスムーズなもの (無くてもエージェントがセットアップ手順を出力します):
    • GitHub Actions から OIDC で assume できるデプロイ用 IAM ロール
    • デプロイ先アカウント/リージョンで cdk bootstrap 済み
  • 生成されたコードは、マージ前に必ず cdk diff の内容とワークフローの権限設定をレビューしてください

あなたはこのリポジトリに PR ごとの使い捨てプレビュー環境 を導入します。ゴールは次の 2 つです。

  1. PR に /deploy とコメントすると、数分後にその PR のコードが動く専用の AWS 環境が立ち、URL が PR にコメントされる
  2. PR が close/merge されると、その環境は自動で片付く

以下の設計原則・実装手順・既知のハマりどころに従って、調査 → 提案 → 実装 → 検証まで進めてください。

前提・制約

  • CDK v2 (aws-cdk-lib) + TypeScript、パッケージマネージャは npm、CI は GitHub Actions
  • プレビュー環境のデプロイは 5 分以内を目標にする。デプロイ時間は、プレビューに載せられるアーキテクチャを規定する最重要の設計変数。ただし絶対の制約ではなく、検証の厳密性を優先するならデプロイ時間を妥協してプレビューのカバー範囲を広げる (共有を減らして PR ごとに持つ) 判断もあってよい。どちらに倒すかは Step 0 でユーザーと合意する
  • プレビュー環境は dev/staging 相当のアカウントにのみ作る (本番アカウントには作らない)
  • AWS 認証は OIDC (長寿命アクセスキーをリポジトリ secrets に置かない)

全体アーキテクチャ

スタックを 2 層に分ける。

  • 共有スタック (base): 作成に時間がかかる・高価な・環境で 1 つでよいリソースを置く。main ブランチへの push で従来どおりデプロイされる定常環境 (既存のスタックをそのまま流用してよい)
  • プレビュースタック (ephemeral): PR ごとに 1 つ作られ、共有スタックのリソースを参照する。アプリ層 (コンピュート・キュー・静的ホスティングなど) だけを含む

どのリソースをどちらに置くかは、作成時間で機械的に判断する。目安:

リソース 作成時間の目安 方針
VPC + NAT Gateway 2–5 分 共有
RDS / Aurora 10–20 分 共有 (クラスタごと共有。後述の「検証範囲の合意」参照)
OpenSearch / ElastiCache / MSK 15–40 分 共有
ACM 証明書 (DNS 検証) 数分〜数十分 共有 (wildcard 1 枚に集約。per-PR 発行は quota も圧迫する)
CloudFront distribution 3–8 分 PR ごとでも可。初回デプロイだけ 5 分を超えることを許容するか、他リソースと並走させて吸収する
ALB + ECS Service 5–10 分 プレビューでは API Gateway + Lambda への差し替えを検討 (下記)
Cognito / IdP 連携 共有 (callback URL 問題に注意、後述)
DynamoDB / SQS / SNS / S3 / EventBridge PR ごと
Lambda (zip) 秒〜 PR ごと
Lambda (コンテナ image) build 時間次第 PR ごと (build 高速化が必須、後述)

重要な考え方:

  • プレビューは本番と同一アーキテクチャでなくてよい。本番が ALB + ECS でも、プレビューは API Gateway + Lambda で動かすなど、「プレビュー」として多少の挙動差は許容しうる。既存の Web サーバーコンテナは Lambda Web Adapter を挟めば、アプリのコード変更ほぼなしで Lambda にそのまま載せられる
  • 共有によって検証できなくなる範囲を明文化してユーザーと合意する。例: DB を共有するなら「DB スキーマ変更 (migration) はプレビューでは検証できない。main マージ後の dev デプロイで当たる」と README に明記する。プレビュースタックには migration を含めない
  • サーバーレス中心なら、プレビュー環境の追加ランニングコストはほぼゼロになる

Step 0: リポジトリ調査と方針決定

実装前に以下を調査し、結果を分類表と決定事項リストとしてユーザーに提示すること (対話できない環境なら、安全なデフォルトを選び、前提として README に明記して進める)。

  1. 既存の CDK app 構成 (エントリポイント、スタック分割、環境の分け方) を読む
  2. 全リソースを上の表に従って「共有 / PR ごと / プレビューでは差し替え or 省略」に分類する
  3. 以下の決定事項を確認する:
    • デプロイ時間とカバー範囲のトレードオフ: 5 分目標を守るために共有を増やすか、検証範囲を優先してデプロイ時間を許容するか
    • ドメイン戦略 (後述の A/B から選択)
    • トリガー方式: /deploy コメント式 (デフォルト推奨) か、PR push ごとの自動デプロイか
    • デプロイ先アカウント/リージョンと、OIDC ロールの有無
    • 認証プロバイダ (Cognito / Auth0 など) がある場合、per-PR URL の callback 登録方法
  4. cdk synth が現状で通ることを確認してから着手する

ドメイン戦略

  • A. カスタムドメインなし (シンプル): CloudFront のデフォルトドメイン、API Gateway の execute-api URL、Lambda Function URL をそのまま使う。Route53 / ACM が一切不要。URL はデプロイ後にしか分からないので、CfnOutput で出して PR コメントに載せる
  • B. wildcard サブドメイン (Route53 hosted zone がある場合に推奨): 共有スタックで *.dev.example.com の wildcard 証明書を 1 枚だけ発行し、PR ごとに app-pr-<slug>.dev.example.com のような 第 1 階層のラベルに slug を畳んだ FQDN を割り当てる (wildcard 証明書は 1 階層しかカバーしないため)。URL が予測可能になり、IdP の wildcard callback 設定とも相性がよい

Step 1: CDK — スタック分割と参照の張り方

エントリポイント (bin) のパターン

環境変数 PREVIEW_BRANCH_NAME が立っているときだけ、プレビュースタックを共有スタックに「重ねて」宣言する。CI からはプレビュースタックだけを deploy する。

// bin/app.ts (テンプレート — 既存の構成に合わせて調整)
const previewBranch = process.env.PREVIEW_BRANCH_NAME;

const base = new BaseStack(app, "Dev-Base", {
  env: { account: DEV_ACCOUNT, region: REGION },
  // ...既存どおり
});

if (previewBranch) {
  const slug = branchToSlug(previewBranch);
  new PreviewStack(app, `Preview-${slug}`, {
    env: { account: DEV_ACCOUNT, region: REGION },
    slug,
    // 共有リソースは construct の直接参照で渡す。deploy 時に CDK が
    // CFN の cross-stack export/import に展開してくれる
    base,
    // slug はハッシュで潰れていて逆引きできないので、cleanup ジョブが
    // closed PR を特定するための tag として生ブランチ名を必ず付ける
    tags: { PreviewBranch: previewBranch },
  });
}

slug 関数

ブランチ名はスタック名にも DNS ラベルにも使えない文字を含むので、決定的な slug に変換する。決定的であることが重要で、同じブランチに push し直すと同じスタックが更新される (= 環境が使い回される)。

なお、識別子を単純に pr-<PR番号> にする案もある (変換不要で一意性も自明、cleanup の逆引き tag も不要になる)。slug 方式は URL やスタック名にブランチ名の情報が残るぶん、複数のプレビュー環境を並行運用するときに URL を見るだけでどの変更の環境か区別しやすいのが利点。どちらでもよいので、リポジトリの運用に合わせてユーザーと決めること。以下は slug 方式のテンプレート。

// lib/utils/slug.ts
import { createHash } from "node:crypto";

/**
 * ブランチ名から一意かつ DNS-safe な slug を作る。
 * 形式: `<sanitized-prefix (最大25文字)>-<sha1 先頭4文字>`
 * hash は生のブランチ名全体から計算するので、sanitize で同一に潰れる
 * ブランチ同士 (`foo-bar` と `foo_bar` など) も衝突しない。
 * 全体で最大 30 文字。DNS ラベル 63 文字制限・スタック名 128 文字制限に収まる。
 */
export function branchToSlug(branch: string): string {
  const hash = createHash("sha1").update(branch).digest("hex").slice(0, 4);
  const sanitized = branch
    .toLowerCase()
    .replace(/[^a-z0-9-]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .replace(/-+/g, "-");
  const truncated = sanitized.slice(0, 25).replace(/-+$/g, "");
  // 全文字が sanitize で潰れたとき (日本語ブランチ名など) のフォールバック
  const prefix = truncated.length > 0 ? truncated : "b";
  return `${prefix}-${hash}`;
}

共有スタック側に必要なワークアラウンド (重要)

(1) プレビュースタックが参照する値を恒久 export する。 これをやらないと、プレビュースタックが 1 つでも生きている状態で共有スタックを普通にデプロイした瞬間、Export cannot be deleted as it is in use で共有スタックの更新が失敗する。原因: PREVIEW_BRANCH_NAME なしの synth ではプレビュースタックが存在しないため、CDK の自動 export が全部 drop され、CFN が「使用中の export を消そうとしている」と判断するため。

// BaseStack のコンストラクタ末尾:
// PreviewStack が消費する値を恒久 export しておく。
// exportValue は CDK の自動 export と同じ export 名を使うので、
// プレビュースタック同梱時もそのまま再利用され、重複 export にならない。
// PreviewStack 側で参照を増やしたら、このリストにも必ず追加すること。
[
  this.vpc.vpcId,
  this.dbCluster.clusterEndpoint.hostname,
  cdk.Token.asString(this.dbCluster.clusterEndpoint.port), // 数値 Token は文字列化が必要
  this.appSecurityGroup.securityGroupId,
  // ... PreviewStack が base 経由で参照する値をすべて列挙
].forEach((v) => this.exportValue(v));

(2) 共有リソースの Security Group にプレビュースタック側から触らない。 CDK は dbCluster.connections.allowFrom(previewLambda, ...) のような暗黙の参照で、共有 RDS の SG に per-PR の ingress rule を生やしてしまう (= PR の増減のたびに共有スタックに diff が出る)。対策: アプリ用 SG を共有スタック側で 1 つだけ定義し、DB への ingress もそこで 1 本だけ張り、プレビュースタックの Lambda/ECS はその SG を借用する。プレビュースタック側では SG rule を一切作らない。

(3) クロスリージョン参照 (us-east-1 の CloudFront 証明書など) は共有スタックで「再 export」する。 プレビュースタックから us-east-1 のスタックを直接参照すると、crossRegionReferences の仕組み上、共有側 us-east-1 スタックの custom resource に PR ごとのエントリが追加され、PR の増減で共有スタックに diff が出る。対策: 共有スタック (メインリージョン側) で手書きの CfnOutput として export し直し、プレビュースタックはそれを import する。

// BaseStack にて。Stack.exportValue は他スタック所有のリソースを引数に
// 取れないため、CfnOutput を手書きする。
new cdk.CfnOutput(this, "CertificateArn", {
  exportName: `${this.stackName}-CertificateArn`,
  value: usEast1Certificate.certificateArn, // crossRegionReferences で受けた値
});
this.certificate = acm.Certificate.fromCertificateArn(
  this, "ReExportedCertificate",
  cdk.Fn.importValue(`${this.stackName}-CertificateArn`),
);
// PreviewStack には this.certificate を渡す

プレビュースタック側の規約

  • cdk destroy / delete-stack 一発で完全に消えること。 スタック全体に cdk.RemovalPolicies.of(this).destroy() を適用する (古い aws-cdk-lib に RemovalPolicies が無ければ upgrade するか、Aspect で全リソースの RemovalPolicy を DESTROY に倒す)。S3 バケットは加えて autoDeleteObjects: true が必要
  • 固定の物理名を持つリソースを置かない。 名前付き IAM ロール、固定名の SSM パラメータ、固定名の LogGroup などは 2 つ目の PR スタックで衝突する。物理名は CFN の自動命名に任せるか、必ず slug を含める
  • 環境で単一であるべきものを含めない。 DB migration、シングルトンの cron、外部システムへ値を publish する仕組みなどは共有スタックに置いたまま、プレビュースタックからは除外する
  • 本番用スタックとコードを共有する場合は、アプリ層を Construct として切り出し、共有スタックとプレビュースタックの両方からインスタンス化する形にすると重複がない

補足 Tips

  • fromLookup (VPC / hosted zone) を使う場合は cdk.context.json をコミットし、CI での synth を決定的にする
  • コンテナ image の Lambda を使う場合: GitHub Actions ランナーのアーキテクチャを image の platform と一致させる (arm64 image なら arm ランナー)。QEMU エミュレーションが挟まるとビルドが数倍遅くなる。それでも遅ければ、プレビューだけ zip 形式の NodejsFunction 等に差し替えることも検討
  • IdP の callback URL: Auth0 のように wildcard callback (https://app-pr-*.dev.example.com) を許可できる IdP ならドメイン戦略 B と組み合わせて設定一発で済む。Cognito は wildcard 不可なので、プレビュースタック内の custom resource で自分の URL を UserPoolClient の callback リストに追加/削除する

Step 2: フロントエンド (SPA) がある場合

SPA のビルドには「API の URL」などデプロイしてみないと分からない値が要る、という鶏と卵の問題がある。解決策は 2 つ。どちらかを採用する:

  • a. deploy-time build: @cdklabs/deploy-time-buildNodejsBuild を使い、CDK デプロイ中に CodeBuild 上で SPA をビルドする。ビルド env に CFN Token (API URL 等) をそのまま渡せる (デプロイ時に解決される)。ビルドが他リソースの作成と並走するので、デプロイ時間の節約にもなる
    • 注意: NodejsBuild に CloudFront distribution を渡すと distribution の作成完了 (初回は時間がかかる) を待ってからビルドが走る直列関係になる。distribution は渡さず、ビルド完了を示す値を physicalResourceId にした AwsCustomResource で CloudFront invalidation を別途発行し、ビルドと distribution 作成を並走させる
  • b. runtime config: ビルドは環境非依存にしておき、config.json を S3 に置いて SPA が起動時に fetch する。ビルドを CI ランナー側で 1 回で済ませられる

Step 3: GitHub Actions — デプロイワークフロー

デフォルトは /deploy コメントによるオンデマンドデプロイとする (push ごとの自動デプロイはコストと fork PR のセキュリティ面で慎重に。信頼できるメンバーだけの private リポジトリなら pull_request: types: [opened, synchronize] への変更も可)。

テンプレート (.github/workflows/deploy-preview.yml)。コメントに書いた理由ごと維持すること:

name: Deploy-Preview

on:
  issue_comment:
    types: [created]
  # 検証用の手動トリガ。workflow_dispatch は GHA 側で write 権限が要るので追加の gating 不要
  workflow_dispatch:
    inputs:
      pr_number:
        description: deploy 対象の PR 番号
        required: true
        type: number

# 同一 PR の連続 deploy は直列化、別 PR は並列。前段の deploy を途中で殺すと
# CloudFormation が半端な状態になるので cancel-in-progress は false
concurrency:
  group: deploy-preview-${{ inputs.pr_number || github.event.issue.number }}
  cancel-in-progress: false

env:
  ROLE_ARN: arn:aws:iam::<DEV_ACCOUNT_ID>:role/<GHA_DEPLOY_ROLE>
  AWS_REGION: <REGION>

permissions:
  id-token: write      # OIDC で AWS にアクセス
  contents: read       # checkout
  pull-requests: write # 結果コメント
  issues: write        # コメントへの reaction (PR コメントは issue comment 扱い)

jobs:
  deploy:
    # PR 上のコメントかつ /deploy 始まり、かつ write 以上を持つ人だけに限定
    # (fork PR からの誤発火・外部ユーザーによる勝手な deploy を防ぐ)
    if: |
      github.event_name == 'workflow_dispatch'
      || (github.event.issue.pull_request != null
          && startsWith(github.event.comment.body, '/deploy')
          && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
    runs-on: ubuntu-latest  # コンテナ image Lambda が arm64 なら arm ランナーに変える
    env:
      GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      PR_NUMBER: ${{ inputs.pr_number || github.event.issue.number }}
    steps:
      # 受け付けたことを最速で伝える (deploy は数分かかるので)
      - name: React to comment
        if: github.event_name == 'issue_comment'
        run: |
          gh api --method POST \
            repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions \
            -f content=eyes

      # issue_comment / workflow_dispatch の checkout は default branch になる。
      # PR の head を API から取り直し、SHA で pin する (走行中に force push
      # されてもこの run は固定 commit を deploy する)
      - name: Resolve PR head
        id: pr
        run: |
          PR_DATA=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUMBER")
          {
            echo "branch=$(echo "$PR_DATA" | jq -r .head.ref)"
            echo "sha=$(echo "$PR_DATA" | jq -r .head.sha)"
          } >> "$GITHUB_OUTPUT"

      - uses: actions/checkout@v4
        with:
          ref: ${{ steps.pr.outputs.sha }}

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ env.ROLE_ARN }}
          aws-region: ${{ env.AWS_REGION }}

      - run: npm ci

      - name: Deploy preview stack
        id: deploy
        env:
          PREVIEW_BRANCH_NAME: ${{ steps.pr.outputs.branch }}
        run: |
          # --exclusively が無いと base 参照経由で共有スタックまで巻き込んで
          # update される。プレビュースタックだけに絞る
          npx cdk deploy "Preview-*" \
            --exclusively \
            --require-approval never \
            --outputs-file preview-outputs.json

      # 結果コメントは marker 付きで投稿し、再 deploy 時に過去分を削除して
      # PR の履歴を綺麗に保つ。掃除は成否に関わらず走らせる
      - name: Delete previous result comments
        if: always()
        run: |
          gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" --paginate \
            --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains("<!-- preview-deploy-result -->"))) | .id' \
            | while read -r id; do
                gh api -X DELETE "repos/${{ github.repository }}/issues/comments/$id" || true
              done

      - name: Comment on success
        if: success()
        env:
          PR_BRANCH: ${{ steps.pr.outputs.branch }}
          PR_SHA: ${{ steps.pr.outputs.sha }}
        run: |
          STACK_NAME=$(jq -r 'keys[0]' preview-outputs.json)
          {
            echo "<!-- preview-deploy-result -->"
            echo "✅ Deployed \`$PR_BRANCH\` (\`${PR_SHA:0:7}\`) to **$STACK_NAME**"
            echo
            jq -r '.[keys[0]] | to_entries[] | "- \(.key): \(.value)"' preview-outputs.json
          } > pr-comment.md
          gh pr comment "$PR_NUMBER" --body-file pr-comment.md

      - name: Comment on failure
        if: failure()
        env:
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          {
            echo "<!-- preview-deploy-result -->"
            echo "❌ Deploy failed. See [Actions run]($RUN_URL)."
          } > pr-comment.md
          gh pr comment "$PR_NUMBER" --body-file pr-comment.md

注意 (README にも書くこと): issue_comment トリガーのワークフローは default branch 上の定義が実行される。つまりこのワークフロー自体が main にマージされるまで /deploy は効かない。ワークフロー自体を変更する PR も /deploy では検証できないので、workflow_dispatchgh workflow run --ref <branch> で叩いて検証する。

OIDC ロールが無い場合

GitHub OIDC provider + デプロイロールのセットアップ手順 (CloudFormation テンプレート or CDK スタック or CLI コマンド) を別途生成し、管理者が一度だけ実行するよう README に記載する。trust policy の sub 条件は必ず対象リポジトリに絞る。ロール権限は CDK デプロイに必要な範囲 (最低限 cdk bootstrap の deploy ロール群を assume できる権限) にする。

Step 4: GitHub Actions — クリーンアップワークフロー

PR の close をトリガーにするのではなく、**日次 cron で「スタック一覧と open PR の突き合わせ」**を行う方式にする (close イベント取りこぼしや削除失敗があっても、次回の cron が拾うので自己修復的)。

テンプレート (.github/workflows/cleanup-preview.yml) の要点:

name: Cleanup-Preview-Stacks
on:
  schedule:
    - cron: "0 18 * * *"  # CI のピーク時間帯を外す
  workflow_dispatch:
    inputs:
      dry_run:
        description: 削除対象を出すだけで delete-stack は呼ばない
        type: boolean
        default: true

# describe → delete の間に別 run が走ると競合するので常に直列化
concurrency:
  group: cleanup-preview-stacks
  cancel-in-progress: false

permissions:
  id-token: write
  contents: read
  pull-requests: read

ジョブのロジック (bash + aws cli + gh cli で実装):

  1. gh pr list --state open --json headRefName で open な PR のブランチ一覧を取る。空行は除去する (空行が混じると後段のマッチが誤爆して全スタックが「open 扱い」になる)
  2. aws cloudformation describe-stacks でスタック名が Preview- で始まるものを列挙。IN_PROGRESS 状態のスタックは除外 (deploy ワークフローが触っている最中の可能性がある)
  3. 各スタックの PreviewBranch tag を読む。tag が無いスタックは warning を出して skip (手動作成スタックの巻き込み防止)
  4. tag のブランチが open PR 一覧に無ければ aws cloudformation delete-stack削除完了は待たない (ランナーの課金時間を節約。失敗や残骸は翌日の cron が拾う)
  5. dry_run 時は削除せず対象を列挙するだけ。結果 (deleted / kept / skipped) を $GITHUB_STEP_SUMMARY に表として出力する

オプション: CDK の custom resource (S3 autoDeleteObjects 等) が使う Lambda は、CFN 削除中に Lambda サービスが LogGroup を自動再作成する race があり、スタック削除後も /aws/lambda/Preview-* の LogGroup が残ることがある。気になる場合は cleanup ジョブの最後に、現存スタックに紐付かない該当 prefix の LogGroup をスイープするステップを足す。

Step 5: 検証

実装後、以下を順に確認すること:

  1. PREVIEW_BRANCH_NAME なしnpx cdk synth が通る (従来のデプロイが壊れていない)
  2. PREVIEW_BRANCH_NAME=feature/test-branch ありnpx cdk synth が通り、cdk listPreview-<slug> が出る
  3. PREVIEW_BRANCH_NAME ありの状態で、共有スタックに対する cdk diff が no changes であること。 ここに diff が出るなら暗黙の cross-stack 参照 (SG rule、cross-region export、自動 export の増減) が漏れている。プレビュースタックの追加が共有スタックを 1 文字も変更しない状態になるまで直す
  4. 逆方向: プレビュースタックが参照する値がすべて恒久 export リストに載っていることを、synth 結果 (共有スタックの Outputs) を目で見て確認する
  5. 可能ならユーザーに実デプロイしてもらい (ワークフローを main にマージ → /deploy)、CloudFormation コンソールのタイムライン表示で時間を食っているリソースを特定する。目標 (5 分) を超える場合は「共有に移す / 別アーキテクチャに差し替える / 初回のみ許容 or 検証範囲優先と README に明記」のいずれかで解消する
  6. cleanup ワークフローを dry_run: true で実行し、削除対象の判定が正しいことを確認する

最後にユーザーへ報告すること

  • 共有 / PR ごとの最終的なリソース分類表
  • プレビューで検証できる範囲・できない範囲 (README に書いた内容。例: DB スキーマ変更は対象外)
  • 残っている手動セットアップ (OIDC ロール作成、IdP の callback URL 設定、ワークフローの main へのマージが先に必要なこと)
  • 概算コスト (PR スタック 1 つあたりのランニングコスト。サーバーレス構成ならほぼゼロのはず)
  • 運用方法: /deploy の使い方、同一ブランチへの再 push で同じ環境が更新されること、close 後は翌日の cron で消えること
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment