Skip to content

Commit 1072e3d

Browse files
committed
Generalise migration version iteration modes
Add `version-iteration` (false/major/minor/patch) and `if-no-iterations` (error/pass) inputs to give explicit control over which intermediate versions are generated during multi-version jumps. The old `iterate-v0-minors` input is deprecated and mutually exclusive with `version-iteration`. When neither input is set, the action preserves the historical v0.x minor iteration behaviour but emits a deprecation warning asking users to set `version-iteration` explicitly. The implicit default will change in a future release. Input resolution order: 1. `version-iteration` set → use it directly; error if `iterate-v0-minors` is also set (mutually exclusive). 2. `iterate-v0-minors` set → map to the new mode with a deprecation warning; `if-no-iterations` defaults to "pass" for v0.x. 3. Neither set → backward-compatible default (v0.x minor iteration, `if-no-iterations` defaults to "pass"); deprecation warning emitted. `if-no-iterations` defaults to "error" under explicit `version-iteration` modes and "pass" under the deprecated and implicit paths, so existing workflows see no behaviour change. The empty-versions path now distinguishes error (exit 1, `migration_ran=false`) from pass (exit 0, `migration_ran=true`), fixing the downstream labelling/auto-merge flow. Signed-off-by: Leandro Lucarella <luca-frequenz@llucax.com>
1 parent f314744 commit 1072e3d

8 files changed

Lines changed: 1008 additions & 88 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ conditions, output references, and step IDs are consistent.
5656
## Repository layout
5757

