From 4c405f4242e70b86777cc156967ac6643af422c0 Mon Sep 17 00:00:00 2001 From: mt4110 <4221282+mt4110@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:50:46 +0900 Subject: [PATCH 1/3] Improve local act workflow UX --- .github/workflows/verify.yml | 94 ++++-- README.md | 41 +++ README_EN.md | 40 +++ docs/ci/FLOW.md | 3 +- docs/ci/QUICKSTART.md | 29 ++ ops/ci/ci_self.sh | 408 ++++++++++++++++++++++++ ops/ci/ci_self_test.go | 358 ++++++++++++++++++++- ops/ci/scaffold_verify_workflow.sh | 115 +++++-- ops/ci/scaffold_verify_workflow_test.go | 163 ++++++++++ 9 files changed, 1193 insertions(+), 58 deletions(-) create mode 100644 ops/ci/scaffold_verify_workflow_test.go diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 3b748a3..107e33b 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -13,7 +13,7 @@ permissions: jobs: verify-lite: - if: ${{ vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) }} + if: ${{ github.event.act == true || (vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) }} runs-on: - self-hosted - mac-mini @@ -23,44 +23,72 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 - name: Setup Go + if: ${{ !env.ACT }} uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff with: go-version-file: go.mod + - name: Setup Go For act + if: ${{ env.ACT }} + shell: bash + run: | + if command -v go >/dev/null 2>&1; then + go version + elif command -v mise >/dev/null 2>&1; then + mise x -- go version + else + echo "ERROR: go or mise is required for local act runs" + exit 1 + fi + - name: Verify Lite - run: go run ./cmd/verify-lite + shell: bash + run: | + run_go() { + if command -v go >/dev/null 2>&1; then + go "$@" + elif command -v mise >/dev/null 2>&1; then + mise x -- go "$@" + else + echo "ERROR: go or mise is required" + return 127 + fi + } + run_go run ./cmd/verify-lite - name: Append Verify Lite Status To Job Summary if: always() run: | + if [[ -z "${GITHUB_STEP_SUMMARY:-}" ]]; then + echo "SKIP: step_summary reason=missing_env" + exit 0 + fi echo "## verify-lite.status" >> "$GITHUB_STEP_SUMMARY" cat out/verify-lite.status >> "$GITHUB_STEP_SUMMARY" || echo "(missing: out/verify-lite.status)" >> "$GITHUB_STEP_SUMMARY" - name: Evaluate Verify Lite Status if: always() - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b - with: - script: | - const fs = require('fs'); - const path = 'out/verify-lite.status'; - if (!fs.existsSync(path)) { - core.setFailed('verify-lite.status missing'); - } else { - const content = fs.readFileSync(path, 'utf8'); - if (content.includes('ERROR:') || content.includes('status=ERROR')) { - core.setFailed('verify-lite reported ERROR'); - } - } + shell: bash + run: | + path='out/verify-lite.status' + if [[ ! -f "$path" ]]; then + echo "verify-lite.status missing" + exit 1 + fi + if grep -Eq 'ERROR:|status=ERROR' "$path"; then + echo "verify-lite reported ERROR" + exit 1 + fi - name: Notify Discord (CI Alerts) - if: always() + if: ${{ always() && !env.ACT }} continue-on-error: true env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} run: go run ./cmd/notify_discord --status out/verify-lite.status --title "verify-lite" --webhook-env DISCORD_WEBHOOK_URL --min-level ERROR verify-full-dryrun: - if: ${{ vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) }} + if: ${{ github.event.act == true || (vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) }} needs: verify-lite runs-on: - self-hosted @@ -82,11 +110,15 @@ jobs: - name: Append Verify Full Status To Job Summary if: always() run: | + if [[ -z "${GITHUB_STEP_SUMMARY:-}" ]]; then + echo "SKIP: step_summary reason=missing_env" + exit 0 + fi echo "## verify-full.status" >> "$GITHUB_STEP_SUMMARY" cat out/verify-full.status >> "$GITHUB_STEP_SUMMARY" || echo "(missing: out/verify-full.status)" >> "$GITHUB_STEP_SUMMARY" - name: Upload Verify Full Artifacts - if: always() + if: ${{ always() && !env.ACT }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: verify-full-dryrun-${{ github.run_id }} @@ -98,22 +130,20 @@ jobs: - name: Evaluate Verify Full Status if: always() - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b - with: - script: | - const fs = require('fs'); - const path = 'out/verify-full.status'; - if (!fs.existsSync(path)) { - core.setFailed('verify-full.status missing'); - } else { - const content = fs.readFileSync(path, 'utf8'); - if (content.includes('ERROR:') || content.includes('status=ERROR')) { - core.setFailed('verify-full reported ERROR'); - } - } + shell: bash + run: | + path='out/verify-full.status' + if [[ ! -f "$path" ]]; then + echo "verify-full.status missing" + exit 1 + fi + if grep -Eq 'ERROR:|status=ERROR' "$path"; then + echo "verify-full reported ERROR" + exit 1 + fi - name: Notify Discord (CI Alerts) - if: always() + if: ${{ always() && !env.ACT }} continue-on-error: true env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/README.md b/README.md index 533fddb..18f5a03 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ cd ~/dev/ ci-self up ``` +`ci-self act` で局所の概算時間を測る場合は、先に `brew install act` が必要です。 + - `ci-self up` は `register + run-focus` を連続実行 - `verify.yml` / PRテンプレートが無ければ自動雛形を生成 - 雛形の生成はローカルファイル変更のみ(GitHub反映には commit/push が必要) @@ -192,9 +194,48 @@ CI_SELF_PR_BASE=main 以後はオプションを減らして実行できます。 +## GitHub権限なしで verify を絞って計測する + +`gh workflow run` を使わず、ローカルで選んだ job だけを回して、局所の概算時間を見たいときは `act` 導線を使えます。 + +```bash +brew install act +cd ~/dev/ +ci-self act +ci-self act --list +ci-self act --job + +# どこからでも明示指定できる +ci-self act --project-dir ~/dev/ --job +``` + +**この計測値はローカルでの概算です。実際の GitHub Actions / `remote-ci` / 実機 self-hosted runner の所要時間とは異なる場合があります。** + +ポイント: + +- `ci-self act` は対象 repo の `.github/workflows/*.yml|*.yaml` を見る +- `--workflow` を省略すると、対象 repo の `.github/workflows/*.yml|*.yaml` を見て、複数ある場合は `> どのworkflowを、actで実行したいですか?` と対話選択する +- 選択画面では `q` で抜けられる +- まず `ci-self act --list` で job id を確認してから、`--job ` を付ける +- workflow 選択画面の番号と `--job` は別物。`--job` には基本的に `verify` / `verify-lite` のような job id を渡す +- `~/dev/maakie-brainlab` なら `ci-self act --project-dir ~/dev/maakie-brainlab --list` のあと、`ci-self act --project-dir ~/dev/maakie-brainlab --job verify` +- 実行時間は `elapsed_sec` に加えて `benchmark_started_at` / `benchmark_finished_at` も出し、artifact は `out/act-artifacts/` に出す +- 実行中ログは左端に `[YYYY MM/DD HH:MM:SS]` を付けて流す +- `SELF_HOSTED_OWNER` や `gh auth` が無くても回せる +- workflow が1つも無い repo では、まず `.github/workflows/*.yml` を用意する必要がある +- 既存 workflow が古い場合は `bash ops/ci/scaffold_verify_workflow.sh --repo --apply --force` で act 互換の verify.yml に更新する +- TTY から `scaffold_verify_workflow.sh --apply` を実行した場合は、`verify.yml` の新規作成/上書き前に `[y/N]` 確認が入る + +注意: + +- `act` は GitHub Actions の完全再現ではない。局所の概算時間測定と早い失敗検出には向くが、最終判定は `remote-ci` / 実機 self-hosted runner を優先する +- 既存 workflow に `github.event.act == true` の逃がしが無い場合、owner guard で job が skip されることがある +- `verify-full-dryrun` は手元の Docker/Colima 到達性が前提 + ## 主要コマンド - `ci-self up`: ローカル最短(register + run-focus) +- `ci-self act`: `act` で verify workflow/job をローカル実行し、対象を絞って概算時間を測る - `ci-self focus`: run-focus 後、PR未作成なら自動作成し checks を監視 - `ci-self remote-ci`: 鍵必須・同期・別端末での verify 実行・結果回収を1コマンドで実行 - `ci-self doctor --fix`: 依存/gh auth/colima/docker/runner_health を診断し可能な範囲で修復 diff --git a/README_EN.md b/README_EN.md index e601037..b8c0a5e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -18,6 +18,8 @@ cd ~/dev/ ci-self up ``` +If you want to use `ci-self act` for rough local timing, install it first with `brew install act`. + `ci-self up` runs `register + run-focus` in sequence. ## Use A Remote CI Runner In One Command @@ -145,9 +147,47 @@ CI_SELF_REMOTE_IDENTITY=/Users//.ssh/id_ed25519_for_ci_runner CI_SELF_PR_BASE=main ``` +## Run Targeted Verify Jobs Locally With act + +If you want to run only selected jobs locally without `gh workflow run` and get rough local timing, use the `act` path. + +```bash +brew install act +cd ~/dev/ +ci-self act +ci-self act --list +ci-self act --job + +# Or point at the repo explicitly from anywhere +ci-self act --project-dir ~/dev/ --job +``` + +**These timings are local estimates only. Actual duration on GitHub Actions, `remote-ci`, or a real self-hosted runner may differ.** + +Notes: + +- `ci-self act` looks at `.github/workflows/*.yml|*.yaml` inside the target repo +- If you omit `--workflow` and the repo has multiple workflows, it opens a shell prompt asking which workflow to run; press `q` to quit +- Start with `ci-self act --list`, then run `--job ` +- The workflow menu number is separate from `--job`; pass a real job id such as `verify` or `verify-lite` +- For `~/dev/maakie-brainlab`, use `ci-self act --project-dir ~/dev/maakie-brainlab --list` and then `ci-self act --project-dir ~/dev/maakie-brainlab --job verify` +- It prints `elapsed_sec` plus `benchmark_started_at` / `benchmark_finished_at`, and stores artifacts under `out/act-artifacts/` +- Live log lines are prefixed with `[YYYY MM/DD HH:MM:SS]` +- It does not require `SELF_HOSTED_OWNER` or `gh auth` +- If the repo has no workflow files yet, add `.github/workflows/*.yml` first +- If your existing workflow is old, refresh it with `bash ops/ci/scaffold_verify_workflow.sh --repo --apply --force` +- When `scaffold_verify_workflow.sh --apply` runs from a TTY, it asks for `[y/N]` confirmation before creating or overwriting `verify.yml` + +Keep in mind: + +- `act` is useful for rough local timing and early failure detection, but it is not a full GitHub Actions replica +- If your workflow does not include a `github.event.act == true` bypass, owner guards may skip the job locally +- `verify-full-dryrun` still depends on local Docker/Colima reachability + ## Main Commands - `ci-self up`: fastest local path (`register + run-focus`) +- `ci-self act`: run a selected verify workflow/job locally via `act` for rough timing - `ci-self focus`: runs `run-focus`, creates a PR if missing, then watches checks - `ci-self remote-ci`: SSH-required sync + remote verify + result collection in one command - `ci-self doctor --fix`: checks dependencies, `gh auth`, Colima, Docker, and runner health diff --git a/docs/ci/FLOW.md b/docs/ci/FLOW.md index c8a11c9..eee35ad 100644 --- a/docs/ci/FLOW.md +++ b/docs/ci/FLOW.md @@ -8,7 +8,7 @@ ## 推奨フロー -1) MacBook: 編集 + verify-lite(速い) +1) MacBook: 編集 + `ci-self act --job verify-lite` で局所の概算時間を見る、または verify-lite(速い) 2) MacBook → Mac mini: verify-full(重い) 3) Mac mini: `verify-full` 実行後に `review-pack` で証拠bundle生成 4) MacBook: gh で PR 作成(1回だけ) @@ -20,6 +20,7 @@ - 実行場所: Workstation(MacBook) - 標準入口: `ops/ci/run_verify_lite.sh` +- 局所計測入口: `ci-self act --job verify-lite`(概算時間) - 目的: 早い失敗検出(公式推奨lint + 単体テスト) - Go公式推奨: `gofmt -l .` / `go vet ./...` / `go test ./...` - 出力: `out/verify-lite.status` と `OK:/SKIP:/ERROR:` ログ diff --git a/docs/ci/QUICKSTART.md b/docs/ci/QUICKSTART.md index 3a0f7a4..f85c2ca 100644 --- a/docs/ci/QUICKSTART.md +++ b/docs/ci/QUICKSTART.md @@ -19,6 +19,35 @@ ci-self up 1. `register`(runner登録・health・owner変数・workflow/template雛形) 2. `run-focus`(verify実行/監視・PR checks監視・PRテンプレ同期) +## 2.5) GitHub権限なしで対象jobだけ計測する + +```bash +brew install act +cd ~/dev/ +ci-self act +ci-self act --list +ci-self act --job + +# どこからでも明示指定できる +ci-self act --project-dir ~/dev/ --job +``` + +**この計測値はローカルでの概算です。実際の GitHub Actions / `remote-ci` / 実機 self-hosted runner の所要時間とは異なる場合があります。** + +- `ci-self act` は対象 repo の `.github/workflows/*.yml|*.yaml` を見る +- `--workflow` を省略すると、repo の `.github/workflows/*.yml|*.yaml` から選ぶ。複数ある場合は対話選択、`q` で終了 +- まず `ci-self act --list` で job id を確認してから `--job ` を付ける +- workflow 選択画面の番号と `--job` は別物。`--job` には `verify` のような job id を入れる +- `~/dev/maakie-brainlab` なら `ci-self act --project-dir ~/dev/maakie-brainlab --list` のあと `ci-self act --project-dir ~/dev/maakie-brainlab --job verify` +- `gh auth` や `SELF_HOSTED_OWNER` が無くても回せる +- 実行時間は `elapsed_sec` に加えて `benchmark_started_at` / `benchmark_finished_at` を出し、artifact は `out/act-artifacts/` に出す +- 実行中ログは左端に `[YYYY MM/DD HH:MM:SS]` を付ける +- `verify-full-dryrun` は Docker/Colima が必要 +- workflow が1つも無い repo では、まず `.github/workflows/*.yml` を置く +- 既存 workflow が古い場合は `bash ops/ci/scaffold_verify_workflow.sh --repo --apply --force` で更新する +- workflow に `github.event.act == true` が無い場合、owner guard で job が skip されることがある +- TTY から `scaffold_verify_workflow.sh --apply` を叩くと、`verify.yml` の作成/上書き前に `[y/N]` を聞く + ## 設定ファイルで毎回のオプションを省略 ```bash diff --git a/ops/ci/ci_self.sh b/ops/ci/ci_self.sh index d37d16c..96021b3 100755 --- a/ops/ci/ci_self.sh +++ b/ops/ci/ci_self.sh @@ -47,6 +47,29 @@ expand_local_path() { fi } +timestamp_now() { + date '+%Y %m/%d %H:%M:%S' +} + +log_ts() { + local ts="" + ts="$(timestamp_now)" + printf '[%s] %s\n' "$ts" "$*" +} + +log_ts_err() { + local ts="" + ts="$(timestamp_now)" + printf '[%s] %s\n' "$ts" "$*" >&2 +} + +prefix_stream_with_timestamp() { + local line="" + while IFS= read -r line || [[ -n "$line" ]]; do + printf '[%s] %s\n' "$(timestamp_now)" "$line" + done +} + preferred_rsync_bin() { local current="" current="$(command -v rsync 2>/dev/null || true)" @@ -157,6 +180,7 @@ Usage: Commands: up One-command local register + run-focus + act Run selected workflow/job locally via act for rough timing focus run-focus + optional PR auto-create doctor Dependency/runner checks (with optional --fix) config-init Create .ci-self.env template in current project @@ -173,6 +197,7 @@ Commands: Examples: cd ~/dev/maakie-brainlab ci-self up + ci-self act --job verify-lite ci-self focus ci-self doctor --fix ci-self config-init @@ -214,6 +239,270 @@ current_branch() { git branch --show-current } +resolve_act_workflow_path() { + local project_dir="$1" + local workflow="${2:-.github/workflows/verify.yml}" + if [[ "$workflow" == /* ]]; then + printf '%s\n' "$workflow" + return 0 + fi + printf '%s\n' "$project_dir/$workflow" +} + +find_local_workflows() { + local project_dir="$1" + local workflow_dir="$project_dir/.github/workflows" + local workflow="" + local matches=() + [[ -d "$workflow_dir" ]] || return 0 + + shopt -s nullglob + for workflow in "$workflow_dir"/*.yml "$workflow_dir"/*.yaml; do + [[ -f "$workflow" ]] && matches+=("$workflow") + done + shopt -u nullglob + + [[ "${#matches[@]}" -gt 0 ]] || return 0 + printf '%s\n' "${matches[@]}" | LC_ALL=C sort +} + +workflow_display_name() { + local workflow="$1" + local line="" + local name="" + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + name:*) + name="$(trim "${line#name:}")" + name="$(unquote_value "$name")" + break + ;; + esac + done < "$workflow" + if [[ -z "$name" ]]; then + name="$(basename "$workflow")" + fi + printf '%s\n' "$name" +} + +workflow_menu_label() { + local project_dir="$1" + local workflow="$2" + local name="" + local relative="$workflow" + name="$(workflow_display_name "$workflow")" + if [[ "$workflow" == "$project_dir/"* ]]; then + relative="${workflow#"$project_dir/"}" + fi + printf '%s (%s)\n' "$name" "$relative" +} + +list_act_jobs() { + local project_dir="$1" + local workflow="$2" + local event_name="${3:-workflow_dispatch}" + local line="" + local rows_started=0 + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -n "$line" ]] || continue + if [[ "$line" == Stage[[:space:]]*Job\ ID[[:space:]]* ]]; then + rows_started=1 + continue + fi + [[ "$rows_started" -eq 1 ]] || continue + case "$line" in + [0-9]*) + set -- $line + [[ $# -ge 2 ]] && printf '%s\n' "$2" + ;; + *) + ;; + esac + done < <(act -C "$project_dir" -W "$workflow" -l "$event_name" 2>/dev/null || true) +} + +print_act_jobs_hint() { + local project_dir="$1" + local workflow="$2" + local event_name="$3" + local workflow_label="" + local job="" + local jobs=() + + while IFS= read -r job || [[ -n "$job" ]]; do + [[ -n "$job" ]] && jobs+=("$job") + done < <(list_act_jobs "$project_dir" "$workflow" "$event_name") + + if [[ "$workflow" == "$project_dir/"* ]]; then + workflow_label="${workflow#"$project_dir/"}" + else + workflow_label="$workflow" + fi + + if [[ "${#jobs[@]}" -eq 0 ]]; then + log_ts_err "HINT: no jobs were discovered from workflow=$workflow_label" + log_ts_err "HINT: run 'ci-self act --workflow $workflow_label --list' to inspect this workflow" + return 0 + fi + + log_ts_err "HINT: available jobs in $workflow_label:" + for job in "${jobs[@]}"; do + log_ts_err "HINT: - $job" + done +} + +resolve_requested_job() { + local project_dir="$1" + local workflow="$2" + local event_name="$3" + local requested_job="${4:-}" + local job="" + local jobs=() + + while IFS= read -r job || [[ -n "$job" ]]; do + [[ -n "$job" ]] && jobs+=("$job") + done < <(list_act_jobs "$project_dir" "$workflow" "$event_name") + + if [[ -z "$requested_job" ]]; then + printf '%s\n' "" + return 0 + fi + + if [[ "${#jobs[@]}" -eq 0 ]]; then + log_ts_err "WARN: could not discover jobs before act run; passing through requested job=$requested_job" + printf '%s\n' "$requested_job" + return 0 + fi + + if [[ "$requested_job" =~ ^[0-9]+$ ]]; then + if (( requested_job >= 1 && requested_job <= ${#jobs[@]} )); then + job="${jobs[$((requested_job - 1))]}" + log_ts_err "OK: selected job=$job (index=$requested_job)" + printf '%s\n' "$job" + return 0 + fi + log_ts_err "ERROR: job index out of range: $requested_job" + log_ts_err "HINT: choose 1..${#jobs[@]} or pass an actual job id" + print_act_jobs_hint "$project_dir" "$workflow" "$event_name" + return 2 + fi + + for job in "${jobs[@]}"; do + if [[ "$job" == "$requested_job" ]]; then + printf '%s\n' "$requested_job" + return 0 + fi + done + + log_ts_err "ERROR: job not found in workflow: $requested_job" + print_act_jobs_hint "$project_dir" "$workflow" "$event_name" + return 2 +} + +select_local_workflow() { + local project_dir="$1" + local workflow_dir="$project_dir/.github/workflows" + local workflow="" + local workflows=() + local choice="" + local selected="" + local idx=1 + + while IFS= read -r workflow || [[ -n "$workflow" ]]; do + [[ -n "$workflow" ]] && workflows+=("$workflow") + done < <(find_local_workflows "$project_dir") + + if [[ "${#workflows[@]}" -eq 0 ]]; then + log_ts_err "ERROR: no workflow files found under: $workflow_dir" + log_ts_err "HINT: pass --workflow or add a workflow first" + return 2 + fi + + if [[ "${#workflows[@]}" -eq 1 ]]; then + printf '%s\n' "${workflows[0]}" + return 0 + fi + + while true; do + printf '> どのworkflowを、actで実行したいですか?\n' >&2 + idx=1 + for workflow in "${workflows[@]}"; do + printf '> [%d] %s\n' "$idx" "$(workflow_menu_label "$project_dir" "$workflow")" >&2 + idx=$((idx + 1)) + done + printf '> [q] quit\n' >&2 + printf '> ' >&2 + if ! IFS= read -r choice; then + printf '\n' >&2 + log_ts_err "SKIP: act selection cancelled" + return 130 + fi + + choice="$(trim "$choice")" + case "$choice" in + q|Q) + log_ts_err "SKIP: act selection cancelled" + return 130 + ;; + '' ) + printf '> 入力が空です。番号か q を入力してください。\n' >&2 + ;; + *[!0-9]*) + printf '> 不正な入力です。番号か q を入力してください。\n' >&2 + ;; + *) + if (( choice >= 1 && choice <= ${#workflows[@]} )); then + selected="${workflows[$((choice - 1))]}" + log_ts_err "OK: selected workflow=$selected" + printf '%s\n' "$selected" + return 0 + fi + printf '> 範囲外です。1 から %d まで、または q を入力してください。\n' "${#workflows[@]}" >&2 + ;; + esac + done +} + +write_act_event_payload() { + local event_name="${1:-workflow_dispatch}" + case "$event_name" in + pull_request) + cat <<'JSON' +{ + "act": true, + "pull_request": { + "head": { + "ref": "act-head", + "repo": { + "fork": false + } + }, + "base": { + "ref": "main" + } + } +} +JSON + ;; + workflow_dispatch) + cat <<'JSON' +{ + "act": true, + "inputs": {} +} +JSON + ;; + *) + cat <<'JSON' +{ + "act": true +} +JSON + ;; + esac +} + ensure_verify_workflow_nix_compat() { local project_dir="${1:-}" [[ -z "$project_dir" ]] && return 0 @@ -668,6 +957,124 @@ USAGE cmd_run_watch --all-green --sync-pr-template "${run_focus_args[@]}" } +cmd_act() { + local project_dir="$PWD" + local workflow="" + local job="" + local event_name="workflow_dispatch" + local list_only=0 + local started_at="" + local finished_at="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --project-dir) project_dir="${2:-}"; shift 2 ;; + --workflow) workflow="${2:-}"; shift 2 ;; + --job) job="${2:-}"; shift 2 ;; + --event) event_name="${2:-}"; shift 2 ;; + --list) list_only=1; shift ;; + -h|--help) + cat <<'USAGE' +Usage: ci-self act [--project-dir path] [--workflow path] [--job job-id] [--event push|pull_request|workflow_dispatch] [--list] + +Examples: + ci-self act + ci-self act --list + ci-self act --job verify-lite + ci-self act --project-dir ~/dev/maakie-brainlab --list + ci-self act --project-dir ~/dev/maakie-brainlab --job verify +USAGE + return 0 + ;; + *) + log_ts_err "ERROR: unknown option for act: $1" + return 2 + ;; + esac + done + + [[ -z "$project_dir" ]] && project_dir="$PWD" + [[ -n "$CONFIG_PROJECT_DIR" && "$project_dir" == "$PWD" ]] && project_dir="$CONFIG_PROJECT_DIR" + project_dir="$(expand_local_path "$project_dir")" + + [[ -d "$project_dir" ]] || { log_ts_err "ERROR: --project-dir not found: $project_dir"; return 2; } + + if [[ -n "$workflow" ]]; then + workflow="$(resolve_act_workflow_path "$project_dir" "$workflow")" + [[ -f "$workflow" ]] || { log_ts_err "ERROR: workflow not found: $workflow"; return 2; } + else + workflow="$(select_local_workflow "$project_dir")" || return $? + fi + + command -v act >/dev/null 2>&1 || { + log_ts_err "ERROR: act command not found" + log_ts_err "HINT: brew install act" + return 1 + } + + if ! grep -Fq "github.event.act == true" "$workflow"; then + log_ts_err "WARN: workflow may not be act-compatible: $workflow" + log_ts_err "HINT: bash \"$ROOT_DIR/ops/ci/scaffold_verify_workflow.sh\" --repo \"$project_dir\" --apply --force" + fi + + if [[ -n "$job" && "$list_only" -eq 0 ]]; then + job="$(resolve_requested_job "$project_dir" "$workflow" "$event_name" "$job")" || return $? + fi + + local act_cmd=(act -C "$project_dir") + if [[ "$list_only" -eq 1 ]]; then + act_cmd+=(-W "$workflow" -l "$event_name") + log_ts "OK: act list project_dir=$project_dir workflow=$workflow event=$event_name" + local list_status=0 + if "${act_cmd[@]}" 2>&1 | prefix_stream_with_timestamp; then + return 0 + fi + list_status="${PIPESTATUS[0]}" + log_ts_err "ERROR: act list failed exit_code=$list_status project_dir=$project_dir" + return "$list_status" + fi + + local event_file="" + event_file="$(mktemp "${TMPDIR:-/tmp}/ci-self-act-event.XXXXXX")" + write_act_event_payload "$event_name" > "$event_file" + + local artifact_dir="$project_dir/out/act-artifacts" + mkdir -p "$artifact_dir" + + act_cmd+=( + "$event_name" + -W "$workflow" + -e "$event_file" + --artifact-server-path "$artifact_dir" + --pull=false + --action-offline-mode + -P "self-hosted=-self-hosted" + -P "self-hosted,mac-mini=-self-hosted" + -P "self-hosted,mac-mini,colima,verify-full=-self-hosted" + ) + [[ -n "$job" ]] && act_cmd+=(-j "$job") + + started_at="$(timestamp_now)" + log_ts "OK: act run project_dir=$project_dir workflow=$workflow event=$event_name${job:+ job=$job} artifact_dir=$artifact_dir benchmark_started_at=$started_at" + local elapsed_sec=0 + local status=0 + SECONDS=0 + if "${act_cmd[@]}" 2>&1 | prefix_stream_with_timestamp; then + status=0 + else + status="${PIPESTATUS[0]}" + fi + elapsed_sec="$SECONDS" + finished_at="$(timestamp_now)" + rm -f "$event_file" + + if [[ "$status" -ne 0 ]]; then + log_ts_err "ERROR: act failed exit_code=$status elapsed_sec=$elapsed_sec benchmark_started_at=$started_at benchmark_finished_at=$finished_at project_dir=$project_dir" + return "$status" + fi + log_ts "OK: act completed elapsed_sec=$elapsed_sec benchmark_started_at=$started_at benchmark_finished_at=$finished_at artifact_dir=$artifact_dir project_dir=$project_dir" +} + cmd_focus() { local repo="" local ref="" @@ -1694,6 +2101,7 @@ main() { shift || true case "$cmd" in up) cmd_up "$@" ;; + act) cmd_act "$@" ;; focus) cmd_focus "$@" ;; doctor) cmd_doctor "$@" ;; config-init) cmd_config_init "$@" ;; diff --git a/ops/ci/ci_self_test.go b/ops/ci/ci_self_test.go index 9935cbe..fa5019b 100644 --- a/ops/ci/ci_self_test.go +++ b/ops/ci/ci_self_test.go @@ -5,11 +5,17 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "testing" ) func runCiSelfInDirEnv(t *testing.T, dir string, env []string, args ...string) (string, error) { + t.Helper() + return runCiSelfInDirEnvInput(t, dir, env, "", args...) +} + +func runCiSelfInDirEnvInput(t *testing.T, dir string, env []string, input string, args ...string) (string, error) { t.Helper() scriptPath, err := filepath.Abs("./ci_self.sh") if err != nil { @@ -24,6 +30,9 @@ func runCiSelfInDirEnv(t *testing.T, dir string, env []string, args ...string) ( if len(env) > 0 { cmd.Env = append(os.Environ(), env...) } + if input != "" { + cmd.Stdin = strings.NewReader(input) + } out, runErr := cmd.CombinedOutput() return string(out), runErr } @@ -46,7 +55,7 @@ func TestHelpListsRemoteCommands(t *testing.T) { if err != nil { t.Fatalf("help failed: %v\noutput:\n%s", err, out) } - for _, want := range []string{"up", "focus", "doctor", "config-init", "remote-ci", "remote-register", "remote-run-focus", "remote-up"} { + for _, want := range []string{"up", "act", "focus", "doctor", "config-init", "remote-ci", "remote-register", "remote-run-focus", "remote-up"} { if !strings.Contains(out, want) { t.Fatalf("help output missing %q\noutput:\n%s", want, out) } @@ -132,6 +141,353 @@ func TestUpHelp(t *testing.T) { } } +func TestActHelp(t *testing.T) { + out, err := runCiSelf(t, "act", "--help") + if err != nil { + t.Fatalf("act --help failed: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "Usage: ci-self act") { + t.Fatalf("act help output missing usage\noutput:\n%s", out) + } +} + +func TestActRequiresBinary(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "verify.yml"), []byte("name: verify\n"), 0o644); err != nil { + t.Fatalf("write workflow failed: %v", err) + } + + out, err := runCiSelfInDirEnv( + t, + tmp, + []string{"PATH=" + tmp + ":/usr/bin:/bin:/usr/sbin:/sbin"}, + "act", + "--project-dir", + tmp, + ) + if err == nil { + t.Fatalf("expected act command to fail when binary is missing\noutput:\n%s", out) + } + if !strings.Contains(out, "ERROR: act command not found") { + t.Fatalf("expected missing act error\noutput:\n%s", out) + } + if !strings.Contains(out, "brew install act") { + t.Fatalf("expected install hint\noutput:\n%s", out) + } +} + +func TestActErrorsWhenNoWorkflowExists(t *testing.T) { + tmp := t.TempDir() + + out, err := runCiSelfInDirEnv( + t, + tmp, + []string{"PATH=/usr/bin:/bin:/usr/sbin:/sbin"}, + "act", + "--project-dir", + tmp, + ) + if err == nil { + t.Fatalf("expected act command to fail when workflow is missing\noutput:\n%s", out) + } + if !strings.Contains(out, "ERROR: no workflow files found under: "+filepath.Join(tmp, ".github", "workflows")) { + t.Fatalf("expected missing workflow directory error\noutput:\n%s", out) + } +} + +func TestActInteractiveWorkflowSelection(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "alpha.yml"), []byte("name: Alpha Flow\n"), 0o644); err != nil { + t.Fatalf("write alpha workflow failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "beta.yaml"), []byte("name: Beta Flow\n"), 0o644); err != nil { + t.Fatalf("write beta workflow failed: %v", err) + } + + logPath := filepath.Join(tmp, "act.log") + actPath := filepath.Join(tmp, "act") + actScript := fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> %q +if [[ " $* " == *" -l "* ]]; then + cat <<'EOF' +Stage Job ID Job name Workflow name Workflow file Events +0 verify verify Beta Flow beta.yaml workflow_dispatch +EOF + exit 0 +fi +`, logPath) + if err := os.WriteFile(actPath, []byte(actScript), 0o755); err != nil { + t.Fatalf("write fake act failed: %v", err) + } + + out, err := runCiSelfInDirEnvInput( + t, + tmp, + []string{"PATH=" + tmp + ":" + os.Getenv("PATH")}, + "2\n", + "act", + "--project-dir", + tmp, + "--list", + ) + if err != nil { + t.Fatalf("interactive act list failed: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "> どのworkflowを、actで実行したいですか?") { + t.Fatalf("expected workflow selection prompt\noutput:\n%s", out) + } + if !strings.Contains(out, "OK: selected workflow="+filepath.Join(tmp, ".github", "workflows", "beta.yaml")) { + t.Fatalf("expected selected workflow output\noutput:\n%s", out) + } + + logBody, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatalf("failed to read act log: %v", readErr) + } + if !strings.Contains(string(logBody), "-W "+filepath.Join(tmp, ".github", "workflows", "beta.yaml")) { + t.Fatalf("expected selected workflow path in act invocation\nlog:\n%s", string(logBody)) + } +} + +func TestActInteractiveWorkflowSelectionQuit(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "alpha.yml"), []byte("name: Alpha Flow\n"), 0o644); err != nil { + t.Fatalf("write alpha workflow failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "beta.yaml"), []byte("name: Beta Flow\n"), 0o644); err != nil { + t.Fatalf("write beta workflow failed: %v", err) + } + + out, err := runCiSelfInDirEnvInput( + t, + tmp, + []string{"PATH=/usr/bin:/bin:/usr/sbin:/sbin"}, + "q\n", + "act", + "--project-dir", + tmp, + ) + if err == nil { + t.Fatalf("expected quit selection to stop the command\noutput:\n%s", out) + } + if !strings.Contains(out, "SKIP: act selection cancelled") { + t.Fatalf("expected selection cancel output\noutput:\n%s", out) + } +} + +func TestActBuildsTargetedInvocation(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "verify.yml"), []byte("name: verify\n"), 0o644); err != nil { + t.Fatalf("write workflow failed: %v", err) + } + + logPath := filepath.Join(tmp, "act.log") + actPath := filepath.Join(tmp, "act") + actScript := fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail +if [[ " $* " == *" -l "* ]]; then + cat <<'EOF' +Stage Job ID Job name Workflow name Workflow file Events +0 verify-lite verify-lite verify verify.yml workflow_dispatch +1 verify-full verify-full verify verify.yml workflow_dispatch +EOF + exit 0 +fi +echo "$*" >> %q +echo "[verify/verify-lite] sample stdout" +echo "[verify/verify-lite] sample stderr" >&2 +event_file="" +prev="" +for arg in "$@"; do + if [[ "$prev" == "-e" ]]; then + event_file="$arg" + break + fi + prev="$arg" +done +if [[ -n "$event_file" && -f "$event_file" ]]; then + echo "--- event ---" >> %q + cat "$event_file" >> %q +fi +`, logPath, logPath, logPath) + if err := os.WriteFile(actPath, []byte(actScript), 0o755); err != nil { + t.Fatalf("write fake act failed: %v", err) + } + + out, err := runCiSelfInDirEnv( + t, + tmp, + []string{"PATH=" + tmp + ":" + os.Getenv("PATH")}, + "act", + "--project-dir", + tmp, + "--job", + "verify-lite", + ) + if err != nil { + t.Fatalf("act command failed: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "OK: act completed") { + t.Fatalf("expected act completion output\noutput:\n%s", out) + } + if !regexp.MustCompile(`(?m)^\[[0-9]{4} [0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\] OK: act run project_dir=`).MatchString(out) { + t.Fatalf("expected timestamped act start output\noutput:\n%s", out) + } + if !regexp.MustCompile(`(?m)^\[[0-9]{4} [0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\] \[verify/verify-lite\] sample stdout$`).MatchString(out) { + t.Fatalf("expected timestamped stdout passthrough\noutput:\n%s", out) + } + if !regexp.MustCompile(`(?m)^\[[0-9]{4} [0-9]{2}/[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\] \[verify/verify-lite\] sample stderr$`).MatchString(out) { + t.Fatalf("expected timestamped stderr passthrough\noutput:\n%s", out) + } + + logBody, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatalf("failed to read act log: %v", readErr) + } + logText := string(logBody) + if !strings.Contains(logText, "-C "+tmp) { + t.Fatalf("expected project dir argument\nlog:\n%s", logText) + } + if !strings.Contains(logText, "workflow_dispatch") { + t.Fatalf("expected workflow_dispatch event\nlog:\n%s", logText) + } + if !strings.Contains(logText, "-W "+filepath.Join(tmp, ".github", "workflows", "verify.yml")) { + t.Fatalf("expected workflow path argument\nlog:\n%s", logText) + } + if !strings.Contains(logText, "-j verify-lite") { + t.Fatalf("expected targeted job argument\nlog:\n%s", logText) + } + if !strings.Contains(logText, "--artifact-server-path "+filepath.Join(tmp, "out", "act-artifacts")) { + t.Fatalf("expected artifact server path\nlog:\n%s", logText) + } + if !strings.Contains(logText, "-P self-hosted=-self-hosted") { + t.Fatalf("expected self-hosted platform mapping\nlog:\n%s", logText) + } + if !strings.Contains(logText, `"act": true`) { + t.Fatalf("expected act event payload\nlog:\n%s", logText) + } +} + +func TestActRejectsUnknownJobWithAvailableJobsHint(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "verify.yml"), []byte("name: verify\n"), 0o644); err != nil { + t.Fatalf("write workflow failed: %v", err) + } + + actPath := filepath.Join(tmp, "act") + actScript := `#!/usr/bin/env bash +set -euo pipefail +if [[ " $* " == *" -l "* ]]; then + cat <<'EOF' +Stage Job ID Job name Workflow name Workflow file Events +0 verify-lite verify-lite verify verify.yml workflow_dispatch +1 verify-full verify-full verify verify.yml workflow_dispatch +EOF + exit 0 +fi +echo "unexpected act execution" >&2 +exit 99 +` + if err := os.WriteFile(actPath, []byte(actScript), 0o755); err != nil { + t.Fatalf("write fake act failed: %v", err) + } + + out, err := runCiSelfInDirEnv( + t, + tmp, + []string{"PATH=" + tmp + ":" + os.Getenv("PATH")}, + "act", + "--project-dir", + tmp, + "--job", + "missing-job", + ) + if err == nil { + t.Fatalf("expected invalid job to fail\noutput:\n%s", out) + } + if !strings.Contains(out, "ERROR: job not found in workflow: missing-job") { + t.Fatalf("expected missing job error\noutput:\n%s", out) + } + if !strings.Contains(out, "HINT: - verify-lite") || !strings.Contains(out, "HINT: - verify-full") { + t.Fatalf("expected available jobs hint\noutput:\n%s", out) + } +} + +func TestActResolvesNumericJobIndex(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "verify.yml"), []byte("name: verify\n"), 0o644); err != nil { + t.Fatalf("write workflow failed: %v", err) + } + + logPath := filepath.Join(tmp, "act.log") + actPath := filepath.Join(tmp, "act") + actScript := fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail +if [[ " $* " == *" -l "* ]]; then + cat <<'EOF' +Stage Job ID Job name Workflow name Workflow file Events +0 verify-lite verify-lite verify verify.yml workflow_dispatch +1 verify-full verify-full verify verify.yml workflow_dispatch +EOF + exit 0 +fi +echo "$*" >> %q +`, logPath) + if err := os.WriteFile(actPath, []byte(actScript), 0o755); err != nil { + t.Fatalf("write fake act failed: %v", err) + } + + out, err := runCiSelfInDirEnv( + t, + tmp, + []string{"PATH=" + tmp + ":" + os.Getenv("PATH")}, + "act", + "--project-dir", + tmp, + "--job", + "2", + ) + if err != nil { + t.Fatalf("numeric job selection failed: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "OK: selected job=verify-full (index=2)") { + t.Fatalf("expected numeric selection log\noutput:\n%s", out) + } + + logBody, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatalf("failed to read act log: %v", readErr) + } + if !strings.Contains(string(logBody), "-j verify-full") { + t.Fatalf("expected resolved job id in act invocation\nlog:\n%s", string(logBody)) + } +} + func TestFocusHelp(t *testing.T) { out, err := runCiSelf(t, "focus", "--help") if err != nil { diff --git a/ops/ci/scaffold_verify_workflow.sh b/ops/ci/scaffold_verify_workflow.sh index ef7f3d5..cc3e5e8 100755 --- a/ops/ci/scaffold_verify_workflow.sh +++ b/ops/ci/scaffold_verify_workflow.sh @@ -27,6 +27,52 @@ APPLY=0 FORCE=0 UPDATE_GITIGNORE=1 +trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s\n' "$s" +} + +is_interactive_tty() { + if [[ "${CI_SELF_TEST_FORCE_TTY:-0}" == "1" ]]; then + return 0 + fi + [[ -t 0 && -t 1 ]] +} + +confirm_apply() { + local prompt="$1" + local answer="" + local normalized="" + + while true; do + printf '%s [y/N] ' "$prompt" >&2 + if ! IFS= read -r answer; then + printf '\n' >&2 + return 1 + fi + normalized="$(printf '%s' "$answer" | tr '[:upper:]' '[:lower:]')" + normalized="$(trim "$normalized")" + case "$normalized" in + y|yes) return 0 ;; + n|no|'') return 1 ;; + *) printf 'Please answer yes or no.\n' >&2 ;; + esac + done +} + +update_gitignore_entries() { + [[ "$UPDATE_GITIGNORE" -eq 1 ]] || return 0 + touch "$GITIGNORE_FILE" + for entry in ".local/" "out/" "cache/"; do + if ! grep -Fxq "$entry" "$GITIGNORE_FILE"; then + printf '%s\n' "$entry" >>"$GITIGNORE_FILE" + echo "OK: added to .gitignore: $entry" + fi + done +} + while [[ $# -gt 0 ]]; do case "$1" in --repo) @@ -93,7 +139,7 @@ concurrency: jobs: verify: - if: ${{ vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) }} + if: ${{ github.event.act == true || (vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) }} runs-on: - self-hosted - mac-mini @@ -102,14 +148,39 @@ jobs: - uses: actions/checkout@v4 - name: Setup Go + if: ${{ !env.ACT }} uses: actions/setup-go@v5 with: go-version-file: go.mod + - name: Setup Go For act + if: ${{ env.ACT }} + shell: bash + run: | + if command -v go >/dev/null 2>&1; then + go version + elif command -v mise >/dev/null 2>&1; then + mise x -- go version + else + echo "ERROR: go or mise is required for local act runs" + exit 1 + fi + - name: Verify + shell: bash run: | - go test ./... - go vet ./... + run_go() { + if command -v go >/dev/null 2>&1; then + go "$@" + elif command -v mise >/dev/null 2>&1; then + mise x -- go "$@" + else + echo "ERROR: go or mise is required" + return 127 + fi + } + run_go test ./... + run_go vet ./... YAML } @@ -132,7 +203,7 @@ concurrency: jobs: verify: - if: ${{ vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) }} + if: ${{ github.event.act == true || (vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) }} runs-on: - self-hosted - mac-mini @@ -183,7 +254,7 @@ concurrency: jobs: verify: - if: ${{ vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) }} + if: ${{ github.event.act == true || (vars.SELF_HOSTED_OWNER != '' && github.repository_owner == vars.SELF_HOSTED_OWNER && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) }} runs-on: - self-hosted - mac-mini @@ -213,7 +284,19 @@ if [[ "$APPLY" -eq 0 ]]; then fi mkdir -p "$WORKFLOW_DIR" -if [[ -f "$WORKFLOW_FILE" && "$FORCE" -ne 1 ]]; then +if is_interactive_tty; then + if [[ -f "$WORKFLOW_FILE" ]]; then + if ! confirm_apply "verify.yml を上書きしますか?"; then + echo "SKIP: user declined workflow overwrite: $WORKFLOW_FILE" + exit 0 + fi + else + if ! confirm_apply "verify.yml がありません。作成しますか?"; then + echo "SKIP: user declined workflow creation: $WORKFLOW_FILE" + exit 0 + fi + fi +elif [[ -f "$WORKFLOW_FILE" && "$FORCE" -ne 1 ]]; then echo "SKIP: $WORKFLOW_FILE already exists (use --force to overwrite)" if [[ "$MODE" == "nix" ]]; then if ! grep -Fq "nix-daemon.sh" "$WORKFLOW_FILE"; then @@ -221,27 +304,11 @@ if [[ -f "$WORKFLOW_FILE" && "$FORCE" -ne 1 ]]; then echo "HINT: rerun with --force to refresh verify.yml template" fi fi - if [[ "$UPDATE_GITIGNORE" -eq 1 ]]; then - touch "$GITIGNORE_FILE" - for entry in ".local/" "out/" "cache/"; do - if ! grep -Fxq "$entry" "$GITIGNORE_FILE"; then - printf '%s\n' "$entry" >>"$GITIGNORE_FILE" - echo "OK: added to .gitignore: $entry" - fi - done - fi + update_gitignore_entries exit 0 fi cp "$TMP_FILE" "$WORKFLOW_FILE" echo "OK: wrote $WORKFLOW_FILE (mode=$MODE)" -if [[ "$UPDATE_GITIGNORE" -eq 1 ]]; then - touch "$GITIGNORE_FILE" - for entry in ".local/" "out/" "cache/"; do - if ! grep -Fxq "$entry" "$GITIGNORE_FILE"; then - printf '%s\n' "$entry" >>"$GITIGNORE_FILE" - echo "OK: added to .gitignore: $entry" - fi - done -fi +update_gitignore_entries diff --git a/ops/ci/scaffold_verify_workflow_test.go b/ops/ci/scaffold_verify_workflow_test.go new file mode 100644 index 0000000..1654c22 --- /dev/null +++ b/ops/ci/scaffold_verify_workflow_test.go @@ -0,0 +1,163 @@ +package ci_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func runScaffoldVerifyWorkflow(t *testing.T, repo string, args ...string) (string, error) { + t.Helper() + return runScaffoldVerifyWorkflowWithEnvInput(t, repo, nil, "", args...) +} + +func runScaffoldVerifyWorkflowWithEnvInput(t *testing.T, repo string, env []string, input string, args ...string) (string, error) { + t.Helper() + allArgs := []string{"./scaffold_verify_workflow.sh", "--repo", repo} + allArgs = append(allArgs, args...) + cmd := exec.Command("bash", allArgs...) + cmd.Dir = "." + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } + if input != "" { + cmd.Stdin = strings.NewReader(input) + } + out, err := cmd.CombinedOutput() + return string(out), err +} + +func runScaffoldVerifyWorkflowTTY(t *testing.T, repo string, input string, args ...string) (string, error) { + t.Helper() + env := []string{"CI_SELF_TEST_FORCE_TTY=1"} + return runScaffoldVerifyWorkflowWithEnvInput(t, repo, env, input, args...) +} + +func TestScaffoldVerifyWorkflowCreatesWhenMissingNonInteractive(t *testing.T) { + repo := t.TempDir() + + out, err := runScaffoldVerifyWorkflow(t, repo, "--apply") + if err != nil { + t.Fatalf("scaffold failed: %v\noutput:\n%s", err, out) + } + + target := filepath.Join(repo, ".github", "workflows", "verify.yml") + if _, statErr := os.Stat(target); statErr != nil { + t.Fatalf("expected verify workflow to be created: %v", statErr) + } + body, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("read failed: %v", readErr) + } + if !strings.Contains(string(body), "- uses: actions/checkout@v4") { + t.Fatalf("expected checkout step in generated workflow\ncontent:\n%s", string(body)) + } + if strings.Contains(string(body), "if: ${{ !env.ACT }}\n uses: actions/checkout@v4") { + t.Fatalf("checkout step should not be guarded by ACT\ncontent:\n%s", string(body)) + } + if !strings.Contains(out, "OK: wrote "+target) { + t.Fatalf("expected write output\noutput:\n%s", out) + } +} + +func TestScaffoldVerifyWorkflowSkipsWhenExistsNonInteractive(t *testing.T) { + repo := t.TempDir() + target := filepath.Join(repo, ".github", "workflows", "verify.yml") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + const keep = "KEEP_EXISTING_WORKFLOW\n" + if err := os.WriteFile(target, []byte(keep), 0o644); err != nil { + t.Fatalf("write failed: %v", err) + } + + out, err := runScaffoldVerifyWorkflow(t, repo, "--apply") + if err != nil { + t.Fatalf("expected skip success, got error: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "SKIP: "+target+" already exists") { + t.Fatalf("expected skip output\noutput:\n%s", out) + } + + body, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("read failed: %v", readErr) + } + if string(body) != keep { + t.Fatalf("existing workflow should be preserved\ncontent:\n%s", string(body)) + } +} + +func TestScaffoldVerifyWorkflowPromptsBeforeCreateOnTTY(t *testing.T) { + repo := t.TempDir() + + out, err := runScaffoldVerifyWorkflowTTY(t, repo, "y\n", "--apply") + if err != nil { + t.Fatalf("tty scaffold failed: %v\noutput:\n%s", err, out) + } + + target := filepath.Join(repo, ".github", "workflows", "verify.yml") + if _, statErr := os.Stat(target); statErr != nil { + t.Fatalf("expected verify workflow to be created: %v", statErr) + } + if !strings.Contains(out, "verify.yml がありません。作成しますか? [y/N]") { + t.Fatalf("expected create prompt\noutput:\n%s", out) + } +} + +func TestScaffoldVerifyWorkflowPromptsBeforeOverwriteOnTTY(t *testing.T) { + repo := t.TempDir() + target := filepath.Join(repo, ".github", "workflows", "verify.yml") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + if err := os.WriteFile(target, []byte("OLD\n"), 0o644); err != nil { + t.Fatalf("write failed: %v", err) + } + + out, err := runScaffoldVerifyWorkflowTTY(t, repo, "y\n", "--apply") + if err != nil { + t.Fatalf("tty overwrite failed: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "verify.yml を上書きしますか? [y/N]") { + t.Fatalf("expected overwrite prompt\noutput:\n%s", out) + } + + body, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("read failed: %v", readErr) + } + if !strings.Contains(string(body), "jobs:") { + t.Fatalf("expected workflow content to be overwritten\ncontent:\n%s", string(body)) + } +} + +func TestScaffoldVerifyWorkflowDeclineOverwriteOnTTY(t *testing.T) { + repo := t.TempDir() + target := filepath.Join(repo, ".github", "workflows", "verify.yml") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + const keep = "KEEP_EXISTING_WORKFLOW\n" + if err := os.WriteFile(target, []byte(keep), 0o644); err != nil { + t.Fatalf("write failed: %v", err) + } + + out, err := runScaffoldVerifyWorkflowTTY(t, repo, "n\n", "--apply") + if err != nil { + t.Fatalf("expected decline overwrite to exit successfully, got: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "SKIP: user declined workflow overwrite") { + t.Fatalf("expected decline output\noutput:\n%s", out) + } + + body, readErr := os.ReadFile(target) + if readErr != nil { + t.Fatalf("read failed: %v", readErr) + } + if string(body) != keep { + t.Fatalf("existing workflow should remain unchanged\ncontent:\n%s", string(body)) + } +} From 505952527976d29df39609947c211155f0c8c071 Mon Sep 17 00:00:00 2001 From: mt4110 <4221282+mt4110@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:32:55 +0900 Subject: [PATCH 2/3] Address act review feedback --- ops/ci/ci_self.sh | 23 ++++-- ops/ci/ci_self_test.go | 93 ++++++++++++++++++++++++- ops/ci/scaffold_verify_workflow_test.go | 2 +- 3 files changed, 112 insertions(+), 6 deletions(-) diff --git a/ops/ci/ci_self.sh b/ops/ci/ci_self.sh index 96021b3..babd933 100755 --- a/ops/ci/ci_self.sh +++ b/ops/ci/ci_self.sh @@ -70,6 +70,12 @@ prefix_stream_with_timestamp() { done } +cleanup_temp_file() { + local path="${1:-}" + [[ -n "$path" ]] || return 0 + rm -f "$path" +} + preferred_rsync_bin() { local current="" current="$(command -v rsync 2>/dev/null || true)" @@ -1040,18 +1046,23 @@ USAGE local artifact_dir="$project_dir/out/act-artifacts" mkdir -p "$artifact_dir" + local act_offline_mode="${CI_SELF_ACT_OFFLINE_MODE:-0}" act_cmd+=( "$event_name" -W "$workflow" -e "$event_file" --artifact-server-path "$artifact_dir" + --no-skip-checkout --pull=false - --action-offline-mode -P "self-hosted=-self-hosted" -P "self-hosted,mac-mini=-self-hosted" -P "self-hosted,mac-mini,colima,verify-full=-self-hosted" ) + if [[ "$act_offline_mode" == "1" ]]; then + act_cmd+=(--action-offline-mode) + log_ts "OK: act offline mode enabled via CI_SELF_ACT_OFFLINE_MODE=1; required action repositories must already be cached locally" + fi [[ -n "$job" ]] && act_cmd+=(-j "$job") started_at="$(timestamp_now)" @@ -1059,14 +1070,18 @@ USAGE local elapsed_sec=0 local status=0 SECONDS=0 - if "${act_cmd[@]}" 2>&1 | prefix_stream_with_timestamp; then + if ( + trap 'cleanup_temp_file "$event_file"' EXIT + trap 'cleanup_temp_file "$event_file"; exit 130' INT TERM + "${act_cmd[@]}" 2>&1 | prefix_stream_with_timestamp + ); then status=0 else - status="${PIPESTATUS[0]}" + status="$?" fi elapsed_sec="$SECONDS" finished_at="$(timestamp_now)" - rm -f "$event_file" + cleanup_temp_file "$event_file" if [[ "$status" -ne 0 ]]; then log_ts_err "ERROR: act failed exit_code=$status elapsed_sec=$elapsed_sec benchmark_started_at=$started_at benchmark_finished_at=$finished_at project_dir=$project_dir" diff --git a/ops/ci/ci_self_test.go b/ops/ci/ci_self_test.go index fa5019b..3b36199 100644 --- a/ops/ci/ci_self_test.go +++ b/ops/ci/ci_self_test.go @@ -10,6 +10,35 @@ import ( "testing" ) +func mergeEnvOverrides(base []string, overrides []string) []string { + if len(overrides) == 0 { + return base + } + + overrideKeys := make(map[string]struct{}, len(overrides)) + for _, entry := range overrides { + key := entry + if idx := strings.IndexByte(entry, '='); idx >= 0 { + key = entry[:idx] + } + overrideKeys[key] = struct{}{} + } + + merged := make([]string, 0, len(base)+len(overrides)) + for _, entry := range base { + key := entry + if idx := strings.IndexByte(entry, '='); idx >= 0 { + key = entry[:idx] + } + if _, exists := overrideKeys[key]; exists { + continue + } + merged = append(merged, entry) + } + merged = append(merged, overrides...) + return merged +} + func runCiSelfInDirEnv(t *testing.T, dir string, env []string, args ...string) (string, error) { t.Helper() return runCiSelfInDirEnvInput(t, dir, env, "", args...) @@ -28,7 +57,7 @@ func runCiSelfInDirEnvInput(t *testing.T, dir string, env []string, input string cmd.Dir = dir } if len(env) > 0 { - cmd.Env = append(os.Environ(), env...) + cmd.Env = mergeEnvOverrides(os.Environ(), env) } if input != "" { cmd.Stdin = strings.NewReader(input) @@ -374,6 +403,12 @@ fi if !strings.Contains(logText, "-j verify-lite") { t.Fatalf("expected targeted job argument\nlog:\n%s", logText) } + if !strings.Contains(logText, "--no-skip-checkout") { + t.Fatalf("expected no-skip-checkout argument\nlog:\n%s", logText) + } + if strings.Contains(logText, "--action-offline-mode") { + t.Fatalf("expected offline mode to be opt-in\nlog:\n%s", logText) + } if !strings.Contains(logText, "--artifact-server-path "+filepath.Join(tmp, "out", "act-artifacts")) { t.Fatalf("expected artifact server path\nlog:\n%s", logText) } @@ -488,6 +523,62 @@ echo "$*" >> %q } } +func TestActOfflineModeOptIn(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "verify.yml"), []byte("name: verify\n"), 0o644); err != nil { + t.Fatalf("write workflow failed: %v", err) + } + + logPath := filepath.Join(tmp, "act.log") + actPath := filepath.Join(tmp, "act") + actScript := fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail +if [[ " $* " == *" -l "* ]]; then + cat <<'EOF' +Stage Job ID Job name Workflow name Workflow file Events +0 verify-lite verify-lite verify verify.yml workflow_dispatch +EOF + exit 0 +fi +echo "$*" >> %q +`, logPath) + if err := os.WriteFile(actPath, []byte(actScript), 0o755); err != nil { + t.Fatalf("write fake act failed: %v", err) + } + + out, err := runCiSelfInDirEnv( + t, + tmp, + []string{ + "PATH=" + tmp + ":" + os.Getenv("PATH"), + "CI_SELF_ACT_OFFLINE_MODE=1", + }, + "act", + "--project-dir", + tmp, + "--job", + "verify-lite", + ) + if err != nil { + t.Fatalf("act command failed with offline mode: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "OK: act offline mode enabled via CI_SELF_ACT_OFFLINE_MODE=1") { + t.Fatalf("expected offline mode log\noutput:\n%s", out) + } + + logBody, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatalf("failed to read act log: %v", readErr) + } + if !strings.Contains(string(logBody), "--action-offline-mode") { + t.Fatalf("expected offline mode argument\nlog:\n%s", string(logBody)) + } +} + func TestFocusHelp(t *testing.T) { out, err := runCiSelf(t, "focus", "--help") if err != nil { diff --git a/ops/ci/scaffold_verify_workflow_test.go b/ops/ci/scaffold_verify_workflow_test.go index 1654c22..aadf6c0 100644 --- a/ops/ci/scaffold_verify_workflow_test.go +++ b/ops/ci/scaffold_verify_workflow_test.go @@ -20,7 +20,7 @@ func runScaffoldVerifyWorkflowWithEnvInput(t *testing.T, repo string, env []stri cmd := exec.Command("bash", allArgs...) cmd.Dir = "." if len(env) > 0 { - cmd.Env = append(os.Environ(), env...) + cmd.Env = mergeEnvOverrides(os.Environ(), env) } if input != "" { cmd.Stdin = strings.NewReader(input) From 0a5afc1f5f2a573b9128f85520df57c9cbec28b7 Mon Sep 17 00:00:00 2001 From: mt4110 <4221282+mt4110@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:34:41 +0900 Subject: [PATCH 3/3] Handle remaining review follow-ups --- ops/ci/ci_self.sh | 21 ++++-- ops/ci/ci_self_test.go | 102 +++++++++++++++++++++++++++++ ops/ci/scaffold_verify_workflow.sh | 2 + 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/ops/ci/ci_self.sh b/ops/ci/ci_self.sh index babd933..1d6da71 100755 --- a/ops/ci/ci_self.sh +++ b/ops/ci/ci_self.sh @@ -38,6 +38,11 @@ trim() { printf '%s\n' "$s" } +parse_decimal_index() { + local raw="$1" + printf '%s\n' "$((10#$raw))" +} + expand_local_path() { local p="$1" if [[ "$p" == "~/"* ]]; then @@ -382,13 +387,15 @@ resolve_requested_job() { fi if [[ "$requested_job" =~ ^[0-9]+$ ]]; then - if (( requested_job >= 1 && requested_job <= ${#jobs[@]} )); then - job="${jobs[$((requested_job - 1))]}" - log_ts_err "OK: selected job=$job (index=$requested_job)" + local requested_job_index=0 + requested_job_index="$(parse_decimal_index "$requested_job")" + if (( requested_job_index >= 1 && requested_job_index <= ${#jobs[@]} )); then + job="${jobs[$((requested_job_index - 1))]}" + log_ts_err "OK: selected job=$job (index=$requested_job_index)" printf '%s\n' "$job" return 0 fi - log_ts_err "ERROR: job index out of range: $requested_job" + log_ts_err "ERROR: job index out of range: $requested_job_index" log_ts_err "HINT: choose 1..${#jobs[@]} or pass an actual job id" print_act_jobs_hint "$project_dir" "$workflow" "$event_name" return 2 @@ -458,8 +465,10 @@ select_local_workflow() { printf '> 不正な入力です。番号か q を入力してください。\n' >&2 ;; *) - if (( choice >= 1 && choice <= ${#workflows[@]} )); then - selected="${workflows[$((choice - 1))]}" + local choice_index=0 + choice_index="$(parse_decimal_index "$choice")" + if (( choice_index >= 1 && choice_index <= ${#workflows[@]} )); then + selected="${workflows[$((choice_index - 1))]}" log_ts_err "OK: selected workflow=$selected" printf '%s\n' "$selected" return 0 diff --git a/ops/ci/ci_self_test.go b/ops/ci/ci_self_test.go index 3b36199..916f274 100644 --- a/ops/ci/ci_self_test.go +++ b/ops/ci/ci_self_test.go @@ -287,6 +287,54 @@ fi } } +func TestActInteractiveWorkflowSelectionAcceptsLeadingZeroIndex(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "alpha.yml"), []byte("name: Alpha Flow\n"), 0o644); err != nil { + t.Fatalf("write alpha workflow failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "beta.yaml"), []byte("name: Beta Flow\n"), 0o644); err != nil { + t.Fatalf("write beta workflow failed: %v", err) + } + + logPath := filepath.Join(tmp, "act.log") + actPath := filepath.Join(tmp, "act") + actScript := fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail +echo "$*" >> %q +if [[ " $* " == *" -l "* ]]; then + cat <<'EOF' +Stage Job ID Job name Workflow name Workflow file Events +0 verify verify Beta Flow beta.yaml workflow_dispatch +EOF + exit 0 +fi +`, logPath) + if err := os.WriteFile(actPath, []byte(actScript), 0o755); err != nil { + t.Fatalf("write fake act failed: %v", err) + } + + out, err := runCiSelfInDirEnvInput( + t, + tmp, + []string{"PATH=" + tmp + ":" + os.Getenv("PATH")}, + "02\n", + "act", + "--project-dir", + tmp, + "--list", + ) + if err != nil { + t.Fatalf("interactive act list with leading zero failed: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "OK: selected workflow="+filepath.Join(tmp, ".github", "workflows", "beta.yaml")) { + t.Fatalf("expected selected workflow output\noutput:\n%s", out) + } +} + func TestActInteractiveWorkflowSelectionQuit(t *testing.T) { tmp := t.TempDir() workflowDir := filepath.Join(tmp, ".github", "workflows") @@ -523,6 +571,60 @@ echo "$*" >> %q } } +func TestActResolvesLeadingZeroJobIndex(t *testing.T) { + tmp := t.TempDir() + workflowDir := filepath.Join(tmp, ".github", "workflows") + if err := os.MkdirAll(workflowDir, 0o755); err != nil { + t.Fatalf("mkdir workflow dir failed: %v", err) + } + if err := os.WriteFile(filepath.Join(workflowDir, "verify.yml"), []byte("name: verify\n"), 0o644); err != nil { + t.Fatalf("write workflow failed: %v", err) + } + + logPath := filepath.Join(tmp, "act.log") + actPath := filepath.Join(tmp, "act") + actScript := fmt.Sprintf(`#!/usr/bin/env bash +set -euo pipefail +if [[ " $* " == *" -l "* ]]; then + cat <<'EOF' +Stage Job ID Job name Workflow name Workflow file Events +0 verify-lite verify-lite verify verify.yml workflow_dispatch +1 verify-full verify-full verify verify.yml workflow_dispatch +EOF + exit 0 +fi +echo "$*" >> %q +`, logPath) + if err := os.WriteFile(actPath, []byte(actScript), 0o755); err != nil { + t.Fatalf("write fake act failed: %v", err) + } + + out, err := runCiSelfInDirEnv( + t, + tmp, + []string{"PATH=" + tmp + ":" + os.Getenv("PATH")}, + "act", + "--project-dir", + tmp, + "--job", + "02", + ) + if err != nil { + t.Fatalf("leading-zero job selection failed: %v\noutput:\n%s", err, out) + } + if !strings.Contains(out, "OK: selected job=verify-full (index=2)") { + t.Fatalf("expected normalized numeric selection log\noutput:\n%s", out) + } + + logBody, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatalf("failed to read act log: %v", readErr) + } + if !strings.Contains(string(logBody), "-j verify-full") { + t.Fatalf("expected resolved job id in act invocation\nlog:\n%s", string(logBody)) + } +} + func TestActOfflineModeOptIn(t *testing.T) { tmp := t.TempDir() workflowDir := filepath.Join(tmp, ".github", "workflows") diff --git a/ops/ci/scaffold_verify_workflow.sh b/ops/ci/scaffold_verify_workflow.sh index cc3e5e8..619a894 100755 --- a/ops/ci/scaffold_verify_workflow.sh +++ b/ops/ci/scaffold_verify_workflow.sh @@ -18,6 +18,8 @@ Behavior: - nix mode: flake.nix exists - go mode: go.mod exists - minimal mode: fallback + - On an interactive TTY, existing verify.yml can be overwritten after [y/N] confirmation. + - In non-interactive runs, --force is required to overwrite an existing verify.yml. - Without --apply, prints generated YAML to stdout. USAGE }