diff --git a/scripts/README.md b/scripts/README.md index 126e7f6..e799de5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,11 +2,27 @@ smkwlab organization 運用補助スクリプト。 -## distribute-claude-review.sh +## distribute-workflow.sh -Claude Code Review の caller ワークフローを、指定したリポジトリ群へ配布します。 -caller は共有 reusable -`smkwlab/.github/.github/workflows/claude-code-review.yml@` を呼び出します。 +共有 caller ワークフローを、指定したリポジトリ群へ配布する**汎用**スクリプト。 +特定のワークフロー(レビュー等)やシークレット(`ANTHROPIC_API_KEY` 等)に依存せず、 +`scripts/callers/.yml` のテンプレートを各対象リポジトリの +`.github/workflows/.yml` として設置し、`smkwlab/.github` 内の同名 reusable を +`@` で呼び出させます。 + +**新しい共有ワークフローを配布したくなったら、`scripts/callers/` にテンプレートを +1つ追加するだけ**です(スクリプト本体の変更は不要)。ワークフロー固有の事情(必要な +シークレット・PR 注記・可変値)は caller 側に置きます。 + +### 用意済みの caller テンプレート + +| caller | 配布先ファイル | 役割 | 必要な前提 | +|--------|----------------|------|-----------| +| `claude-code-review` | `.github/workflows/claude-code-review.yml` | PR 自動レビュー | org secret `ANTHROPIC_API_KEY` | +| `claude-mention` | `.github/workflows/claude-mention.yml` | `@claude` 対話・修正依頼 | org secret `ANTHROPIC_API_KEY` | + +`scripts/distribute-workflow.sh --list-callers` で一覧できます。各 caller の前提は +`scripts/callers/.pr-note.md`(PR 本文に付く注記)にも書かれています。 ### 設計(安全側の既定) @@ -17,25 +33,27 @@ caller は共有 reusable ### 前提 -- `gh`(GitHub CLI)が対象リポジトリへの write 権限を持つアカウントで認証済み -- org シークレット `ANTHROPIC_API_KEY` が各対象リポジトリで利用可能であること - (Org Settings → Secrets → Actions)。**本スクリプトはシークレットを管理しません** +- `gh`(GitHub CLI)が対象リポジトリへの write 権限を持つアカウントで認証済みであること + +これがスクリプト自体の唯一の前提です。**ワークフローが動くために必要なシークレット等は +caller ごとに異なり、本スクリプトは関与しません**(上表「必要な前提」を参照)。 ### 使い方 ```bash -# 候補(非アーカイブの smkwlab リポジトリ)を一覧 -scripts/distribute-claude-review.sh --list-candidates +# caller テンプレート / 配布候補を一覧 +scripts/distribute-workflow.sh --list-callers +scripts/distribute-workflow.sh --list-candidates # 1) まず1リポジトリで dry-run(何も変更しない) -scripts/distribute-claude-review.sh sotsuron-template +scripts/distribute-workflow.sh claude-mention sotsuron-template # 2) 問題なければ --apply で実行(重要リポジトリは opus)。まずここで検証する -scripts/distribute-claude-review.sh --apply --model opus sotsuron-template +scripts/distribute-workflow.sh --apply --model opus claude-mention sotsuron-template -# 3) PR が正常にレビューを起動することを確認してから、横展開(sonnet) -scripts/distribute-claude-review.sh --apply \ - wr-template sotsuron-report-template ise-report-template latex-template +# 3) 動作を確認してから横展開 +scripts/distribute-workflow.sh --apply claude-mention \ + wr-template ise-report-template latex-template poster-template ``` ### オプション @@ -43,23 +61,35 @@ scripts/distribute-claude-review.sh --apply \ | オプション | 既定 | 説明 | |-----------|------|------| | `--apply` | (dry-run) | 実際に変更する | -| `--model ` | `sonnet` | `sonnet` / `opus` / `haiku`。卒論・修論など重要リポジトリは `opus` | -| `--ref ` | `v1` | 呼び出す reusable の参照(タグ/ブランチ/SHA) | -| `--language ` | `日本語` | `review_language` の値 | +| `--ref ` | `v1` | 呼び出す reusable の参照(`__REF__` を置換)。タグ/ブランチ/SHA | +| `--var KEY=VALUE` | — | テンプレ/注記中の `__KEY__` を VALUE に置換(複数指定可)。caller 任意のつまみ。`--var` は `--ref`/`--model`/`--language` より優先(例: `--var REF=x` は `--ref` を上書き) | +| `--model ` | — | `--var MODEL=` の別名(Claude caller 用の利便) | +| `--language ` | — | `--var LANGUAGE=` の別名 | | `--direct` | (PR) | デフォルトブランチへ直接コミット(branch protection の無いリポジトリ向け) | -| `--branch ` | `add-claude-code-review` | PR 用ブランチ名 | +| `--branch ` | `add-` | PR 用ブランチ名 | +| `--list-callers` | — | caller テンプレートを一覧して終了 | | `--list-candidates` | — | 非アーカイブの smkwlab リポジトリを表示して終了 | | `-h`, `--help` | — | ヘルプ | +### 新しい共有ワークフローを配布対象に追加するには + +1. `smkwlab/.github` に reusable 本体(`.github/workflows/.yml`、`workflow_call`)を用意する +2. `scripts/callers/.yml` に caller テンプレートを置く + - 設置先・呼び出し先は `` で決まる(`uses: smkwlab/.github/.github/workflows/.yml@__REF__`) + - 可変値は `__REF__` や任意の `__KEY__` トークンで埋め込み、配布時に `--ref` / `--var` で与える + (GitHub の `${{ ... }}` 式はそのまま残る) +3. (任意)`scripts/callers/.pr-note.md` に PR 本文へ付ける注記(必要なシークレット等)を書く +4. `scripts/distribute-workflow.sh ` で配布 + ### 配布後の確認 -caller を追加したら、対象リポジトリで PR を1つ作成(または既存 PR を更新)し、 -Claude のレビューコメントが日本語で付くことを確認してください。`ANTHROPIC_API_KEY` -が未設定のリポジトリでは reusable 側が安全にスキップします。 +caller を追加したら対象リポジトリで動作を確認してください(確認方法は caller による: +レビューなら PR 作成、`@claude` なら mention コメント等)。必要な前提が満たされて +いないリポジトリでは reusable 側が安全にスキップする設計を推奨します。 ### 注意 -- **まず1リポジトリで検証**してから横展開すること(ブリーフの方針) +- **まず1リポジトリで検証**してから横展開すること - 学生リポジトリはテンプレートから生成されるため、テンプレートに caller を入れると 以降の新規リポジトリへ伝播します。既存の学生リポジトリへ反映するには `thesis-student-registry` の `propagate-workflow`(registry-manager)を併用します diff --git a/scripts/callers/claude-code-review.defaults b/scripts/callers/claude-code-review.defaults new file mode 100644 index 0000000..32a5cb9 --- /dev/null +++ b/scripts/callers/claude-code-review.defaults @@ -0,0 +1,2 @@ +MODEL=sonnet +LANGUAGE=日本語 diff --git a/scripts/callers/claude-code-review.pr-note.md b/scripts/callers/claude-code-review.pr-note.md new file mode 100644 index 0000000..d927145 --- /dev/null +++ b/scripts/callers/claude-code-review.pr-note.md @@ -0,0 +1,4 @@ +PR への自動レビュー(Claude)を有効化します。 + +- 動作には org シークレット `ANTHROPIC_API_KEY` がこのリポジトリで利用可能である必要があります(未配布なら安全にスキップ) +- draft PR は `ready_for_review` まで、fork PR は secret 不在のため、いずれも安全にスキップします diff --git a/scripts/callers/claude-code-review.yml b/scripts/callers/claude-code-review.yml new file mode 100644 index 0000000..f228631 --- /dev/null +++ b/scripts/callers/claude-code-review.yml @@ -0,0 +1,27 @@ +# Caller template: Claude Code Review (automatic PR review). +# Distributed by scripts/distribute-workflow.sh as +# .github/workflows/claude-code-review.yml in each target repository. +# The ref / model / language tokens below are substituted at distribution time. +name: Claude Code Review + +on: + pull_request: + types: [opened, reopened, ready_for_review] # no synchronize: avoid re-reviewing every push + +concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + uses: smkwlab/.github/.github/workflows/claude-code-review.yml@__REF__ + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + secrets: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: __MODEL__ + review_language: __LANGUAGE__ diff --git a/scripts/callers/claude-mention.defaults b/scripts/callers/claude-mention.defaults new file mode 100644 index 0000000..32a5cb9 --- /dev/null +++ b/scripts/callers/claude-mention.defaults @@ -0,0 +1,2 @@ +MODEL=sonnet +LANGUAGE=日本語 diff --git a/scripts/callers/claude-mention.pr-note.md b/scripts/callers/claude-mention.pr-note.md new file mode 100644 index 0000000..a8a474b --- /dev/null +++ b/scripts/callers/claude-mention.pr-note.md @@ -0,0 +1,4 @@ +`@claude` メンションでの対話・修正依頼を有効化します。 + +- 動作には org シークレット `ANTHROPIC_API_KEY` がこのリポジトリで利用可能である必要があります(未配布なら安全にスキップ) +- 起動するのは権限保有者(OWNER / MEMBER / COLLABORATOR)のコメントのみです diff --git a/scripts/callers/claude-mention.yml b/scripts/callers/claude-mention.yml new file mode 100644 index 0000000..80a1be3 --- /dev/null +++ b/scripts/callers/claude-mention.yml @@ -0,0 +1,33 @@ +# Caller template: Claude Mention (interactive @claude handler). +# Distributed by scripts/distribute-workflow.sh as +# .github/workflows/claude-mention.yml in each target repository. +# The ref / model / language tokens below are substituted at distribution time. +name: Claude Mention + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened] + +jobs: + claude: + # Run only when @claude is mentioned AND the author has repository access + # (OWNER/MEMBER/COLLABORATOR), so external users cannot trigger a write run. + if: > + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)) || + (github.event_name == 'issues' && contains(github.event.issue.body, '@claude') && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.issue.author_association)) + uses: smkwlab/.github/.github/workflows/claude-mention.yml@__REF__ + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + secrets: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + with: + model: __MODEL__ + language: __LANGUAGE__ diff --git a/scripts/distribute-claude-review.sh b/scripts/distribute-claude-review.sh deleted file mode 100755 index 5ff0d71..0000000 --- a/scripts/distribute-claude-review.sh +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env bash -# -# distribute-claude-review.sh -# -# Add the Claude Code Review caller workflow to one or more smkwlab repositories. -# The caller invokes the shared reusable workflow: -# smkwlab/.github/.github/workflows/claude-code-review.yml@ -# -# Design goals: safe by default. -# - Operates ONLY on repositories you name explicitly (no org-wide blast). -# - Dry-run by default; you must pass --apply to make any change. -# - Idempotent: skips a repo that already has the caller. -# - Delivers via Pull Request by default (use --direct to commit to the -# default branch, e.g. for repos without branch protection). -# -# Prerequisites: -# - gh (GitHub CLI) authenticated as a user with write access to the targets. -# - The org secret ANTHROPIC_API_KEY must be available to each target repo -# (Org Settings -> Secrets -> Actions). This script does NOT manage secrets. -# -# Usage: -# scripts/distribute-claude-review.sh [options] [ ...] -# -# may be "name" (assumed under smkwlab/) or "owner/name". -# -# Options: -# --apply Actually make changes (default is dry-run). -# --model Model for these repos: sonnet | opus | haiku (default: sonnet). -# --ref Reusable workflow ref to pin (default: v1). -# --language review_language value (default: 日本語). -# --direct Commit straight to the default branch instead of opening a PR. -# --branch Branch name to use for the PR (default: add-claude-code-review). -# --list-candidates Print non-archived smkwlab repos and exit (helper for choosing). -# -h, --help Show this help. -# -# Examples: -# # See what would happen for one template repo (dry-run): -# scripts/distribute-claude-review.sh sotsuron-template -# -# # Actually open a PR on one repo, using opus (verify here FIRST): -# scripts/distribute-claude-review.sh --apply --model opus sotsuron-template -# -# # Roll out to several document templates with sonnet: -# scripts/distribute-claude-review.sh --apply \ -# wr-template sotsuron-report-template ise-report-template latex-template -# -set -euo pipefail - -ORG="smkwlab" -WORKFLOW_PATH=".github/workflows/claude-code-review.yml" - -# Defaults -APPLY=false -MODEL="sonnet" -REF="v1" -LANGUAGE="日本語" -DIRECT=false -PR_BRANCH="add-claude-code-review" - -die() { echo "error: $*" >&2; exit 1; } -info() { echo " $*"; } - -usage() { sed -n '2,52p' "$0" | sed 's/^# \{0,1\}//'; } - -list_candidates() { - echo "Non-archived repositories under ${ORG}:" >&2 - gh repo list "$ORG" --no-archived --limit 200 \ - --json name,visibility,isArchived \ - --jq '.[] | select(.isArchived | not) | " \(.name)\t(\(.visibility))"' -} - -# --- parse args --- -REPOS=() -while [[ $# -gt 0 ]]; do - case "$1" in - --apply) APPLY=true; shift ;; - --model) MODEL="${2:?--model needs a value}"; shift 2 ;; - --ref) REF="${2:?--ref needs a value}"; shift 2 ;; - --language) LANGUAGE="${2:?--language needs a value}"; shift 2 ;; - --direct) DIRECT=true; shift ;; - --branch) PR_BRANCH="${2:?--branch needs a value}"; shift 2 ;; - --list-candidates) list_candidates; exit 0 ;; - -h|--help) usage; exit 0 ;; - --*) die "unknown option: $1" ;; - *) REPOS+=("$1"); shift ;; - esac -done - -command -v gh >/dev/null || die "gh (GitHub CLI) is required." -case "$MODEL" in sonnet|opus|haiku) ;; *) die "invalid --model: $MODEL (sonnet|opus|haiku)";; esac -[[ ${#REPOS[@]} -gt 0 ]] || die "no repositories given. See --help or --list-candidates." - -# Caller workflow content (rendered per repo with the chosen model/ref/language). -render_caller() { - cat </dev/null); then - echo " ✗ cannot access repo (not found / no permission)"; failed=$((failed+1)); continue - fi - - # Idempotency: skip if the caller already exists on the default branch. - if gh api "repos/${repo}/contents/${WORKFLOW_PATH}?ref=${default_branch}" >/dev/null 2>&1; then - echo " • already has ${WORKFLOW_PATH} — skipping"; skipped=$((skipped+1)); continue - fi - - content_b64=$(render_caller | base64 | tr -d '\n') - - if [[ $APPLY != true ]]; then - info "would add ${WORKFLOW_PATH} (model=${MODEL}, @${REF})" - info "would deliver via $([[ $DIRECT == true ]] && echo 'direct commit' || echo "PR ${PR_BRANCH} -> ${default_branch}")" - changed=$((changed+1)); continue - fi - - if [[ $DIRECT == true ]]; then - if gh api -X PUT "repos/${repo}/contents/${WORKFLOW_PATH}" \ - -f message="${PR_TITLE}" -f branch="${default_branch}" \ - -f content="${content_b64}" >/dev/null 2>&1; then - echo " ✓ committed to ${default_branch}"; changed=$((changed+1)) - else - echo " ✗ commit failed"; failed=$((failed+1)) - fi - continue - fi - - # PR flow: create branch from default head, add file, open PR. - head_sha=$(gh api "repos/${repo}/git/refs/heads/${default_branch}" --jq .object.sha) - if gh api "repos/${repo}/git/refs/heads/${PR_BRANCH}" >/dev/null 2>&1; then - echo " ✗ branch ${PR_BRANCH} already exists — resolve manually"; failed=$((failed+1)); continue - fi - gh api -X POST "repos/${repo}/git/refs" \ - -f ref="refs/heads/${PR_BRANCH}" -f sha="${head_sha}" >/dev/null - - if ! gh api -X PUT "repos/${repo}/contents/${WORKFLOW_PATH}" \ - -f message="${PR_TITLE}" -f branch="${PR_BRANCH}" \ - -f content="${content_b64}" >/dev/null 2>&1; then - echo " ✗ failed to add file on ${PR_BRANCH}"; failed=$((failed+1)); continue - fi - - if pr_url=$(gh pr create --repo "${repo}" --base "${default_branch}" --head "${PR_BRANCH}" \ - --title "${PR_TITLE}" --body "$(pr_body)" 2>/dev/null); then - echo " ✓ PR: ${pr_url}"; changed=$((changed+1)) - else - echo " ✗ file added to ${PR_BRANCH} but PR creation failed — create it manually"; failed=$((failed+1)) - fi -done - -echo -echo "=== summary: ${processed} processed, ${changed} changed, ${skipped} skipped, ${failed} failed ===" -[[ $APPLY == true ]] || echo "(dry-run — re-run with --apply to make changes)" -[[ $failed -eq 0 ]] diff --git a/scripts/distribute-workflow.sh b/scripts/distribute-workflow.sh new file mode 100755 index 0000000..a438242 --- /dev/null +++ b/scripts/distribute-workflow.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# +# distribute-workflow.sh +# +# Distribute a shared caller workflow to one or more smkwlab repositories. +# Generic over the caller and over the workflow's domain: it knows nothing about +# Claude, reviews, secrets, or models. Pick a template from scripts/callers/ and +# it is installed as .github/workflows/.yml in each target, calling the +# matching reusable workflow in smkwlab/.github. +# +# Caller-specific details (tunable values, required secrets, PR notes) live with +# the caller under scripts/callers/, not in this script. To distribute a new +# shared workflow, add a template there — no script changes. +# +# Design goals: safe by default. +# - Operates ONLY on repositories you name explicitly (no org-wide blast). +# - Dry-run by default; you must pass --apply to make any change. +# - Idempotent: skips a repo that already has the caller. +# - Delivers via Pull Request by default (--direct to commit to the default +# branch, e.g. for repos without branch protection). +# +# Prerequisite: gh (GitHub CLI) authenticated as a user with write access to the +# targets. (Any secrets a caller needs are the caller's concern — see its +# scripts/callers/.pr-note.md — and are NOT managed here.) +# +# Usage: +# scripts/distribute-workflow.sh [options] [ ...] +# +# Name of a template under scripts/callers/ (without .yml). +# Installed in targets as .github/workflows/.yml. +# "name" (assumed under smkwlab/) or "owner/name". +# +# Options: +# --apply Actually make changes (default is dry-run). +# --ref Reusable workflow ref to pin (default: v1). Substituted +# for the __REF__ token in the template. +# --var KEY=VALUE Substitute __KEY__ with VALUE in the template/PR note. +# Repeatable. Lets a caller expose arbitrary knobs. +# --model Convenience alias for --var MODEL=. +# --language Convenience alias for --var LANGUAGE=. +# --direct Commit straight to the default branch instead of a PR. +# --branch PR branch name (default: add-). +# --list-callers List available caller templates and exit. +# --list-candidates List non-archived smkwlab repos and exit. +# -h, --help Show this help. +# +# Examples: +# scripts/distribute-workflow.sh --list-callers +# scripts/distribute-workflow.sh claude-mention sotsuron-template # dry-run +# scripts/distribute-workflow.sh --apply --model opus claude-mention sotsuron-template +# scripts/distribute-workflow.sh --apply --var FOO=bar some-workflow repo-a repo-b +# +set -euo pipefail + +ORG="smkwlab" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CALLERS_DIR="${SCRIPT_DIR}/callers" + +# Defaults +APPLY=false +REF="v1" +DIRECT=false +PR_BRANCH="" +declare -a VARS=() # "KEY=VALUE" substitutions for __KEY__ tokens + +die() { echo "error: $*" >&2; exit 1; } +info() { echo " $*"; } + +usage() { sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'; } + +list_callers() { + echo "Available caller templates (scripts/callers/):" >&2 + for f in "$CALLERS_DIR"/*.yml; do [ -e "$f" ] && echo " $(basename "$f" .yml)"; done +} + +list_candidates() { + echo "Non-archived repositories under ${ORG}:" >&2 + gh repo list "$ORG" --no-archived --limit 200 \ + --json name,visibility,isArchived \ + --jq '.[] | select(.isArchived | not) | " \(.name)\t(\(.visibility))"' +} + +# --- parse args --- +CALLER="" +REPOS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --apply) APPLY=true; shift ;; + --ref) REF="${2:?--ref needs a value}"; shift 2 ;; + --var) [[ "${2:-}" == *=* ]] || die "--var needs KEY=VALUE"; VARS+=("$2"); shift 2 ;; + --model) VARS+=("MODEL=${2:?--model needs a value}"); shift 2 ;; + --language) VARS+=("LANGUAGE=${2:?--language needs a value}"); shift 2 ;; + --direct) DIRECT=true; shift ;; + --branch) PR_BRANCH="${2:?--branch needs a value}"; shift 2 ;; + --list-callers) list_callers; exit 0 ;; + --list-candidates) list_candidates; exit 0 ;; + -h|--help) usage; exit 0 ;; + --*) die "unknown option: $1" ;; + *) if [[ -z "$CALLER" ]]; then CALLER="$1"; else REPOS+=("$1"); fi; shift ;; + esac +done + +command -v gh >/dev/null || die "gh (GitHub CLI) is required." +[[ -n "$CALLER" ]] || { echo "no caller given." >&2; list_callers; exit 2; } + +TEMPLATE="${CALLERS_DIR}/${CALLER}.yml" +[[ -f "$TEMPLATE" ]] || { echo "error: unknown caller '$CALLER' (no ${TEMPLATE})" >&2; list_callers; exit 2; } +[[ ${#REPOS[@]} -gt 0 ]] || die "no repositories given. See --help or --list-candidates." + +WORKFLOW_PATH=".github/workflows/${CALLER}.yml" +NOTE_FILE="${CALLERS_DIR}/${CALLER}.pr-note.md" +DEFAULTS_FILE="${CALLERS_DIR}/${CALLER}.defaults" +[[ -n "$PR_BRANCH" ]] || PR_BRANCH="add-${CALLER}" + +# Collect token substitutions: CLI --var/--model/--language first (highest +# precedence), then the caller's optional .defaults, then --ref. sed applies +# -e in order and the FIRST match for a token wins, so CLI overrides defaults. +DEFAULTS=() +if [[ -f "$DEFAULTS_FILE" ]]; then + while IFS= read -r line; do + [[ "$line" =~ ^[[:space:]]*(#|$) ]] && continue + DEFAULTS+=("$line") + done < "$DEFAULTS_FILE" +fi +ORDERED=() +((${#VARS[@]})) && ORDERED+=("${VARS[@]}") +((${#DEFAULTS[@]})) && ORDERED+=("${DEFAULTS[@]}") +ORDERED+=("REF=${REF}") + +# Build sed args. Escape the replacement so a value containing | & \ is treated +# literally (a generic --var may carry anything). GitHub ${{ ... }} expressions +# contain no __TOKEN__ and are left intact. +SED_ARGS=() +for kv in "${ORDERED[@]}"; do + key="${kv%%=*}"; val="${kv#*=}" + [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "invalid token key: '$key'" + esc=$(printf '%s' "$val" | sed -e 's/[\\&|]/\\&/g') + SED_ARGS+=(-e "s|__${key}__|${esc}|g") +done +render() { sed "${SED_ARGS[@]}" "$1"; } # render a file with substitutions + +# Refuse to distribute if any __TOKEN__ is left unsubstituted (e.g. a forgotten +# --model) in the template OR its PR note. Caught once here, before touching any +# repository. +for f in "$TEMPLATE" "$NOTE_FILE"; do + [[ -f "$f" ]] || continue + leftover=$(render "$f" | grep -oE '__[A-Za-z0-9_]+__' | sort -u | tr '\n' ' ' || true) + [[ -z "${leftover// /}" ]] || die "$(basename "$f") has unsubstituted tokens: ${leftover}(provide them via --var / --model / --language or a ${CALLER}.defaults file)" +done + +PR_TITLE="ci: add ${CALLER} caller" +pr_body() { + echo "Adds the shared \`${CALLER}\` workflow as a caller of \`${ORG}/.github/${WORKFLOW_PATH}@${REF}\`." + if [[ -f "$NOTE_FILE" ]]; then echo; render "$NOTE_FILE"; fi +} + +vars_disp="${VARS[*]:-}"; [[ -n "$vars_disp" ]] || vars_disp="(none)" +echo "=== distribute-workflow ===" +echo "caller: $CALLER -> $WORKFLOW_PATH" +echo "mode: $([[ $APPLY == true ]] && echo APPLY || echo DRY-RUN)" +echo "deliver: $([[ $DIRECT == true ]] && echo 'direct commit to default branch' || echo "pull request ($PR_BRANCH)")" +echo "ref: @$REF vars: ${vars_disp}" +echo "targets: ${REPOS[*]}" +echo + +processed=0 skipped=0 changed=0 failed=0 + +for raw in "${REPOS[@]}"; do + repo="$raw"; [[ "$repo" == */* ]] || repo="${ORG}/${repo}" + echo "--- ${repo} ---" + processed=$((processed+1)) + + if ! default_branch=$(gh api "repos/${repo}" --jq .default_branch 2>/dev/null); then + echo " ✗ cannot access repo (not found / no permission)"; failed=$((failed+1)); continue + fi + + if gh api "repos/${repo}/contents/${WORKFLOW_PATH}?ref=${default_branch}" >/dev/null 2>&1; then + echo " • already has ${WORKFLOW_PATH} — skipping"; skipped=$((skipped+1)); continue + fi + + content_b64=$(render "$TEMPLATE" | base64 | tr -d '\n') + + if [[ $APPLY != true ]]; then + info "would add ${WORKFLOW_PATH} (@${REF}; vars: ${vars_disp})" + info "would deliver via $([[ $DIRECT == true ]] && echo 'direct commit' || echo "PR ${PR_BRANCH} -> ${default_branch}")" + changed=$((changed+1)); continue + fi + + if [[ $DIRECT == true ]]; then + if gh api -X PUT "repos/${repo}/contents/${WORKFLOW_PATH}" \ + -f message="${PR_TITLE}" -f branch="${default_branch}" \ + -f content="${content_b64}" >/dev/null 2>&1; then + echo " ✓ committed to ${default_branch}"; changed=$((changed+1)) + else + echo " ✗ commit failed"; failed=$((failed+1)) + fi + continue + fi + + if ! head_sha=$(gh api "repos/${repo}/git/refs/heads/${default_branch}" --jq .object.sha 2>/dev/null); then + echo " ✗ failed to read ${default_branch} head"; failed=$((failed+1)); continue + fi + if gh api "repos/${repo}/git/refs/heads/${PR_BRANCH}" >/dev/null 2>&1; then + echo " ✗ branch ${PR_BRANCH} already exists — resolve manually"; failed=$((failed+1)); continue + fi + if ! gh api -X POST "repos/${repo}/git/refs" \ + -f ref="refs/heads/${PR_BRANCH}" -f sha="${head_sha}" >/dev/null 2>&1; then + echo " ✗ failed to create branch ${PR_BRANCH}"; failed=$((failed+1)); continue + fi + + if ! gh api -X PUT "repos/${repo}/contents/${WORKFLOW_PATH}" \ + -f message="${PR_TITLE}" -f branch="${PR_BRANCH}" \ + -f content="${content_b64}" >/dev/null 2>&1; then + echo " ✗ failed to add file on ${PR_BRANCH}"; failed=$((failed+1)); continue + fi + + if pr_url=$(gh pr create --repo "${repo}" --base "${default_branch}" --head "${PR_BRANCH}" \ + --title "${PR_TITLE}" --body "$(pr_body)" 2>/dev/null); then + echo " ✓ PR: ${pr_url}"; changed=$((changed+1)) + else + echo " ✗ file added to ${PR_BRANCH} but PR creation failed — create it manually"; failed=$((failed+1)) + fi +done + +echo +echo "=== summary: ${processed} processed, ${changed} changed, ${skipped} skipped, ${failed} failed ===" +[[ $APPLY == true ]] || echo "(dry-run — re-run with --apply to make changes)" +[[ $failed -eq 0 ]]