5858
```
59-
action.yml # Composite action definition (19 steps)
59+
action.yml # Composite action definition
6060
scripts/run-migration.sh # Helper: run migration scripts
6161
scripts/build-report.sh # Helper: build Markdown summary
62+
scripts/check-update.sh # Helper: decide if migration is needed
6263
scripts/create-label.sh # Helper: ensure label exists/updated
6364
scripts/add-label.sh # Helper: ensure/add PR labels
6465
scripts/remove-label.sh # Helper: remove PR labels safely

README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ script reports manual intervention is needed.
3737
4. For each version in the upgrade range, the migration script is
3838
downloaded from the URL template (or the inline script is used) and
3939
executed.
40+
If the selected `version-iteration` mode produces no versions, the
41+
action fails by default so the unexpected no-op is visible. Set
42+
`if-no-iterations: "pass"` to treat that edge case as a clean
43+
migration with no file changes. (When `version-iteration` is not
44+
set, the implicit v0.x default and the deprecated
45+
`iterate-v0-minors` fallback both default to `"pass"` for backward
46+
compatibility.)
4047
5. File changes are committed to the PR branch.
4148
6. A PR comment and job summary are posted with the results.
4249
7. If all migrations succeed (exit code 0) **and** auto-merge is enabled
@@ -117,6 +124,21 @@ upgrade range. Scripts must follow these conventions:
117124
This is always set, regardless of whether the script was provided
118125
via URL template or inline.
119126

127+
* **Version iteration**`version-iteration` controls which versions
128+
are generated for multi-version jumps. Explicit values are `"false"`,
129+
`"major"`, `"minor"`, and `"patch"`. When empty (the default), the
130+
action preserves backward-compatible behaviour (v0.x bumps iterate
131+
intermediate minors, other bumps target only the new version) and
132+
emits a deprecation warning — set `version-iteration` explicitly to
133+
silence the warning. Boundary-only modes can legitimately produce no
134+
versions when the Dependabot update does not cross that boundary. In
135+
that case, `if-no-iterations` controls whether the action fails
136+
(`"error"`) or passes as a no-op migration (`"pass"`). When
137+
`if-no-iterations` is empty (the default), it uses `"error"` — except
138+
under the implicit v0.x default and the deprecated
139+
`iterate-v0-minors` fallback, where it uses `"pass"` for backward
140+
compatibility.
141+
120142
* **Exit code** — return **0** if the migration succeeded. Return a
121143
**non-zero** exit code if manual intervention is needed. A non-zero
122144
exit causes the action to add the `intervention-pending` label, fail
@@ -233,7 +255,9 @@ jobs:
233255
| `auto-merge-on-changes` | no | `"false"` | Auto-approve and auto-merge even when the migration produced commits (requires `token`) |
234256
| `sign-commits` | no | `"false"` | When `"true"`, create migration commits via API so commits have verified signatures (when supported, for example when using a GitHub App for `token`) |
235257
| `report-title` | no | `""` | Heading used for PR comments and job summaries; when empty, uses the calling workflow title |
236-
| `iterate-v0-minors` | no | `"true"` | Iterate through intermediate v0.x minor versions |
258+
| `version-iteration` | no | `""` | Controls generated migration versions: `"false"` runs only the target version; `"major"`, `"minor"`, and `"patch"` iterate semver boundaries. When empty, the action preserves backward-compatible behaviour (v0.x minor iteration) and emits a deprecation warning — set this explicitly to silence the warning. |
259+
| `if-no-iterations` | no | `""` | What to do when `version-iteration` produces no versions: `"error"` fails the action; `"pass"` treats it as a clean no-op migration. Defaults to `"error"` — except under the implicit v0.x default and the deprecated `iterate-v0-minors` fallback, where it defaults to `"pass"`. |
260+
| `iterate-v0-minors` | no | `""` | Deprecated; use `version-iteration` instead. When set to "true", v0.x bumps iterate intermediate minor versions; when "false", only the target version is run. When empty (default), the implicit v0.x minor iteration applies instead. Mutually exclusive with `version-iteration`. |
237261
| `python-version` | no | `"3.14"` | Python version for running the script |
238262
| `expected-actor` | no | `dependabot[bot]` | GitHub actor whose PRs trigger migration and auto-approval (gates metadata fetch, migration decision, and patch-only auto-approve) |
239263
| `migrated-label` | no | `migrated` | Label name for migrated state |
@@ -253,9 +277,9 @@ jobs:
253277

254278
| Output | Description |
255279
|---|---|
256-
| `migration_ran` | Whether the migration script(s) executed |
280+
| `migration_ran` | Whether migration has been handled: `"true"` when script(s) executed, no versions needed migrating, the PR was already migrated, or the update is patch-only without `version-iteration`; `"false"` otherwise. |
257281
| `overall_exit` | Consolidated exit code: `"0"` if all scripts succeeded, `"1"` if any required intervention |
258-
| `needs_migration` | `"true"` for minor/major bumps, `"false"` for patch-only |
282+
| `needs_migration` | `"true"` for minor/major bumps (always) and patch bumps (when `version-iteration` is set), `"false"` for patch-only without `version-iteration` |
259283
| `commit_made` | Whether the migration produced a commit |
260284

261285
### Requirements

action.yml

Lines changed: 111 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
# 21. Approve and auto-merge – clean migration only
8383
# 22. Handle patch-only updates – auto-merge with token,
8484
# otherwise instruct manual review
85+
# 23. Resolve migration_ran – always(), consolidate output
8586
#
8687
# Label ordering (step 19): intervention-pending-label is added BEFORE
8788
# migrated-label. If interrupted between the two API calls, the PR
@@ -168,13 +169,38 @@ inputs:
168169
When empty, the calling workflow title is used.
169170
required: false
170171
default: ""
172+
version-iteration:
173+
description: >-
174+
Controls which intermediate versions to run migration scripts
175+
for when a multi-version jump is detected. "false" runs only
176+
the target version; "major" runs for each major boundary
177+
(vX.0.0); "minor" also runs for each minor boundary within
178+
the target major; "patch" also runs for each patch within the
179+
target minor. When empty (default), falls back to the
180+
deprecated `iterate-v0-minors` input for backward
181+
compatibility.
182+
required: false
183+
default: ""
184+
if-no-iterations:
185+
description: >-
186+
Controls what happens when `version-iteration` produces no
187+
versions to migrate. "error" fails the action so the
188+
unexpected no-op is not missed; "pass" treats it as a clean
189+
migration with no file changes. When empty (default), uses
190+
"error" — except under the deprecated `iterate-v0-minors`
191+
fallback, where it defaults to "pass" for backward
192+
compatibility.
193+
required: false
194+
default: ""
171195
iterate-v0-minors:
172196
description: >-
173-
When "true" (default), v0.x bumps iterate through each
174-
intermediate minor version. When "false", only the target
175-
version is run.
197+
Deprecated — use `version-iteration` instead. When "true",
198+
v0.x bumps iterate through each intermediate minor version.
199+
When "false", only the target version is run. When empty
200+
(default), the input is inactive. Mutually exclusive with
201+
`version-iteration`.
176202
required: false
177-
default: "true"
203+
default: ""
178204
python-version:
179205
description: >-
180206
Python version to use for running the migration script.
@@ -261,17 +287,24 @@ inputs:
261287

262288
outputs:
263289
migration_ran:
264-
description: Whether the migration script(s) executed.
265-
value: ${{ steps.run-migration.outputs.migration_ran }}
290+
description: >-
291+
Whether migration has been handled: "true" when script(s)
292+
executed, when no versions needed migrating and
293+
`if-no-iterations` is "pass", when a previous run already
294+
migrated this PR, or when the update is patch-only without
295+
`version-iteration`. "false" otherwise.
296+
value: ${{ steps.resolve-migration-ran.outputs.migration_ran }}
266297
overall_exit:
267298
description: >-
268299
Consolidated exit code: "0" if all scripts succeeded, "1" if
269300
any required intervention.
270301
value: ${{ steps.run-migration.outputs.overall_exit }}
271302
needs_migration:
272303
description: >-
273-
Whether a migration was needed: "true" for minor/major bumps,
274-
"false" for patch-only.
304+
Whether the update entered the migration flow: "true" for
305+
minor/major bumps (always) and patch bumps (when
306+
version-iteration is set), "false" for patch-only bumps
307+
without version-iteration.
275308
value: ${{ steps.check-update.outputs.needs_migration }}
276309
commit_made:
277310
description: Whether the migration produced a commit.
@@ -293,6 +326,9 @@ runs:
293326
AUTO_MERGE_ON_CHANGES: ${{ inputs.auto-merge-on-changes }}
294327
SCRIPT_URL_TEMPLATE: ${{ inputs.script-url-template }}
295328
MIGRATION_SCRIPT: ${{ inputs.migration-script }}
329+
VERSION_ITERATION: ${{ inputs.version-iteration }}
330+
IF_NO_ITERATIONS: ${{ inputs.if-no-iterations }}
331+
ITERATE_V0_MINORS: ${{ inputs.iterate-v0-minors }}
296332
run: |
297333
if [ -n "$SCRIPT_URL_TEMPLATE" ] && [ -n "$MIGRATION_SCRIPT" ]; then
298334
echo "::error::\`script-url-template\` and \`migration-script\` are" \
@@ -312,6 +348,33 @@ runs:
312348
exit 1
313349
fi
314350
351+
if [ -n "$VERSION_ITERATION" ]; then
352+
case "$VERSION_ITERATION" in
353+
false|major|minor|patch) ;;
354+
*)
355+
echo "::error::\`version-iteration\` must be one of:" \
356+
"false, major, minor, patch."
357+
exit 1
358+
;;
359+
esac
360+
fi
361+
362+
if [ -n "$VERSION_ITERATION" ] && [ -n "$ITERATE_V0_MINORS" ]; then
363+
echo "::error::\`version-iteration\` and \`iterate-v0-minors\` are" \
364+
"mutually exclusive — remove \`iterate-v0-minors\` when using" \
365+
"\`version-iteration\`."
366+
exit 1
367+
fi
368+
369+
case "$IF_NO_ITERATIONS" in
370+
""|error|pass) ;;
371+
*)
372+
echo "::error::\`if-no-iterations\` must be one of:" \
373+
"error, pass."
374+
exit 1
375+
;;
376+
esac
377+
315378
# ── 2. Determine current state from PR labels ──────────────────────
316379
#
317380
# Read the PR labels to figure out whether the migration has already
@@ -423,9 +486,16 @@ runs:
423486

424487
# ── 6. Decide whether a migration is needed ────────────────────────
425488
#
426-
# Patch-only bumps never need migration. Versions are resolved
427-
# from updated-dependencies-json for both single and grouped
428-
# updates.
489+
# Versions are resolved from updated-dependencies-json for both
490+
# single and grouped updates.
491+
#
492+
# When version-iteration is NOT set (backward-compat path), patch-
493+
# only bumps short-circuit here — run-migration.sh never runs and
494+
# handle-patch-update.sh handles them instead.
495+
#
496+
# When version-iteration IS set, all update types — including
497+
# patch — flow through run-migration.sh so that the configured
498+
# iteration strategy is honoured.
429499
- name: Check update type
430500
if: >-
431501
steps.check-status.outputs.already_migrated == 'false'
@@ -437,53 +507,9 @@ runs:
437507
env:
438508
UPDATE_TYPE: ${{ steps.dependabot-metadata.outputs.update-type }}
439509
DEPS_JSON: ${{ steps.dependabot-metadata.outputs.updated-dependencies-json }}
510+
VERSION_ITERATION: ${{ inputs.version-iteration }}
440511
run: |
441-
if ! command -v jq >/dev/null 2>&1; then
442-
echo "::error::jq is required to parse updated-dependencies-json."
443-
exit 1
444-
fi
445-
446-
OLD_VERSIONS=$(echo "$DEPS_JSON" \
447-
| jq -r '.[].prevVersion | select(. != "" and . != null)')
448-
NEW_VERSIONS=$(echo "$DEPS_JSON" \
449-
| jq -r '.[].newVersion | select(. != "" and . != null)')
450-
451-
if [ -z "$OLD_VERSIONS" ] || [ -z "$NEW_VERSIONS" ]; then
452-
echo "::error::Could not resolve versions from updated-dependencies-json."
453-
echo "::error::updated-dependencies-json: $DEPS_JSON"
454-
exit 1
455-
fi
456-
457-
OLD_VERSION=$(echo "$OLD_VERSIONS" | sort -V | head -1)
458-
NEW_VERSION=$(echo "$NEW_VERSIONS" | sort -V | tail -1)
459-
echo "Resolved update range: ${OLD_VERSION} → ${NEW_VERSION}"
460-
461-
if [ -z "$UPDATE_TYPE" ]; then
462-
if echo "$DEPS_JSON" \
463-
| jq -e '.[] | select(.updateType == "version-update:semver-major")' \
464-
>/dev/null; then
465-
UPDATE_TYPE="version-update:semver-major"
466-
elif echo "$DEPS_JSON" \
467-
| jq -e '.[] | select(.updateType == "version-update:semver-minor")' \
468-
>/dev/null; then
469-
UPDATE_TYPE="version-update:semver-minor"
470-
else
471-
UPDATE_TYPE="version-update:semver-patch"
472-
fi
473-
fi
474-
475-
{
476-
echo "update_type=$UPDATE_TYPE"
477-
echo "old_version=$OLD_VERSION"
478-
echo "new_version=$NEW_VERSION"
479-
} >>"$GITHUB_OUTPUT"
480-
481-
if [ "$UPDATE_TYPE" = "version-update:semver-patch" ]; then
482-
echo "needs_migration=false" >>"$GITHUB_OUTPUT"
483-
echo "Patch update only — no migration needed."
484-
else
485-
echo "needs_migration=true" >>"$GITHUB_OUTPUT"
486-
fi
512+
bash "${{ github.action_path }}/scripts/check-update.sh"
487513
488514
# ── 7. Checkout the PR branch so the migration can modify files ────
489515
- name: Checkout PR branch
@@ -519,6 +545,8 @@ runs:
519545
UPDATED_DEPENDENCIES_JSON: ${{ steps.dependabot-metadata.outputs.updated-dependencies-json }}
520546
SCRIPT_URL_TEMPLATE: ${{ inputs.script-url-template }}
521547
MIGRATION_SCRIPT: ${{ inputs.migration-script }}
548+
VERSION_ITERATION: ${{ inputs.version-iteration }}
549+
IF_NO_ITERATIONS: ${{ inputs.if-no-iterations }}
522550
ITERATE_V0_MINORS: ${{ inputs.iterate-v0-minors }}
523551
MIGRATION_TOKEN_INPUT: ${{ inputs.migration-token }}
524552
ACTION_PATH: ${{ github.action_path }}
@@ -851,3 +879,29 @@ runs:
851879
${{ inputs.auto-merged-label-description }}
852880
REPORT_TITLE: ${{ inputs.report-title || github.workflow }}
853881
run: "${{ github.action_path }}/scripts/handle-patch-update.sh"
882+
883+
# ── 23. Resolve migration_ran ───────────────────────────────────────
884+
#
885+
# Runs with `always()` so the output is set in every code path:
886+
# migration ran, no-iteration pass/error, patch-only update, or
887+
# already-migrated re-trigger. Internal steps (10-22) still gate
888+
# on steps.run-migration.outputs.migration_ran; this step only
889+
# feeds the external output.
890+
- name: Resolve migration_ran
891+
id: resolve-migration-ran
892+
if: always()
893+
shell: bash
894+
env:
895+
SCRIPT_MIGRATION_RAN: ${{ steps.run-migration.outputs.migration_ran }}
896+
ALREADY_MIGRATED: ${{ steps.check-status.outputs.already_migrated }}
897+
NEEDS_MIGRATION: ${{ steps.check-update.outputs.needs_migration }}
898+
run: |
899+
if [ -n "$SCRIPT_MIGRATION_RAN" ]; then
900+
echo "migration_ran=$SCRIPT_MIGRATION_RAN" >>"$GITHUB_OUTPUT"
901+
elif [ "$ALREADY_MIGRATED" = "true" ]; then
902+
echo "migration_ran=true" >>"$GITHUB_OUTPUT"
903+
elif [ "$NEEDS_MIGRATION" = "false" ]; then
904+
echo "migration_ran=true" >>"$GITHUB_OUTPUT"
905+
else
906+
echo "migration_ran=false" >>"$GITHUB_OUTPUT"
907+
fi

scripts/check-update.sh

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env bash
2+
# License: MIT
3+
# Copyright © 2026 Frequenz Energy-as-a-Service GmbH
4+
#
5+
# Decide whether a migration is needed based on the Dependabot
6+
# update metadata.
7+
#
8+
# Resolves the old/new version range from the updated-dependencies
9+
# JSON and determines the update type. When version-iteration is
10+
# NOT set (backward-compat path), patch-only bumps short-circuit
11+
# with needs_migration=false so that handle-patch-update.sh can
12+
# handle them. When version-iteration IS set, all update types
13+
# — including patch — are forwarded to run-migration.sh.
14+
#
15+
# Expected environment variables (set by the composite action):
16+
# UPDATE_TYPE – update type from dependabot/fetch-metadata
17+
# (may be empty for grouped updates)
18+
# DEPS_JSON – updated-dependencies-json from
19+
# dependabot/fetch-metadata
20+
# VERSION_ITERATION – iteration mode (empty when not set)
21+
22+
set -euo pipefail
23+
24+
if ! command -v jq >/dev/null 2>&1; then
25+
echo "::error::jq is required to parse updated-dependencies-json."
26+
exit 1
27+
fi
28+
29+
OLD_VERSIONS=$(echo "$DEPS_JSON" \
30+
| jq -r '.[].prevVersion | select(. != "" and . != null)')
31+
NEW_VERSIONS=$(echo "$DEPS_JSON" \
32+
| jq -r '.[].newVersion | select(. != "" and . != null)')
33+
34+
if [ -z "$OLD_VERSIONS" ] || [ -z "$NEW_VERSIONS" ]; then
35+
echo "::error::Could not resolve versions from updated-dependencies-json."
36+
echo "::error::updated-dependencies-json: $DEPS_JSON"
37+
exit 1
38+
fi
39+
40+
OLD_VERSION=$(echo "$OLD_VERSIONS" | sort -V | head -1)
41+
NEW_VERSION=$(echo "$NEW_VERSIONS" | sort -V | tail -1)
42+
echo "Resolved update range: ${OLD_VERSION}${NEW_VERSION}"
43+
44+
if [ -z "$UPDATE_TYPE" ]; then
45+
if echo "$DEPS_JSON" \
46+
| jq -e '.[] | select(.updateType == "version-update:semver-major")' \
47+
>/dev/null; then
48+
UPDATE_TYPE="version-update:semver-major"
49+
elif echo "$DEPS_JSON" \
50+
| jq -e '.[] | select(.updateType == "version-update:semver-minor")' \
51+
>/dev/null; then
52+
UPDATE_TYPE="version-update:semver-minor"
53+
else
54+
UPDATE_TYPE="version-update:semver-patch"
55+
fi
56+
fi
57+
58+
{
59+
echo "update_type=$UPDATE_TYPE"
60+
echo "old_version=$OLD_VERSION"
61+
echo "new_version=$NEW_VERSION"
62+
} >>"$GITHUB_OUTPUT"
63+
64+
if [ -z "$VERSION_ITERATION" ] \
65+
&& [ "$UPDATE_TYPE" = "version-update:semver-patch" ]; then
66+
echo "needs_migration=false" >>"$GITHUB_OUTPUT"
67+
echo "Patch update only — no migration needed."
68+
else
69+
echo "needs_migration=true" >>"$GITHUB_OUTPUT"
70+
fi

0 commit comments

Comments
 (0)