Skip to content

Commit 6266774

Browse files
authored
feat: migrate-consumers automation for the new-repo migration (Phase 1B) (#237)
1 parent b9b0f17 commit 6266774

6 files changed

Lines changed: 1087 additions & 0 deletions

File tree

Lines changed: 388 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,388 @@
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."

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ The action runs untrusted input (PR titles, bodies, comments, diffs) through an
178178
| `security-scan.yml` | Periodic security scanning. |
179179
| `update-docker-agent-version.yml` | Bumps `DOCKER_AGENT_VERSION` automatically. |
180180
| `update-consumers.yml` | Pushes version updates to downstream consumer repos. |
181+
| `migrate-consumers.yml` | Consumer migration to the new repo: opens PRs across consumer repos rewriting `docker/cagent-action` refs to `docker/docker-agent-action` (incremental, no deadline — old repo stays live; dry-run by default, `repos` allowlist for pilots). |
181182
| `manual-test-pirate-agent.yml` | Manual smoke test with a toy agent. |
182183

183184
## Common tasks (cheat sheet)

0 commit comments

Comments
 (0)