|
| 1 | +name: Migrate consumer repos |
| 2 | + |
| 3 | +# Consumer migration automation for the move from `docker/cagent-action` to |
| 4 | +# the new `docker/docker-agent-action` repo (roadmap Phase 1B, issue #8). |
| 5 | +# |
| 6 | +# The action moved to a brand new repo rather than being renamed in place: |
| 7 | +# GitHub Actions `uses:` references do not follow repository renames, so an |
| 8 | +# in-place rename would have broken all 48+ consumers at once. The old repo |
| 9 | +# stays live and functional during the transition — there is no shim and no |
| 10 | +# migration deadline. Consumers migrate at their own pace; this workflow can |
| 11 | +# be run on demand (or on their behalf) to open migration PRs. |
| 12 | +# |
| 13 | +# It searches org:docker for any `docker/cagent-action` reference in workflow |
| 14 | +# files, rewrites them to `docker/docker-agent-action` (re-pinned to a release |
| 15 | +# published under the new name), and opens one PR per consumer repo via |
| 16 | +# signed commits. |
| 17 | +# |
| 18 | +# Differences from update-consumers.yml: |
| 19 | +# - matches ALL reference shapes (root action, sub-actions, reusable |
| 20 | +# workflow, gh api URLs) — not just the reusable-workflow path. This |
| 21 | +# covers both the two-workflow (~20 repos) and single-workflow (~15 |
| 22 | +# repos) consumer patterns. |
| 23 | +# - rewrites every matching workflow file in a repo in one PR (consumers |
| 24 | +# often reference both review-pr.yml and the trigger workflow). |
| 25 | +# - supports dry-run (default ON) and a repo allowlist for piloting the |
| 26 | +# automation on a few repos per tier (roadmap issue #9). |
| 27 | +# |
| 28 | +# All rewrite logic lives in src/migrate-consumer-refs (TypeScript, unit |
| 29 | +# tested) — this workflow only orchestrates discovery, cloning, and PRs. |
| 30 | + |
| 31 | +on: |
| 32 | + workflow_dispatch: |
| 33 | + inputs: |
| 34 | + version: |
| 35 | + description: "docker-agent-action release to pin consumers to (e.g. v2.0.0). Defaults to latest release." |
| 36 | + required: false |
| 37 | + type: string |
| 38 | + repos: |
| 39 | + description: "Comma-separated allowlist of repos to process (e.g. docker/sailor,docker/compose). Empty = all discovered consumers." |
| 40 | + required: false |
| 41 | + type: string |
| 42 | + default: "" |
| 43 | + dry-run: |
| 44 | + description: "Dry run: show the diffs that would be committed, but do not push commits or open PRs." |
| 45 | + required: false |
| 46 | + type: boolean |
| 47 | + default: true |
| 48 | + |
| 49 | +permissions: |
| 50 | + contents: read |
| 51 | + id-token: write |
| 52 | + |
| 53 | +concurrency: |
| 54 | + group: migrate-consumers |
| 55 | + cancel-in-progress: false |
| 56 | + |
| 57 | +jobs: |
| 58 | + migrate-consumers: |
| 59 | + name: Migrate consumer repo references |
| 60 | + runs-on: ubuntu-latest |
| 61 | + steps: |
| 62 | + |
| 63 | + - name: Resolve version and SHA |
| 64 | + id: resolve |
| 65 | + env: |
| 66 | + GH_TOKEN: ${{ github.token }} |
| 67 | + INPUT_VERSION: ${{ inputs.version }} |
| 68 | + run: | |
| 69 | + if [ -n "$INPUT_VERSION" ]; then |
| 70 | + VERSION="$INPUT_VERSION" |
| 71 | + else |
| 72 | + VERSION=$(gh release view --repo docker/docker-agent-action --json tagName --jq .tagName) |
| 73 | + fi |
| 74 | + if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then |
| 75 | + echo "::error::Invalid version format: '$VERSION' (expected vX.Y.Z)" |
| 76 | + exit 1 |
| 77 | + fi |
| 78 | + OBJ_TYPE=$(gh api "repos/docker/docker-agent-action/git/ref/tags/$VERSION" --jq .object.type) |
| 79 | + if [ "$OBJ_TYPE" = "tag" ]; then |
| 80 | + SHA=$(gh api "repos/docker/docker-agent-action/git/ref/tags/$VERSION" --jq '.object.url' \ |
| 81 | + | xargs gh api --jq .object.sha) |
| 82 | + else |
| 83 | + SHA=$(gh api "repos/docker/docker-agent-action/git/ref/tags/$VERSION" --jq .object.sha) |
| 84 | + fi |
| 85 | + echo "version=$VERSION" >> $GITHUB_OUTPUT |
| 86 | + echo "sha=$SHA" >> $GITHUB_OUTPUT |
| 87 | + echo "Resolved: $VERSION @ $SHA" |
| 88 | +
|
| 89 | + - name: Checkout for composite actions |
| 90 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 91 | + with: |
| 92 | + repository: docker/docker-agent-action |
| 93 | + ref: ${{ steps.resolve.outputs.sha }} |
| 94 | + persist-credentials: false |
| 95 | + |
| 96 | + - name: Setup credentials |
| 97 | + uses: ./setup-credentials |
| 98 | + |
| 99 | + # setup-credentials already masks the token via core.setSecret, but the |
| 100 | + # invariant lives in another module — re-mask explicitly here so this |
| 101 | + # workflow stays safe even if that implementation detail changes. |
| 102 | + - name: Mask app token |
| 103 | + run: echo "::add-mask::$GITHUB_APP_TOKEN" |
| 104 | + |
| 105 | + # Check out the current repo (wherever this workflow runs from) so the |
| 106 | + # CLI tools can be built — github.sha is always a valid ref here, both |
| 107 | + # before and after the content is copied to docker/docker-agent-action. |
| 108 | + - name: Checkout source for build |
| 109 | + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 |
| 110 | + with: |
| 111 | + ref: ${{ github.sha }} |
| 112 | + persist-credentials: false |
| 113 | + |
| 114 | + - name: Setup pnpm |
| 115 | + uses: pnpm/action-setup@8912a9102ac27614460f54aedde9e1e7f9aec20d # v6.0.5 |
| 116 | + with: |
| 117 | + run_install: false |
| 118 | + |
| 119 | + - name: Setup Node.js |
| 120 | + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 |
| 121 | + with: |
| 122 | + node-version: 24 |
| 123 | + cache: pnpm |
| 124 | + |
| 125 | + - name: Build CLI tools |
| 126 | + run: pnpm install --frozen-lockfile && pnpm build |
| 127 | + |
| 128 | + - name: Discover consumer repos |
| 129 | + id: discover |
| 130 | + env: |
| 131 | + GH_TOKEN: ${{ env.GITHUB_APP_TOKEN }} |
| 132 | + REPO_ALLOWLIST: ${{ inputs.repos }} |
| 133 | + run: | |
| 134 | + set -euo pipefail |
| 135 | +
|
| 136 | + # Search for ANY docker/cagent-action reference in workflow files. |
| 137 | + # GitHub code search treats the quoted string as a literal substring, |
| 138 | + # so this matches uses: refs (root action, sub-actions, reusable |
| 139 | + # workflow) AND gh api URLs in one query. |
| 140 | + echo "Searching org:docker for docker/cagent-action references..." |
| 141 | + REPOS=$(gh api --method GET --paginate '/search/code?per_page=100' \ |
| 142 | + -f q='org:docker "docker/cagent-action" language:YAML path:.github/workflows' \ |
| 143 | + --jq '[.items[].repository.full_name] | unique | .[]') |
| 144 | +
|
| 145 | + if [ -z "$REPOS" ]; then |
| 146 | + echo "No consumer repos found." |
| 147 | + echo "repos=" >> $GITHUB_OUTPUT |
| 148 | + exit 0 |
| 149 | + fi |
| 150 | +
|
| 151 | + # Never migrate the action repos themselves: the old repo stays |
| 152 | + # live during the transition and its workflow files legitimately |
| 153 | + # reference its own slug, and the new repo contains this workflow's |
| 154 | + # own grep pattern and comments — neither must be rewritten. |
| 155 | + REPOS=$(printf '%s\n' "$REPOS" | grep -vxF \ |
| 156 | + -e 'docker/cagent-action' \ |
| 157 | + -e 'docker/docker-agent-action' || true) |
| 158 | +
|
| 159 | + if [ -z "$REPOS" ]; then |
| 160 | + echo "No consumer repos found after exclusions." |
| 161 | + echo "repos=" >> $GITHUB_OUTPUT |
| 162 | + exit 0 |
| 163 | + fi |
| 164 | +
|
| 165 | + # Apply the allowlist filter if provided (issue #9: pilot runs). |
| 166 | + if [ -n "$REPO_ALLOWLIST" ]; then |
| 167 | + FILTERED="" |
| 168 | + IFS=',' read -ra ALLOWED <<< "$REPO_ALLOWLIST" |
| 169 | + while IFS= read -r REPO; do |
| 170 | + for A in "${ALLOWED[@]}"; do |
| 171 | + # Trim surrounding whitespace via parameter expansion — unlike |
| 172 | + # xargs this does not interpret backslashes or quotes, so a |
| 173 | + # malformed entry cannot corrupt the filtering. |
| 174 | + A_TRIMMED="${A#"${A%%[![:space:]]*}"}" |
| 175 | + A_TRIMMED="${A_TRIMMED%"${A_TRIMMED##*[![:space:]]}"}" |
| 176 | + if [ "$REPO" = "$A_TRIMMED" ]; then |
| 177 | + FILTERED+="$REPO"$'\n' |
| 178 | + fi |
| 179 | + done |
| 180 | + done <<< "$REPOS" |
| 181 | + REPOS=$(printf '%s' "$FILTERED") |
| 182 | + if [ -z "$REPOS" ]; then |
| 183 | + echo "::warning::Allowlist did not match any discovered consumer repos" |
| 184 | + echo "repos=" >> $GITHUB_OUTPUT |
| 185 | + exit 0 |
| 186 | + fi |
| 187 | + fi |
| 188 | +
|
| 189 | + echo "Consumer repos to process:" |
| 190 | + echo "$REPOS" |
| 191 | + { |
| 192 | + echo 'repos<<REPOS_EOF' |
| 193 | + echo "$REPOS" |
| 194 | + echo 'REPOS_EOF' |
| 195 | + } >> $GITHUB_OUTPUT |
| 196 | +
|
| 197 | + - name: Migrate references and open PRs |
| 198 | + if: steps.discover.outputs.repos != '' |
| 199 | + env: |
| 200 | + GH_TOKEN: ${{ env.GITHUB_APP_TOKEN }} |
| 201 | + REPOS: ${{ steps.discover.outputs.repos }} |
| 202 | + SHA: ${{ steps.resolve.outputs.sha }} |
| 203 | + VERSION: ${{ steps.resolve.outputs.version }} |
| 204 | + DRY_RUN: ${{ inputs.dry-run }} |
| 205 | + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |
| 206 | + run: | |
| 207 | + set -euo pipefail |
| 208 | +
|
| 209 | + if [ -z "$SHA" ] || [ -z "$VERSION" ]; then |
| 210 | + echo "::error::SHA or VERSION is empty (SHA='$SHA', VERSION='$VERSION')" |
| 211 | + exit 1 |
| 212 | + fi |
| 213 | +
|
| 214 | + BRANCH="auto/migrate-to-docker-agent-action" |
| 215 | + RELEASE_URL="https://github.com/docker/docker-agent-action/releases/tag/$VERSION" |
| 216 | + MIGRATE_CLI="$GITHUB_WORKSPACE/dist/migrate-consumer-refs.js" |
| 217 | + SUMMARY_CHANGED="" |
| 218 | + SUMMARY_SKIPPED="" |
| 219 | +
|
| 220 | + # Single EXIT trap registered once, referencing a global — avoids |
| 221 | + # re-registering a trap per loop iteration (traps don't stack; a |
| 222 | + # single-quoted per-iteration trap would silently replace the |
| 223 | + # previous handler). cleanup_workdir is also called explicitly on |
| 224 | + # every skip/continue path and is a no-op when already cleaned. |
| 225 | + CURRENT_WORK_DIR="" |
| 226 | + cleanup_workdir() { |
| 227 | + cd / |
| 228 | + if [ -n "$CURRENT_WORK_DIR" ]; then |
| 229 | + rm -rf "$CURRENT_WORK_DIR" |
| 230 | + CURRENT_WORK_DIR="" |
| 231 | + fi |
| 232 | + } |
| 233 | + trap cleanup_workdir EXIT |
| 234 | +
|
| 235 | + while IFS= read -r REPO; do |
| 236 | + [ -z "$REPO" ] && continue |
| 237 | + echo "==========================================" |
| 238 | + echo "Processing ${REPO}..." |
| 239 | + echo "==========================================" |
| 240 | +
|
| 241 | + WORK_DIR=$(mktemp -d) |
| 242 | + CURRENT_WORK_DIR="$WORK_DIR" |
| 243 | + if ! gh repo clone "$REPO" "$WORK_DIR" -- --depth=1 2>/dev/null; then |
| 244 | + echo "::warning::Failed to clone $REPO — skipping (token may lack access)" |
| 245 | + SUMMARY_SKIPPED+="- ${REPO} (clone failed)"$'\n' |
| 246 | + cleanup_workdir |
| 247 | + continue |
| 248 | + fi |
| 249 | +
|
| 250 | + cd "$WORK_DIR" |
| 251 | +
|
| 252 | + # Find every workflow file containing the old slug. grep -l exits |
| 253 | + # 1 when nothing matches, so guard with || true. |
| 254 | + MATCHING_FILES=$(grep -rl 'docker/cagent-action' .github/workflows/ 2>/dev/null || true) |
| 255 | + if [ -z "$MATCHING_FILES" ]; then |
| 256 | + echo "No old references found in $REPO — already migrated, skipping" |
| 257 | + SUMMARY_SKIPPED+="- ${REPO} (already migrated)"$'\n' |
| 258 | + cleanup_workdir |
| 259 | + continue |
| 260 | + fi |
| 261 | +
|
| 262 | + # Rewrite all matching files via the tested TS tool. Build the |
| 263 | + # argument list as a bash array (no eval, see AGENTS.md). |
| 264 | + # NOTE: pipefail (set above) is required here — without it a CLI |
| 265 | + # failure would be masked by sed succeeding, and a partial |
| 266 | + # CHANGED_FILES list could be committed. The CLI also exits 1 on |
| 267 | + # any per-file error for the same reason. |
| 268 | + FILE_ARGS=() |
| 269 | + while IFS= read -r F; do |
| 270 | + FILE_ARGS+=("$F") |
| 271 | + done <<< "$MATCHING_FILES" |
| 272 | +
|
| 273 | + CHANGED_FILES=$(node "$MIGRATE_CLI" --sha "$SHA" --version "$VERSION" "${FILE_ARGS[@]}" \ |
| 274 | + | sed 's/^changed //') || { |
| 275 | + echo "::warning::migrate-consumer-refs failed for $REPO — skipping" |
| 276 | + SUMMARY_SKIPPED+="- ${REPO} (rewrite failed)"$'\n' |
| 277 | + cleanup_workdir |
| 278 | + continue |
| 279 | + } |
| 280 | +
|
| 281 | + if [ -z "$CHANGED_FILES" ] || git diff --quiet; then |
| 282 | + echo "No changes after rewrite — already up to date" |
| 283 | + SUMMARY_SKIPPED+="- ${REPO} (no changes)"$'\n' |
| 284 | + cleanup_workdir |
| 285 | + continue |
| 286 | + fi |
| 287 | +
|
| 288 | + echo "Changed files:" |
| 289 | + echo "$CHANGED_FILES" |
| 290 | +
|
| 291 | + if [ "$DRY_RUN" = "true" ]; then |
| 292 | + echo "🧪 DRY RUN — diff that would be committed:" |
| 293 | + git --no-pager diff |
| 294 | + SUMMARY_CHANGED+="- ${REPO} (dry run — $(echo "$CHANGED_FILES" | wc -l | tr -d ' ') file(s))"$'\n' |
| 295 | + cleanup_workdir |
| 296 | + continue |
| 297 | + fi |
| 298 | +
|
| 299 | + # Detect the default branch (consumers use main or master). |
| 300 | + DEFAULT_BRANCH=$(gh repo view "$REPO" --json defaultBranchRef --jq .defaultBranchRef.name) |
| 301 | +
|
| 302 | + # Create one signed commit covering all changed files. |
| 303 | + ADD_ARGS=() |
| 304 | + while IFS= read -r F; do |
| 305 | + ADD_ARGS+=(--add "$F") |
| 306 | + done <<< "$CHANGED_FILES" |
| 307 | +
|
| 308 | + COMMIT_OID=$(GITHUB_TOKEN="${GH_TOKEN}" node "$GITHUB_WORKSPACE/dist/signed-commit.js" \ |
| 309 | + --repo "$REPO" \ |
| 310 | + --branch "$BRANCH" \ |
| 311 | + --base-ref "$DEFAULT_BRANCH" \ |
| 312 | + --force \ |
| 313 | + --message "chore: migrate cagent-action to docker-agent-action ($VERSION)" \ |
| 314 | + "${ADD_ARGS[@]}") || { |
| 315 | + echo "::warning::Failed to create signed commit in $REPO (may lack write access)" |
| 316 | + SUMMARY_SKIPPED+="- ${REPO} (commit failed)"$'\n' |
| 317 | + cleanup_workdir |
| 318 | + continue |
| 319 | + } |
| 320 | +
|
| 321 | + echo "✅ Signed commit: $COMMIT_OID" |
| 322 | +
|
| 323 | + # Create or update the PR. `// empty` is required: without it jq |
| 324 | + # prints the literal string "null" when no PR exists, which would |
| 325 | + # wrongly take the "update existing PR" branch and never create one. |
| 326 | + EXISTING_PR=$(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json number --jq '.[0].number // empty') |
| 327 | +
|
| 328 | + printf -v PR_BODY '%s\n\n%s\n%s\n\n%s\n%s\n%s\n\n%s\n\n%s' \ |
| 329 | + "## Summary" \ |
| 330 | + "\`docker/cagent-action\` has moved to a new repository: \`docker/docker-agent-action\`." \ |
| 331 | + "GitHub Actions \`uses:\` references must be updated explicitly, so this PR migrates all references and pins them to [${VERSION}](${RELEASE_URL})." \ |
| 332 | + "- **New repository**: \`docker/docker-agent-action\`" \ |
| 333 | + "- **Pinned commit**: \`${SHA}\`" \ |
| 334 | + "- **Version**: \`${VERSION}\`" \ |
| 335 | + "ℹ️ The old \`docker/cagent-action\` repo remains functional, so there is no deadline — merge whenever convenient. New features and fixes land in the new repo, which is where you'll want to be going forward." \ |
| 336 | + "> Auto-generated by the [migrate-consumers](${RUN_URL}) workflow." |
| 337 | +
|
| 338 | + if [ -n "$EXISTING_PR" ]; then |
| 339 | + echo "Updating existing PR #$EXISTING_PR in $REPO" |
| 340 | + gh pr edit "$EXISTING_PR" --repo "$REPO" \ |
| 341 | + --title "chore: migrate cagent-action to docker-agent-action ($VERSION)" \ |
| 342 | + --body "$PR_BODY" 2>&1 || echo "::warning::Failed to update PR #$EXISTING_PR in $REPO (may be non-fatal)" |
| 343 | + PR_URL=$(gh pr view "$EXISTING_PR" --repo "$REPO" --json url --jq .url 2>/dev/null || echo "") |
| 344 | + else |
| 345 | + echo "Creating new PR in $REPO" |
| 346 | + PR_URL=$(gh pr create --repo "$REPO" \ |
| 347 | + --head "$BRANCH" \ |
| 348 | + --base "$DEFAULT_BRANCH" \ |
| 349 | + --title "chore: migrate cagent-action to docker-agent-action ($VERSION)" \ |
| 350 | + --body "$PR_BODY") || { |
| 351 | + echo "::warning::Failed to create PR in $REPO" |
| 352 | + PR_URL="" |
| 353 | + } |
| 354 | + fi |
| 355 | +
|
| 356 | + # Reviewer assignment is best-effort and decoupled from PR |
| 357 | + # creation: `gh pr create --reviewer` fails the ENTIRE create when |
| 358 | + # the reviewer is not a collaborator of the consumer repo, which |
| 359 | + # would silently drop the migration PR. |
| 360 | + if [ -n "$PR_URL" ]; then |
| 361 | + gh pr edit "$PR_URL" --add-reviewer "derekmisler" 2>&1 \ |
| 362 | + || echo "::warning::Could not add reviewer on $PR_URL (non-fatal)" |
| 363 | + fi |
| 364 | +
|
| 365 | + SUMMARY_CHANGED+="- ${REPO} ${PR_URL:+(${PR_URL})}"$'\n' |
| 366 | +
|
| 367 | + cleanup_workdir |
| 368 | + echo "" |
| 369 | + done <<< "$REPOS" |
| 370 | +
|
| 371 | + # Job summary for triage (roadmap issue #15: monitor consumer migration progress). |
| 372 | + { |
| 373 | + echo "## Migrate consumers — $([ "$DRY_RUN" = "true" ] && echo 'DRY RUN' || echo 'EXECUTED')" |
| 374 | + echo "" |
| 375 | + echo "Target: \`${VERSION}\` @ \`${SHA}\`" |
| 376 | + echo "" |
| 377 | + if [ -n "$SUMMARY_CHANGED" ]; then |
| 378 | + echo "### PRs opened / repos with changes" |
| 379 | + printf '%s' "$SUMMARY_CHANGED" |
| 380 | + echo "" |
| 381 | + fi |
| 382 | + if [ -n "$SUMMARY_SKIPPED" ]; then |
| 383 | + echo "### Skipped" |
| 384 | + printf '%s' "$SUMMARY_SKIPPED" |
| 385 | + fi |
| 386 | + } >> "$GITHUB_STEP_SUMMARY" |
| 387 | +
|
| 388 | + echo "Done." |
0 commit comments