4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Github ActionsのymlファイルをGUIで作ってみたい

4
Last updated at Posted at 2026-02-20

TL;DR

  • GASとVue.jsでリアルタイムにYAMLのプレビューができるツールを作ってみた
  • checkoutAWS Credentialsの設定等の頻出アクションをボタンにまとめてみた
  • VSCode拡張機能で作った方がよさそう

はじめに

CI/CDに取り組んでいる皆さん、Github Actionsのyml書くの面倒くさくないですか?
定期的にGithub ActionsでCI/CDを組む機会がありますが、あまりymlを書きなれていないのもあり、タイポをしたり構文違ったりとうだうだしています。
最近はClaudeやGeminiがyml書いてくれたりしますが、全体のフローとしてしっかり理解してはおきたい。
そこでGoogle App Scriptを使ってGUIでymlファイルを作れるようにしようと考えました。

作ってみた

image.png

Google App Scriptでプロジェクトを立ち上げ、下記のコード構成でGUIのGithub Actions Builderを作ってみました。

Code.gs

function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('GitHub Actions YAML Builder')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

index.html

<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
  
  <style>
    body { background-color: #f4f6f8; font-size: 14px; color: #333; }
    .card { border: none; box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075); margin-bottom: 20px; }
    .card-header { background-color: #fff; border-bottom: 1px solid #edf2f7; font-weight: 600; padding: 15px; }
    
    /* ステップ編集エリア */
    .step-card { border: 1px solid #e2e8f0; border-radius: 6px; margin-bottom: 12px; background: #fff; }
    .step-header { padding: 10px 15px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; background: #f8f9fa; }
    .step-body { padding: 15px; border-top: 1px solid #e2e8f0; }
    
    /* 入力エリア */
    .kv-row { display: flex; gap: 5px; margin-bottom: 5px; align-items: center; }
    textarea.code-editor { font-family: 'Consolas', monospace; font-size: 12px; background: #2d2d2d; color: #e0e0e0; border: none; }
    
    /* プレビュー */
    .sticky-sidebar { position: sticky; top: 20px; max-height: 90vh; overflow-y: auto; }
    pre.preview { background-color: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 6px; font-family: 'Consolas', monospace; font-size: 12px; white-space: pre-wrap; }
    
    .badge-method { font-size: 0.7rem; margin-right: 5px; }
  </style>
</head>
<body>

  <div id="app" class="container-fluid py-4">
    <div class="row">
      <div class="col-lg-7">
        <h4 class="mb-4 text-primary"><i class="bi bi-robot"></i> GitHub Actions Builder Pro</h4>

        <div class="card">
          <div class="card-header d-flex justify-content-between align-items-center" data-bs-toggle="collapse" href="#collapseBasic">
            <span><i class="bi bi-gear"></i> Basic & Permissions</span>
            <i class="bi bi-chevron-down"></i>
          </div>
          <div class="collapse show" id="collapseBasic">
            <div class="card-body">
              <div class="mb-3">
                <label class="form-label fw-bold">Workflow Name</label>
                <input type="text" v-model="workflow.name" class="form-control" placeholder="Automated Code Review">
              </div>

              <label class="form-label fw-bold">Permissions (Top Level)</label>
              <div class="row g-2">
                <div class="col-md-4" v-for="perm in ['contents', 'pull-requests', 'issues', 'id-token']" :key="perm">
                  <div class="input-group input-group-sm">
                    <span class="input-group-text">{{ perm }}</span>
                    <select class="form-select" v-model="workflow.permissions[perm]">
                      <option value="">(Default)</option>
                      <option value="read">read</option>
                      <option value="write">write</option>
                      <option value="none">none</option>
                    </select>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>

        <div class="card">
          <div class="card-header" data-bs-toggle="collapse" href="#collapseTriggers">
            <span><i class="bi bi-lightning"></i> Triggers (on)</span>
          </div>
          <div class="collapse show" id="collapseTriggers">
            <div class="card-body">
              
              <div class="form-check form-switch mb-2">
                <input class="form-check-input" type="checkbox" v-model="triggers.pull_request.enabled" id="triggerPR">
                <label class="form-check-label fw-bold" for="triggerPR">pull_request</label>
              </div>
              <div v-if="triggers.pull_request.enabled" class="ms-4 mb-3 border-start ps-3 border-primary">
                <label class="small text-muted">Types (例: opened, synchronize)</label>
                <input type="text" v-model="triggers.pull_request.types" class="form-control form-control-sm mb-2" placeholder="opened, synchronize, reopened">
                <label class="small text-muted">Branches</label>
                <input type="text" v-model="triggers.pull_request.branches" class="form-control form-control-sm" placeholder="main, develop">
              </div>

              <div class="form-check form-switch mb-2">
                <input class="form-check-input" type="checkbox" v-model="triggers.workflow_dispatch.enabled" id="triggerDispatch">
                <label class="form-check-label fw-bold" for="triggerDispatch">workflow_dispatch (Manual)</label>
              </div>
              <div v-if="triggers.workflow_dispatch.enabled" class="ms-4 mb-3 border-start ps-3 border-primary">
                <label class="small text-muted fw-bold">Inputs definition</label>
                <div v-for="(input, idx) in triggers.workflow_dispatch.inputs" :key="idx" class="d-flex gap-2 mb-2">
                  <input type="text" v-model="input.key" class="form-control form-control-sm" placeholder="key (e.g. pr_number)" style="width: 120px;">
                  <input type="text" v-model="input.description" class="form-control form-control-sm" placeholder="Description">
                  <select v-model="input.required" class="form-select form-select-sm" style="width: 80px;"><option :value="true">Req</option><option :value="false">Opt</option></select>
                  <button class="btn btn-sm btn-outline-danger" @click="triggers.workflow_dispatch.inputs.splice(idx, 1)"><i class="bi bi-trash"></i></button>
                </div>
                <button class="btn btn-sm btn-outline-secondary" @click="triggers.workflow_dispatch.inputs.push({key:'', description:'', required:true, type:'string'})">+ Input</button>
              </div>

            </div>
          </div>
        </div>

        <div v-for="(job, jIndex) in workflow.jobs" :key="jIndex" class="card border-primary">
          <div class="card-header bg-light d-flex justify-content-between align-items-center">
            <div>
              <span class="badge bg-primary me-2">JOB</span>
              <input type="text" v-model="job.id" class="d-inline-block form-control form-control-sm border-0 bg-transparent fw-bold" style="width: 200px;" placeholder="Job ID">
            </div>
            <button class="btn btn-sm btn-outline-danger" @click="removeJob(jIndex)" v-if="workflow.jobs.length > 1">削除</button>
          </div>
          <div class="card-body">
            <div class="row mb-3">
              <div class="col-md-6">
                <label class="form-label small">Runs On</label>
                <select v-model="job.runsOn" class="form-select form-select-sm">
                  <option>ubuntu-latest</option>
                  <option>windows-latest</option>
                  <option>macos-latest</option>
                </select>
              </div>
            </div>

            <h6 class="border-bottom pb-2 mb-3">Steps</h6>

            <div v-for="(step, sIndex) in job.steps" :key="sIndex" class="step-card">
              <div class="step-header" @click="step.expanded = !step.expanded">
                <div>
                  <span class="badge" :class="step.type === 'uses' ? 'bg-success' : 'bg-dark'">{{ step.type }}</span>
                  <span class="fw-bold ms-2">{{ step.name || '(No Name)' }}</span>
                  <small class="text-muted ms-2" v-if="step.type === 'uses'">{{ step.command }}</small>
                </div>
                <div>
                  <i class="bi" :class="step.expanded ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
                  <button class="btn btn-sm btn-link text-danger p-0 ms-3" @click.stop="removeStep(jIndex, sIndex)"><i class="bi bi-x-lg"></i></button>
                </div>
              </div>
              
              <div class="step-body" v-show="step.expanded">
                <div class="row g-2 mb-2">
                  <div class="col-md-6">
                    <label class="small text-muted">Name</label>
                    <input type="text" v-model="step.name" class="form-control form-control-sm">
                  </div>
                  <div class="col-md-3">
                    <label class="small text-muted">ID (for outputs)</label>
                    <input type="text" v-model="step.id" class="form-control form-control-sm" placeholder="step-id">
                  </div>
                  <div class="col-md-3">
                    <label class="small text-muted">Type</label>
                    <select v-model="step.type" class="form-select form-select-sm">
                      <option value="uses">uses</option>
                      <option value="run">run</option>
                    </select>
                  </div>
                </div>

                <div class="mb-2">
                  <label class="small text-muted">Condition (if)</label>
                  <input type="text" v-model="step.ifCondition" class="form-control form-control-sm" placeholder="steps.check.outputs.changed == 'true'">
                </div>

                <div class="mb-3">
                  <label class="small text-muted">{{ step.type === 'uses' ? 'Action (uses)' : 'Script (run)' }}</label>
                  <div v-if="step.type === 'uses'">
                    <input type="text" v-model="step.command" class="form-control form-control-sm" placeholder="actions/checkout@v4">
                  </div>
                  <div v-else>
                    <textarea v-model="step.command" class="form-control code-editor" rows="3" placeholder="echo 'Hello'"></textarea>
                  </div>
                </div>

                <div class="row">
                  <div class="col-md-6">
                    <label class="small text-muted fw-bold">With (Arguments)</label>
                    <div v-for="(val, key) in step.with" :key="key" class="kv-row">
                      <input type="text" :value="key" @input="updateKey(step.with, key, $event)" class="form-control form-control-sm" placeholder="Key">
                      <input type="text" v-model="step.with[key]" class="form-control form-control-sm" placeholder="Value">
                      <button class="btn btn-sm btn-outline-secondary" @click="deleteKey(step.with, key)">×</button>
                    </div>
                    <button class="btn btn-sm btn-link text-decoration-none p-0" @click="addKey(step.with)">+ Add Param</button>
                  </div>
                  <div class="col-md-6">
                    <label class="small text-muted fw-bold">Env (Variables)</label>
                    <div v-for="(val, key) in step.env" :key="key" class="kv-row">
                      <input type="text" :value="key" @input="updateKey(step.env, key, $event)" class="form-control form-control-sm" placeholder="Key">
                      <input type="text" v-model="step.env[key]" class="form-control form-control-sm" placeholder="Value">
                      <button class="btn btn-sm btn-outline-secondary" @click="deleteKey(step.env, key)">×</button>
                    </div>
                    <button class="btn btn-sm btn-link text-decoration-none p-0" @click="addKey(step.env)">+ Add Env</button>
                  </div>
                </div>

              </div>
            </div>

            <div class="d-flex gap-2 mt-3 flex-wrap">
               <button class="btn btn-sm btn-secondary" @click="addStep(jIndex)">+ Empty Step</button>
               <span class="text-muted mx-1">|</span>
               <button class="btn btn-sm btn-outline-info" @click="addSnippet(jIndex, 'checkout')">Checkout</button>
               <button class="btn btn-sm btn-outline-info" @click="addSnippet(jIndex, 'setup-python')">Python</button>
               <button class="btn btn-sm btn-outline-info" @click="addSnippet(jIndex, 'aws')">AWS Creds</button>
               <button class="btn btn-sm btn-outline-info" @click="addSnippet(jIndex, 'script')">Script Action</button>
            </div>

          </div>
        </div>

        <button class="btn btn-outline-primary w-100 mb-5" @click="addJob">+ Add Job</button>
      </div>

      <div class="col-lg-5">
        <div class="sticky-sidebar">
          <div class="card bg-dark text-white">
            <div class="card-header bg-black d-flex justify-content-between align-items-center border-secondary">
              <span class="small">YAML Preview</span>
              <button class="btn btn-sm btn-light py-0" @click="copyToClipboard"><i class="bi bi-clipboard"></i> Copy</button>
            </div>
            <div class="card-body p-0">
              <pre class="preview m-0"><code>{{ yamlOutput }}</code></pre>
            </div>
          </div>
          <div class="alert alert-info mt-3 small">
            <i class="bi bi-info-circle"></i> <strong>Tips:</strong>
            <ul class="mb-0 ps-3">
              <li>"with" や "env" はKey-Value形式で追加できます。</li>
              <li>runコマンドで改行を入れると自動的に <code>|</code> ブロックになります。</li>
              <li>Bedrock連携等はスニペットを活用してください。</li>
            </ul>
          </div>
        </div>
      </div>
    </div>
  </div>

  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script>

  <script>
    const { createApp } = Vue;

    createApp({
      data() {
        return {
          triggers: {
            pull_request: { enabled: true, types: 'opened, synchronize, reopened', branches: 'main, develop, feature-*' },
            workflow_dispatch: { 
              enabled: true,
              inputs: [
                { key: 'pr_number', description: 'Target PR Number', required: true, type: 'string' }
              ]
            }, 
            schedule: { enabled: false, cron: '0 0 * * *' }
          },
          workflow: {
            name: 'Github Action Builder',
            permissions: { contents: 'read', 'pull-requests': 'write', issues: '', 'id-token': '' },
            jobs: [
              {
                id: 'bedrock-code-review',
                runsOn: 'ubuntu-latest',
                steps: [
                  // 初期サンプル: Checkout
                  { 
                    name: 'Checkout code', type: 'uses', command: 'actions/checkout@v4', expanded: false,
                    with: { 'fetch-depth': '0' }, env: {}, ifCondition: '', id: '' 
                  }
                ]
              }
            ]
          }
        }
      },
      computed: {
        yamlOutput() {
          const onObj = {};

          // 1. Triggers
          if (this.triggers.pull_request.enabled) {
            const pr = {};
            if (this.triggers.pull_request.types) pr.types = this.triggers.pull_request.types.split(',').map(s => s.trim());
            if (this.triggers.pull_request.branches) pr.branches = this.triggers.pull_request.branches.split(',').map(s => s.trim());
            onObj.pull_request = Object.keys(pr).length ? pr : null;
          }

          if (this.triggers.workflow_dispatch.enabled) {
            const wd = {};
            if (this.triggers.workflow_dispatch.inputs && this.triggers.workflow_dispatch.inputs.length > 0) {
              wd.inputs = {};
              this.triggers.workflow_dispatch.inputs.forEach(i => {
                if(i.key) wd.inputs[i.key] = { description: i.description, required: i.required, type: i.type || 'string' };
              });
            }
            onObj.workflow_dispatch = Object.keys(wd).length ? wd : null;
          }

          // 2. Permissions
          const permObj = {};
          for (const [k, v] of Object.entries(this.workflow.permissions)) {
            if (v) permObj[k] = v;
          }

          // Build Final Object
          const finalObj = {
            name: this.workflow.name,
            on: onObj
          };
          if (Object.keys(permObj).length) finalObj.permissions = permObj;

          finalObj.jobs = {};

          // 3. Jobs & Steps
          this.workflow.jobs.forEach(job => {
            const jobSteps = job.steps.map(s => {
              const stepObj = {};
              if (s.name) stepObj.name = s.name;
              if (s.id) stepObj.id = s.id;
              if (s.ifCondition) stepObj.if = s.ifCondition;
              
              if (s.type === 'uses') {
                stepObj.uses = s.command;
              } else {
                stepObj.run = s.command;
              }

              if (Object.keys(s.with).length) stepObj.with = s.with;
              if (Object.keys(s.env).length) stepObj.env = s.env;

              return stepObj;
            });

            finalObj.jobs[job.id || 'job'] = {
              'runs-on': job.runsOn,
              steps: jobSteps
            };
          });

          try {
            return jsyaml.dump(finalObj, { lineWidth: -1, noRefs: true });
          } catch (e) {
            return 'Error: ' + e.message;
          }
        }
      },
      methods: {
        addJob() {
          this.workflow.jobs.push({ id: 'new-job', runsOn: 'ubuntu-latest', steps: [] });
        },
        removeJob(index) {
          this.workflow.jobs.splice(index, 1);
        },
        // 汎用的なKey-Value操作
        addKey(obj) {
          // リアクティブにするため新しいオブジェクトを代入
          const newKey = 'new_key_' + Math.floor(Math.random() * 1000);
          obj[newKey] = '';
        },
        deleteKey(obj, key) {
          delete obj[key];
        },
        updateKey(obj, oldKey, event) {
          const newKey = event.target.value;
          if (newKey !== oldKey) {
            obj[newKey] = obj[oldKey];
            delete obj[oldKey];
          }
        },
        addStep(jobIndex) {
          this.workflow.jobs[jobIndex].steps.push(this.createStepTemplate());
        },
        removeStep(jobIndex, stepIndex) {
          this.workflow.jobs[jobIndex].steps.splice(stepIndex, 1);
        },
        createStepTemplate(overrides = {}) {
          return { 
            name: '', type: 'run', command: '', expanded: true, 
            id: '', ifCondition: '', with: {}, env: {}, ...overrides 
          };
        },
        // スニペット機能: よく使う設定を一発追加
        addSnippet(jobIndex, type) {
          let step = {};
          if (type === 'checkout') {
            step = this.createStepTemplate({ name: 'Checkout code', type: 'uses', command: 'actions/checkout@v4', with: {'fetch-depth': '0'} });
          } else if (type === 'setup-python') {
            step = this.createStepTemplate({ 
              name: 'Set up Python', type: 'uses', command: 'actions/setup-python@v5', 
              with: { 'python-version': '3.11', 'cache': 'pip' },
              ifCondition: "steps.changed-files.outputs.any_changed == 'true'"
            });
          } else if (type === 'aws') {
            step = this.createStepTemplate({
              name: 'Configure AWS Credentials', type: 'uses', command: 'aws-actions/configure-aws-credentials@v4',
              with: { 'aws-access-key-id': '${{ secrets.AWS_ACCESS_KEY_ID }}', 'aws-secret-access-key': '${{ secrets.AWS_SECRET_ACCESS_KEY }}', 'aws-region': 'ap-northeast-1' }
            });
          } else if (type === 'script') {
            step = this.createStepTemplate({
              name: 'Run Script', type: 'run', command: 'echo "Hello"\nls -la',
              env: { 'MY_ENV': 'production' }
            });
          }
          this.workflow.jobs[jobIndex].steps.push(step);
        },
        copyToClipboard() {
          navigator.clipboard.writeText(this.yamlOutput);
          alert('YAML copied!');
        }
      }
    }).mount('#app');
  </script>
</body>
</html>

お試しで簡素に作ってみただけなので、全く機能網羅はしていませんし、実用に耐えうるレベルにはなっていませんが、ちょっとした構築の勉強用としては良さそうです。

課題と今後

画像のレベルまで作れたタイミングでふと、「これVSCodeの拡張機能であった方がAIとの連携なんかもできていいのでは?」という気がしてきました。

第一、もう少し処理フローを直感的に理解できるようにした方が使いやすそうです。
もともとVSCodeの拡張機能開発に少し興味があったので、今後VSCodeの拡張機能としてノーコードyml Builderを作ってみようと思います。

さいごに

MSOL Digitalでは、ミッション・ビジョンの実現に向けて、新たな仲間を募集しています。
 少しでもご興味をお持ちいただけましたら、ぜひエントリーをご検討ください。
また、すぐに転職をお考えでない場合でも、カジュアル面談からのスタートも大歓迎です。
まずはお気軽にお話しできますと幸いです!

■エントリーページ

■カジュアル面談の申し込み

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?