TL;DR
- GASとVue.jsでリアルタイムにYAMLのプレビューができるツールを作ってみた
-
checkoutやAWS Credentialsの設定等の頻出アクションをボタンにまとめてみた - VSCode拡張機能で作った方がよさそう
はじめに
CI/CDに取り組んでいる皆さん、Github Actionsのyml書くの面倒くさくないですか?
定期的にGithub ActionsでCI/CDを組む機会がありますが、あまりymlを書きなれていないのもあり、タイポをしたり構文違ったりとうだうだしています。
最近はClaudeやGeminiがyml書いてくれたりしますが、全体のフローとしてしっかり理解してはおきたい。
そこでGoogle App Scriptを使ってGUIでymlファイルを作れるようにしようと考えました。
作ってみた
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では、ミッション・ビジョンの実現に向けて、新たな仲間を募集しています。
少しでもご興味をお持ちいただけましたら、ぜひエントリーをご検討ください。
また、すぐに転職をお考えでない場合でも、カジュアル面談からのスタートも大歓迎です。
まずはお気軽にお話しできますと幸いです!
■エントリーページ
■カジュアル面談の申し込み
