project-auto-wizard 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +1 -0
  2. package/README.ja.md +5 -1
  3. package/README.ko.md +5 -1
  4. package/README.md +6 -2
  5. package/README.zh-CN.md +5 -1
  6. package/package.json +1 -1
  7. package/payload/scripts/messages.py +44 -6
  8. package/payload/scripts/version_manager.py +76 -3
  9. package/payload/version.yml.template +5 -4
  10. package/payload/workflows/common/PROJECT-COMMON-AI-PR-SUMMARY.yaml +7 -7
  11. package/payload/workflows/common/PROJECT-COMMON-AUTO-CHANGELOG-CONTROL.yaml +111 -57
  12. package/payload/workflows/common/PROJECT-COMMON-README-VERSION-UPDATE.yaml +10 -1
  13. package/payload/workflows/common/PROJECT-COMMON-RELEASE-PUBLISH.yaml +13 -3
  14. package/payload/workflows/common/PROJECT-COMMON-VERSION-CONTROL.yaml +10 -1
  15. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-PLAYSTORE-CICD.yaml +2 -2
  16. package/payload/workflows/flutter/PROJECT-FLUTTER-ANDROID-TEST-APK.yaml +10 -10
  17. package/payload/workflows/flutter/PROJECT-FLUTTER-APP-BUILD-TRIGGER.yaml +10 -10
  18. package/payload/workflows/flutter/PROJECT-FLUTTER-CI.yaml +14 -12
  19. package/payload/workflows/flutter/PROJECT-FLUTTER-IOS-TEST-TESTFLIGHT.yaml +35 -18
  20. package/payload/workflows/go/PROJECT-GO-CI.yaml +3 -1
  21. package/payload/workflows/go/PROJECT-GO-PR-PREVIEW.yaml +81 -61
  22. package/payload/workflows/python/PROJECT-PYTHON-CI.yaml +3 -1
  23. package/payload/workflows/python/PROJECT-PYTHON-PR-PREVIEW.yaml +81 -61
  24. package/payload/workflows/react/PROJECT-REACT-CI.yaml +3 -2
  25. package/payload/workflows/spring/PROJECT-SPRING-CI.yml +6 -5
  26. package/payload/workflows/spring/server-deploy/PROJECT-SPRING-PR-PREVIEW.yaml +86 -68
  27. package/src/commands/doctor.js +14 -24
  28. package/src/commands/dry-run.js +5 -3
  29. package/src/commands/full.js +6 -6
  30. package/src/commands/interactive.js +16 -3
  31. package/src/commands/status.js +9 -2
  32. package/src/core/copy/gitignore.js +3 -3
  33. package/src/core/copy/readme.js +11 -10
  34. package/src/core/detect-fs.js +9 -4
  35. package/src/core/options.js +36 -0
  36. package/src/core/version-yml.js +43 -13
  37. package/src/i18n/catalog/en/cli.js +1 -0
  38. package/src/i18n/catalog/en/commands.js +4 -1
  39. package/src/i18n/catalog/en/core.js +1 -0
  40. package/src/i18n/catalog/en/core2.js +2 -0
  41. package/src/i18n/catalog/en/ui.js +5 -0
  42. package/src/i18n/catalog/ko/cli.js +1 -0
  43. package/src/i18n/catalog/ko/commands.js +4 -1
  44. package/src/i18n/catalog/ko/core.js +1 -0
  45. package/src/i18n/catalog/ko/core2.js +2 -0
  46. package/src/i18n/catalog/ko/ui.js +5 -0
  47. package/src/index.js +25 -7
  48. package/src/ui/prompts.js +11 -1
  49. package/src/ui/summary.js +15 -6
@@ -31,7 +31,13 @@
31
31
  #
32
32
  # With version.yml release_automerge: false, step 5 and the wait job are
33
33
  # skipped: the PR is renamed to the release-confirm subject and one guidance
34
- # comment asks the maintainer to merge it with a merge commit (not squash or rebase).
34
+ # comment asks the maintainer to merge it with a merge commit. Squash and rebase merges
35
+ # put the [skip ci] of the confirm commit into the merge result, so GitHub skips the push
36
+ # workflows and PROJECT-RELEASE-PUBLISH has to be run manually for them.
37
+ #
38
+ # When step 5 is rejected by branch protection (required review or checks), the run
39
+ # fails with the cause and the options, and the PR gets the same rename and a guidance
40
+ # comment, so a merge the maintainer makes by hand still publishes the release.
35
41
  #
36
42
  # The merge-commit subject carries the release-confirm pattern
37
43
  # "chore(release):" — keep in sync with PROJECT-COMMON-VERSION-CONTROL
@@ -152,7 +158,7 @@ jobs:
152
158
  run: |
153
159
  [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
154
160
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
155
- SEMVER_AUTO=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?semver_auto:\s*\"?(true|false)", t, re.S); print(m.group(1) if m else "false")' 2>/dev/null || echo "false")
161
+ SEMVER_AUTO=$(python3 "$GITHUB_WORKSPACE/.github/scripts/version_manager.py" option semver_auto --default false || echo "false")
156
162
  echo "semver_auto=$SEMVER_AUTO" >> $GITHUB_OUTPUT
157
163
  echo "$(m wf_common.semver_auto_option semver_auto="$SEMVER_AUTO")"
158
164
 
@@ -163,18 +169,20 @@ jobs:
163
169
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
164
170
  # scoped to the metadata: -> template: -> options: chain so an
165
171
  # unrelated "copilot_ai:" key can never hijack the value
166
- COPILOT_AI=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?copilot_ai:\s*\"?(true|false)", t, re.S); print(m.group(1) if m else "false")' 2>/dev/null || echo "false")
172
+ COPILOT_AI=$(python3 "$GITHUB_WORKSPACE/.github/scripts/version_manager.py" option copilot_ai --default false || echo "false")
167
173
  echo "copilot_ai=$COPILOT_AI" >> $GITHUB_OUTPUT
168
174
  echo "$(m wf_common.copilot_ai_option copilot_ai="$COPILOT_AI")"
169
175
 
170
176
  - name: Read release_automerge option from version.yml
171
177
  id: automerge_option
172
178
  run: |
173
- # Same metadata: -> template: -> options: scope as copilot_ai. A missing key means ON:
174
- # installs from before the option existed already automerge, and that must not change.
175
- AUTOMERGE=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?^\s+release_automerge:\s*\"?(true|false)", t, re.S|re.M); print(m.group(1) if m else "true")' 2>/dev/null || echo "true")
179
+ # Read with the same rule as the CLI (quotes, case and a trailing comment are fine; only true/false count).
180
+ # A missing key means ON: installs from before the option existed already automerge, and that must not change.
181
+ # A key that is present but unrecognized (e.g. maybe, off, empty) is reported and treated as false,
182
+ # so a typo never switches automerge back on for someone who turned it off.
183
+ AUTOMERGE=$(python3 "$GITHUB_WORKSPACE/.github/scripts/version_manager.py" option release_automerge --default true || echo "true")
176
184
  echo "release_automerge=$AUTOMERGE" >> $GITHUB_OUTPUT
177
- echo "release_automerge option: $AUTOMERGE"
185
+ echo "release_automerge=$AUTOMERGE"
178
186
 
179
187
  - name: Install Copilot CLI
180
188
  if: steps.copilot_options.outputs.copilot_ai == 'true'
@@ -187,11 +195,15 @@ jobs:
187
195
  [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
188
196
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
189
197
  git fetch origin {{MAIN_BRANCH}}
190
- git log --pretty=%s "origin/{{MAIN_BRANCH}}..HEAD" > commits.txt
198
+ # --cherry-pick --right-only drops commits whose change already exists on {{MAIN_BRANCH}} under another hash
199
+ # (a rebase-merged release PR), but only while {{DEVELOP_BRANCH}} has not merged {{MAIN_BRANCH}} back: after that merge the
200
+ # rebased copies are on {{DEVELOP_BRANCH}} too, nothing is left to compare with, and they are listed again.
201
+ # A squash merge leaves no matching patch at all and cannot be told apart this way.
202
+ git log --cherry-pick --right-only --pretty=%s "origin/{{MAIN_BRANCH}}...HEAD" > commits.txt
191
203
  # Body-footer BREAKING CHANGE: lines are appended after the subject list, one per
192
204
  # line. The script reads each line as one entry, so passing the whole body (%B)
193
205
  # would turn description lines into change entries.
194
- git log --pretty=%b "origin/{{MAIN_BRANCH}}..HEAD" | grep -E '^BREAKING[ -]CHANGE[[:space:]]*:' >> commits.txt || true
206
+ git log --cherry-pick --right-only --pretty=%b "origin/{{MAIN_BRANCH}}...HEAD" | grep -E '^BREAKING[ -]CHANGE[[:space:]]*:' >> commits.txt || true
195
207
  git diff --stat "origin/{{MAIN_BRANCH}}...HEAD" > _full_diff_stat.txt
196
208
  if [ "$(wc -l < _full_diff_stat.txt)" -gt 49 ]; then
197
209
  { head -49 _full_diff_stat.txt; tail -1 _full_diff_stat.txt; } > diff_stat.txt
@@ -209,7 +221,7 @@ jobs:
209
221
  run: |
210
222
  [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
211
223
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
212
- git log --pretty=%H "origin/{{MAIN_BRANCH}}..HEAD" > commit_shas.txt
224
+ git log --cherry-pick --right-only --pretty=%H "origin/{{MAIN_BRANCH}}...HEAD" > commit_shas.txt
213
225
 
214
226
  gh pr list --state merged --base {{DEVELOP_BRANCH}} \
215
227
  --json number,headRefName,mergeCommit --limit 200 > merged_prs.json
@@ -321,7 +333,7 @@ jobs:
321
333
  FALLBACK_REASON=$(python3 -c 'import html,json,sys; print(html.escape((json.loads(sys.argv[1]).get("fallback_reason") or "")[:200]))' "$SUMMARY_RESULT" 2>/dev/null || echo "")
322
334
  ENGINE_LINE="$ENGINE${FALLBACK_REASON:+ ($FALLBACK_REASON)}"
323
335
  echo "$(m wf_changelog.summary_engine engine_line="$ENGINE_LINE")"
324
- echo "engine: $ENGINE_LINE" >> "$GITHUB_STEP_SUMMARY"
336
+ m wf_aisum.engine_label engine="$ENGINE_LINE" >> "$GITHUB_STEP_SUMMARY"
325
337
  echo "engine=$ENGINE" >> $GITHUB_OUTPUT
326
338
  echo "fallback_reason=$FALLBACK_REASON" >> $GITHUB_OUTPUT
327
339
 
@@ -343,11 +355,11 @@ jobs:
343
355
 
344
356
  {
345
357
  echo "$MARKER"
346
- echo "📋 **PR Summary (project-auto-wizard)**"
358
+ m wf_aisum.summary_heading
347
359
  echo ""
348
360
  cat summary.md
349
361
  echo ""
350
- echo "<sub>engine: ${ENGINE:-unknown}${FALLBACK_REASON:+ ($FALLBACK_REASON)}</sub>"
362
+ echo "<sub>$(m wf_aisum.engine_label engine="${ENGINE:-unknown}${FALLBACK_REASON:+ ($FALLBACK_REASON)}")</sub>"
351
363
  } > comment_body.md
352
364
 
353
365
  python3 -c 'import json; print(json.dumps({"body": open("comment_body.md", encoding="utf-8").read()}))' > comment_payload.json
@@ -417,6 +429,7 @@ jobs:
417
429
  git commit -m "chore(version): confirm v${VERSION} and update release docs [skip ci]"
418
430
 
419
431
  # Race-condition guard: pull-rebase then push (up to 3 retries)
432
+ PUSH_ERR="${RUNNER_TEMP:-/tmp}/push_err.txt"
420
433
  MAX_RETRIES=3
421
434
  RETRY_COUNT=0
422
435
  PUSH_SUCCESS=false
@@ -425,11 +438,18 @@ jobs:
425
438
  RETRY_COUNT=$((RETRY_COUNT + 1))
426
439
  echo "$(m wf_common.push_attempt retry_count="$RETRY_COUNT" max_retries="$MAX_RETRIES")"
427
440
 
428
- if git push origin HEAD:$HEAD_BRANCH; then
441
+ if git push origin HEAD:$HEAD_BRANCH 2> "$PUSH_ERR"; then
442
+ cat "$PUSH_ERR" >&2
429
443
  PUSH_SUCCESS=true
430
444
  echo "$(m wf_changelog.docs_pushed head_branch="$HEAD_BRANCH")"
431
445
  break
432
446
  else
447
+ cat "$PUSH_ERR" >&2
448
+ # A branch-protection rejection is a standing rule, not a race: retrying cannot succeed
449
+ if grep -qiE "GH006|GH013|protected branch|repository rule violations" "$PUSH_ERR"; then
450
+ echo "::error::$(m wf_common.push_protected branch="$HEAD_BRANCH")"
451
+ exit 1
452
+ fi
433
453
  echo "$(m wf_common.push_failed_syncing)"
434
454
  if git pull --rebase origin $HEAD_BRANCH; then
435
455
  echo "$(m wf_common.rebase_ok)"
@@ -447,48 +467,8 @@ jobs:
447
467
  fi
448
468
  fi
449
469
 
450
- - name: Prepare manual release merge (release_automerge=false)
451
- if: steps.automerge_option.outputs.release_automerge == 'false'
452
- env:
453
- GH_TOKEN: ${{ secrets.WORKFLOW_PAT || github.token }}
454
- run: |
455
- [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
456
- m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
457
- PR_NUMBER=${{ github.event.pull_request.number }}
458
- VERSION="${{ steps.bump.outputs.new_version }}"
459
-
460
- # The PR title carries the release-confirm pattern, so a human merge commit (its message includes the PR title)
461
- # still passes the RELEASE-PUBLISH gate. A failed rename only warns: the run must not fail here.
462
- SUBJECT="chore(release): v${VERSION} (PR #${PR_NUMBER})"
463
- # The REST endpoint is used instead of `gh pr edit`, which goes through GraphQL and can fail on token scopes.
464
- RENAMED=true
465
- if gh api -X PATCH "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" -f title="$SUBJECT" > /dev/null; then
466
- echo "$(m wf_changelog.automerge_off subject="$SUBJECT")"
467
- else
468
- RENAMED=false
469
- echo "::warning::$(m wf_changelog.automerge_rename_failed pr_number="$PR_NUMBER")"
470
- fi
471
-
472
- # One guidance comment per PR: re-runs (synchronize) find it by its marker and leave it alone.
473
- # If the comment list cannot be read we cannot tell whether it exists, so skip rather than post a duplicate.
474
- MARKER='<!-- release-automerge-off -->'
475
- if ! COMMENT_IDS=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
476
- --jq ".[] | select(.body | contains(\"$MARKER\")) | .id"); then
477
- echo "::warning::$(m wf_changelog.automerge_off_list_failed pr_number="$PR_NUMBER")"
478
- elif [ -z "$COMMENT_IDS" ]; then
479
- BODY="$MARKER
480
- $(m wf_changelog.automerge_off_comment)"
481
- # A failed rename means the merge message will not carry the release marker unless the maintainer sets it.
482
- if [ "$RENAMED" = false ]; then
483
- BODY="$BODY
484
-
485
- $(m wf_changelog.automerge_off_rename_hint subject="$SUBJECT")"
486
- fi
487
- gh pr comment "$PR_NUMBER" --body "$BODY" \
488
- || echo "::warning::$(m wf_changelog.automerge_rename_failed pr_number="$PR_NUMBER")"
489
- fi
490
-
491
470
  - name: Enable automerge
471
+ id: automerge
492
472
  if: steps.automerge_option.outputs.release_automerge != 'false'
493
473
  env:
494
474
  # With a PAT the merge push triggers downstream workflows
@@ -536,7 +516,7 @@ jobs:
536
516
  # date" rule, merge base into head to catch up (a mere propagation delay is
537
517
  # rejected as "no new commits" and simply retried)
538
518
  gh api -X PUT "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/update-branch" > /dev/null 2>&1 \
539
- && echo "head branch updated from base" || true
519
+ && m wf_changelog.head_updated || true
540
520
  fi
541
521
  WAIT=$((ATTEMPT * 10))
542
522
  echo "$(m wf_changelog.merge_retry attempt="$ATTEMPT" max_attempts="$MAX_ATTEMPTS" wait="${WAIT}")"
@@ -553,11 +533,85 @@ jobs:
553
533
  elif [ "$ALLOW_MERGE_COMMIT" = "false" ]; then
554
534
  echo "::error::$(m wf_changelog.merge_commit_disabled)"
555
535
  else
556
- echo "::error::$(m wf_changelog.merge_gave_up max_attempts="$MAX_ATTEMPTS" allow_merge_commit="$ALLOW_MERGE_COMMIT")"
536
+ # Branch protection is a standing rule, not a propagation delay: name the cause and the options.
537
+ # Whatever the cause, the next step renames the PR so a merge you make yourself still publishes the release.
538
+ REVIEW_DECISION=$(gh pr view "$PR_NUMBER" --json reviewDecision -q .reviewDecision 2>/dev/null || echo "")
539
+ MERGE_STATE=$(gh pr view "$PR_NUMBER" --json mergeStateStatus -q .mergeStateStatus 2>/dev/null || echo "")
540
+ if [ "$REVIEW_DECISION" = "REVIEW_REQUIRED" ] || [ "$REVIEW_DECISION" = "CHANGES_REQUESTED" ]; then
541
+ echo "merge_blocked=review" >> "$GITHUB_OUTPUT"
542
+ echo "::error::$(m wf_changelog.merge_blocked_review prod="{{MAIN_BRANCH}}")"
543
+ elif [ "$MERGE_STATE" = "BLOCKED" ] || grep -qi "base branch policy" "$ERR_FILE"; then
544
+ echo "merge_blocked=policy" >> "$GITHUB_OUTPUT"
545
+ echo "::error::$(m wf_changelog.merge_blocked_policy prod="{{MAIN_BRANCH}}")"
546
+ else
547
+ echo "merge_blocked=other" >> "$GITHUB_OUTPUT"
548
+ echo "::error::$(m wf_changelog.merge_gave_up max_attempts="$MAX_ATTEMPTS" allow_merge_commit="$ALLOW_MERGE_COMMIT")"
549
+ fi
557
550
  fi
558
551
  exit 1
559
552
  fi
560
553
 
554
+ # Runs for release_automerge=false (nothing merges by itself) and when automerge was rejected by a standing rule
555
+ # such as branch protection (a person has to merge). A conflict or a disabled merge-commit setting is not covered: those
556
+ # need a fix first, and the next run retries the automerge.
557
+ # The step never merges; it only prepares the PR so a human merge still passes the RELEASE-PUBLISH gate.
558
+ - name: Prepare manual release merge
559
+ if: (success() && steps.automerge_option.outputs.release_automerge == 'false') || (failure() && steps.automerge.outcome == 'failure' && steps.automerge.outputs.merge_blocked != '')
560
+ env:
561
+ GH_TOKEN: ${{ secrets.WORKFLOW_PAT || github.token }}
562
+ MERGE_BLOCKED: ${{ steps.automerge.outputs.merge_blocked }}
563
+ run: |
564
+ [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
565
+ m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
566
+ PR_NUMBER=${{ github.event.pull_request.number }}
567
+ VERSION="${{ steps.bump.outputs.new_version }}"
568
+
569
+ # The PR title carries the release-confirm pattern, so a human merge commit (its message includes the PR title)
570
+ # still passes the RELEASE-PUBLISH gate. A failed rename only warns: this step must not fail the run.
571
+ SUBJECT="chore(release): v${VERSION} (PR #${PR_NUMBER})"
572
+ # The REST endpoint is used instead of `gh pr edit`, which goes through GraphQL and can fail on token scopes.
573
+ RENAMED=true
574
+ if gh api -X PATCH "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}" -f title="$SUBJECT" > /dev/null; then
575
+ if [ "${{ steps.automerge_option.outputs.release_automerge }}" = "false" ]; then
576
+ echo "$(m wf_changelog.automerge_off subject="$SUBJECT")"
577
+ else
578
+ echo "$(m wf_changelog.automerge_blocked_renamed subject="$SUBJECT")"
579
+ fi
580
+ else
581
+ RENAMED=false
582
+ echo "::warning::$(m wf_changelog.automerge_rename_failed pr_number="$PR_NUMBER")"
583
+ fi
584
+
585
+ # One guidance comment per PR: re-runs (synchronize) find it by its marker and leave it alone.
586
+ # If the comment list cannot be read we cannot tell whether it exists, so skip rather than post a duplicate.
587
+ if [ "${{ steps.automerge_option.outputs.release_automerge }}" = "false" ]; then
588
+ MARKER='<!-- release-automerge-off -->'
589
+ CONTENT="$(m wf_changelog.automerge_off_comment)"
590
+ else
591
+ MARKER='<!-- release-merge-blocked -->'
592
+ case "${MERGE_BLOCKED:-other}" in
593
+ review) REASON="$(m wf_changelog.merge_blocked_reason_review)" ;;
594
+ policy) REASON="$(m wf_changelog.merge_blocked_reason_policy)" ;;
595
+ *) REASON="$(m wf_changelog.merge_blocked_reason_other)" ;;
596
+ esac
597
+ CONTENT="$(m wf_changelog.merge_blocked_comment reason="$REASON")"
598
+ fi
599
+ if ! COMMENT_IDS=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
600
+ --jq ".[] | select(.body | contains(\"$MARKER\")) | .id"); then
601
+ echo "::warning::$(m wf_changelog.automerge_off_list_failed pr_number="$PR_NUMBER")"
602
+ elif [ -z "$COMMENT_IDS" ]; then
603
+ BODY="$MARKER
604
+ $CONTENT"
605
+ # A failed rename means the merge message will not carry the release marker unless the maintainer sets it.
606
+ if [ "$RENAMED" = false ]; then
607
+ BODY="$BODY
608
+
609
+ $(m wf_changelog.automerge_off_rename_hint subject="$SUBJECT")"
610
+ fi
611
+ gh pr comment "$PR_NUMBER" --body "$BODY" \
612
+ || echo "::warning::$(m wf_changelog.automerge_rename_failed pr_number="$PR_NUMBER")"
613
+ fi
614
+
561
615
  wait-for-merge-and-trigger-release:
562
616
  name: Wait for merge and trigger RELEASE-PUBLISH (works without WORKFLOW_PAT)
563
617
  needs: changelog-and-merge
@@ -302,6 +302,7 @@ jobs:
302
302
 
303
303
  # >>> push-with-retry
304
304
  # Race-condition guard: fetch -> rebase -> push, retried
305
+ PUSH_ERR="${RUNNER_TEMP:-/tmp}/push_err.txt"
305
306
  MAX_RETRIES=5
306
307
  RETRY_COUNT=0
307
308
  PUSH_SUCCESS=false
@@ -310,12 +311,20 @@ jobs:
310
311
  RETRY_COUNT=$((RETRY_COUNT + 1))
311
312
  echo "$(m wf_common.push_attempt retry_count="$RETRY_COUNT" max_retries="$MAX_RETRIES")"
312
313
 
313
- if git push origin HEAD:{{MAIN_BRANCH}}; then
314
+ if git push origin HEAD:{{MAIN_BRANCH}} 2> "$PUSH_ERR"; then
315
+ cat "$PUSH_ERR" >&2
314
316
  PUSH_SUCCESS=true
315
317
  echo "$(m wf_readme.pushed)"
316
318
  break
317
319
  fi
318
320
 
321
+ cat "$PUSH_ERR" >&2
322
+ # A branch-protection rejection is a standing rule, not a race: retrying cannot succeed
323
+ if grep -qiE "GH006|GH013|protected branch|repository rule violations" "$PUSH_ERR"; then
324
+ echo "::error::$(m wf_common.push_protected branch="{{MAIN_BRANCH}}")"
325
+ exit 1
326
+ fi
327
+
319
328
  echo "$(m wf_common.push_failed_syncing)"
320
329
  git fetch origin {{MAIN_BRANCH}}
321
330
  if git rebase origin/{{MAIN_BRANCH}}; then
@@ -180,7 +180,7 @@ jobs:
180
180
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
181
181
  # scoped to the metadata: -> template: -> options: chain so an
182
182
  # unrelated "semver_auto:" key can never hijack the value
183
- SEMVER_AUTO=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?semver_auto:\s*\"?(true|false)", t, re.S); print(m.group(1) if m else "false")' 2>/dev/null || echo "false")
183
+ SEMVER_AUTO=$(python3 "$GITHUB_WORKSPACE/.github/scripts/version_manager.py" option semver_auto --default false || echo "false")
184
184
  echo "semver_auto=$SEMVER_AUTO" >> $GITHUB_OUTPUT
185
185
  echo "$(m wf_common.semver_auto_option semver_auto="$SEMVER_AUTO")"
186
186
 
@@ -195,7 +195,7 @@ jobs:
195
195
  m() { python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" get "$@"; }
196
196
  # scoped to the metadata: -> template: -> options: chain so an
197
197
  # unrelated "copilot_ai:" key can never hijack the value
198
- COPILOT_AI=$(python3 -c 'import re; t=open("version.yml",encoding="utf-8").read(); m=re.search(r"metadata:.*?template:.*?options:.*?copilot_ai:\s*\"?(true|false)", t, re.S); print(m.group(1) if m else "false")' 2>/dev/null || echo "false")
198
+ COPILOT_AI=$(python3 "$GITHUB_WORKSPACE/.github/scripts/version_manager.py" option copilot_ai --default false || echo "false")
199
199
  echo "copilot_ai=$COPILOT_AI" >> $GITHUB_OUTPUT
200
200
  echo "$(m wf_common.copilot_ai_option copilot_ai="$COPILOT_AI")"
201
201
 
@@ -314,6 +314,7 @@ jobs:
314
314
  # README-only commit (date refresh) touches the same version line and
315
315
  # conflicts on rebase; the remote README is kept and this run's version
316
316
  # line is reapplied, so a README conflict never blocks the release.
317
+ PUSH_ERR="${RUNNER_TEMP:-/tmp}/push_err.txt"
317
318
  MAX_RETRIES=5
318
319
  RETRY_COUNT=0
319
320
  PUSH_SUCCESS=false
@@ -322,12 +323,21 @@ jobs:
322
323
  RETRY_COUNT=$((RETRY_COUNT + 1))
323
324
  echo "$(m wf_common.push_attempt retry_count="$RETRY_COUNT" max_retries="$MAX_RETRIES")"
324
325
 
325
- if git push origin HEAD:{{MAIN_BRANCH}}; then
326
+ if git push origin HEAD:{{MAIN_BRANCH}} 2> "$PUSH_ERR"; then
327
+ cat "$PUSH_ERR" >&2
326
328
  PUSH_SUCCESS=true
327
329
  echo "$(m wf_release.docs_pushed)"
328
330
  break
329
331
  fi
330
332
 
333
+ cat "$PUSH_ERR" >&2
334
+ # A branch-protection rejection is a standing rule, not a race: retrying cannot succeed
335
+ if grep -qiE "GH006|GH013|protected branch|repository rule violations" "$PUSH_ERR"; then
336
+ echo "::error::$(m wf_common.push_protected branch="{{MAIN_BRANCH}}")"
337
+ echo "::error::$(m wf_release.push_protected_trunk branch="{{MAIN_BRANCH}}")"
338
+ exit 1
339
+ fi
340
+
331
341
  echo "$(m wf_common.push_failed_syncing)"
332
342
  git fetch origin {{MAIN_BRANCH}}
333
343
  if git rebase origin/{{MAIN_BRANCH}}; then
@@ -217,6 +217,7 @@ jobs:
217
217
  # README-only commit (date refresh) touches the same version line and
218
218
  # conflicts on rebase; the remote README is kept and this run's version
219
219
  # line is reapplied, so a README conflict never blocks the release.
220
+ PUSH_ERR="${RUNNER_TEMP:-/tmp}/push_err.txt"
220
221
  MAX_RETRIES=5
221
222
  RETRY_COUNT=0
222
223
  PUSH_SUCCESS=false
@@ -225,12 +226,20 @@ jobs:
225
226
  RETRY_COUNT=$((RETRY_COUNT + 1))
226
227
  echo "$(m wf_common.push_attempt retry_count="$RETRY_COUNT" max_retries="$MAX_RETRIES")"
227
228
 
228
- if git push origin HEAD:{{MAIN_BRANCH}}; then
229
+ if git push origin HEAD:{{MAIN_BRANCH}} 2> "$PUSH_ERR"; then
230
+ cat "$PUSH_ERR" >&2
229
231
  PUSH_SUCCESS=true
230
232
  echo "$(m wf_version.bump_pushed)"
231
233
  break
232
234
  fi
233
235
 
236
+ cat "$PUSH_ERR" >&2
237
+ # A branch-protection rejection is a standing rule, not a race: retrying cannot succeed
238
+ if grep -qiE "GH006|GH013|protected branch|repository rule violations" "$PUSH_ERR"; then
239
+ echo "::error::$(m wf_common.push_protected branch="{{MAIN_BRANCH}}")"
240
+ exit 1
241
+ fi
242
+
234
243
  echo "$(m wf_common.push_failed_syncing)"
235
244
  git fetch origin {{MAIN_BRANCH}}
236
245
  if git rebase origin/{{MAIN_BRANCH}}; then
@@ -90,7 +90,7 @@ jobs:
90
90
  [ -n "${!NAME}" ] || MISSING="$MISSING $NAME"
91
91
  done
92
92
  # The package name may be registered as a Variable instead of a Secret
93
- [ -n "$ANDROID_PACKAGE_NAME" ] || MISSING="$MISSING ANDROID_PACKAGE_NAME(Secret or Variable)"
93
+ [ -n "$ANDROID_PACKAGE_NAME" ] || MISSING="$MISSING ANDROID_PACKAGE_NAME(Secret/Variable)"
94
94
  if [ -n "$MISSING" ]; then
95
95
  echo "::error title=$(m flutter_a.precheck_missing_title)::$(m flutter_a.precheck_missing_body missing="$MISSING")"
96
96
  exit 1
@@ -678,7 +678,7 @@ jobs:
678
678
  echo "=========================================="
679
679
  m flutter_a.deploy_start_time cmd1="$(date -u '+%Y-%m-%d %H:%M:%S UTC')"
680
680
  m wf_flutter.run_number number="${{ github.run_number }}"
681
- echo "🔍 GitHub SHA: ${{ github.sha }}"
681
+ m wf_flutter.github_sha sha="${{ github.sha }}"
682
682
 
683
683
  # Initialize and validate environment variables
684
684
  VERSION_CODE="${{ needs.prepare-build.outputs.version_code }}"
@@ -109,7 +109,7 @@ jobs:
109
109
  rm -rf .paw-msg
110
110
  echo "PROJECT_AUTO_WIZARD_LANG=$LANG_CODE" >> "$GITHUB_ENV"
111
111
  echo "PAW_MESSAGES_PY=$RUNNER_TEMP/paw_messages.py" >> "$GITHUB_ENV"
112
- echo "PAW_MSG=$(PROJECT_AUTO_WIZARD_LANG=$LANG_CODE python3 "$RUNNER_TEMP/paw_messages.py" dump apk_test.)" >> "$GITHUB_ENV"
112
+ PROJECT_AUTO_WIZARD_LANG=$LANG_CODE python3 "$RUNNER_TEMP/paw_messages.py" dump apk_test. > "$RUNNER_TEMP/paw-msg.json"
113
113
 
114
114
  - name: Verify the branch exists up front
115
115
  id: verify_branch
@@ -122,7 +122,7 @@ jobs:
122
122
  with:
123
123
  github-token: ${{ secrets.GITHUB_TOKEN }}
124
124
  script: |
125
- const M = JSON.parse(process.env.PAW_MSG);
125
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
126
126
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
127
127
  const branchName = process.env.PAW_IN_GITHUB_EVENT_CLIENT_PAYLOAD_BRANCH_NAME;
128
128
  const prNumber = process.env.PAW_IN_GITHUB_EVENT_CLIENT_PAYLOAD_PR_NUMBER;
@@ -197,7 +197,7 @@ jobs:
197
197
  with:
198
198
  github-token: ${{ secrets.GITHUB_TOKEN }}
199
199
  script: |
200
- const M = JSON.parse(process.env.PAW_MSG);
200
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
201
201
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
202
202
  const row = (name, status, elapsed = '-') => `| ${msg('apk_test.step_' + name)} | ${msg('apk_test.st_' + status)} | ${elapsed} |`;
203
203
  const prNumber = parseInt(process.env.PAW_IN_GITHUB_EVENT_CLIENT_PAYLOAD_PR_NUMBER);
@@ -375,7 +375,7 @@ jobs:
375
375
  rm -rf .paw-msg
376
376
  echo "PROJECT_AUTO_WIZARD_LANG=$LANG_CODE" >> "$GITHUB_ENV"
377
377
  echo "PAW_MESSAGES_PY=$RUNNER_TEMP/paw_messages.py" >> "$GITHUB_ENV"
378
- echo "PAW_MSG=$(PROJECT_AUTO_WIZARD_LANG=$LANG_CODE python3 "$RUNNER_TEMP/paw_messages.py" dump apk_test.)" >> "$GITHUB_ENV"
378
+ PROJECT_AUTO_WIZARD_LANG=$LANG_CODE python3 "$RUNNER_TEMP/paw_messages.py" dump apk_test. > "$RUNNER_TEMP/paw-msg.json"
379
379
 
380
380
  - name: Checkout repository
381
381
  uses: actions/checkout@v7
@@ -543,7 +543,7 @@ jobs:
543
543
  with:
544
544
  github-token: ${{ secrets.GITHUB_TOKEN }}
545
545
  script: |
546
- const M = JSON.parse(process.env.PAW_MSG);
546
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
547
547
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
548
548
  const row = (name, status, elapsed = '-') => `| ${msg('apk_test.step_' + name)} | ${msg('apk_test.st_' + status)} | ${elapsed} |`;
549
549
  const commentId = parseInt(process.env.PAW_IN_NEEDS_PREPARE_TEST_BUILD_OUTPUTS_PROGRESS_COMMENT_ID);
@@ -690,7 +690,7 @@ jobs:
690
690
  with:
691
691
  github-token: ${{ secrets.GITHUB_TOKEN }}
692
692
  script: |
693
- const M = JSON.parse(process.env.PAW_MSG);
693
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
694
694
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
695
695
  const row = (name, status, elapsed = '-') => `| ${msg('apk_test.step_' + name)} | ${msg('apk_test.st_' + status)} | ${elapsed} |`;
696
696
  const commentId = parseInt(process.env.PAW_IN_NEEDS_PREPARE_TEST_BUILD_OUTPUTS_PROGRESS_COMMENT_ID);
@@ -820,7 +820,7 @@ jobs:
820
820
  PAW_IN_STEPS_PROGRESS_STEP2_OUTPUTS_END_TIME: ${{ steps.progress_step2.outputs.end_time }}
821
821
  with:
822
822
  script: |
823
- const M = JSON.parse(process.env.PAW_MSG);
823
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
824
824
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
825
825
  const step2EndTime = parseInt(process.env.PAW_IN_STEPS_PROGRESS_STEP2_OUTPUTS_END_TIME);
826
826
  const now = Date.now();
@@ -899,7 +899,7 @@ jobs:
899
899
  PAW_IN_STEPS_ARTIFACT_TIME_OUTPUTS_END_TIME: ${{ steps.artifact_time.outputs.end_time }}
900
900
  with:
901
901
  script: |
902
- const M = JSON.parse(process.env.PAW_MSG);
902
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
903
903
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
904
904
  const artifactEndTime = parseInt(process.env.PAW_IN_STEPS_ARTIFACT_TIME_OUTPUTS_END_TIME);
905
905
  const now = Date.now();
@@ -932,7 +932,7 @@ jobs:
932
932
  with:
933
933
  github-token: ${{ secrets.GITHUB_TOKEN }}
934
934
  script: |
935
- const M = JSON.parse(process.env.PAW_MSG);
935
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
936
936
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
937
937
  const row = (name, status, elapsed = '-') => `| ${msg('apk_test.step_' + name)} | ${msg('apk_test.st_' + status)} | ${elapsed} |`;
938
938
  const commentId = parseInt(process.env.PAW_IN_NEEDS_PREPARE_TEST_BUILD_OUTPUTS_PROGRESS_COMMENT_ID);
@@ -1059,7 +1059,7 @@ jobs:
1059
1059
  with:
1060
1060
  github-token: ${{ secrets.GITHUB_TOKEN }}
1061
1061
  script: |
1062
- const M = JSON.parse(process.env.PAW_MSG);
1062
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
1063
1063
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
1064
1064
  const row = (name, status, elapsed = '-') => `| ${msg('apk_test.step_' + name)} | ${msg('apk_test.st_' + status)} | ${elapsed} |`;
1065
1065
  const commentId = parseInt(process.env.PAW_IN_NEEDS_PREPARE_TEST_BUILD_OUTPUTS_PROGRESS_COMMENT_ID);
@@ -62,14 +62,14 @@ jobs:
62
62
  - name: Load messages
63
63
  run: |
64
64
  [ -f "$GITHUB_WORKSPACE/.github/scripts/messages.py" ] || { echo "::error::messages.py is missing; rerun \`npx project-auto-wizard --mode full --force\`"; exit 1; }
65
- echo "PAW_MSG=$(python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" dump app_trigger.)" >> "$GITHUB_ENV"
65
+ python3 "$GITHUB_WORKSPACE/.github/scripts/messages.py" dump app_trigger. > "$RUNNER_TEMP/paw-msg.json"
66
66
 
67
67
  - name: Add 👀 reaction to the comment
68
68
  uses: actions/github-script@v9
69
69
  with:
70
70
  github-token: ${{ secrets.GITHUB_TOKEN }}
71
71
  script: |
72
- const M = JSON.parse(process.env.PAW_MSG);
72
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
73
73
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
74
74
  const commentId = context.payload.comment.id;
75
75
 
@@ -88,7 +88,7 @@ jobs:
88
88
  with:
89
89
  github-token: ${{ secrets.GITHUB_TOKEN }}
90
90
  script: |
91
- const M = JSON.parse(process.env.PAW_MSG);
91
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
92
92
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
93
93
  // Keywords are matched on the lower-cased text (case-insensitive); the branch name is captured from the original body (case preserved)
94
94
  const comment = context.payload.comment.body.toLowerCase();
@@ -149,7 +149,7 @@ jobs:
149
149
  with:
150
150
  github-token: ${{ secrets.GITHUB_TOKEN }}
151
151
  script: |
152
- const M = JSON.parse(process.env.PAW_MSG);
152
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
153
153
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
154
154
  const customBranch = (process.env.CUSTOM_BRANCH || '').trim();
155
155
  const issueNumber = context.payload.issue.number;
@@ -272,7 +272,7 @@ jobs:
272
272
  with:
273
273
  github-token: ${{ secrets.GITHUB_TOKEN }}
274
274
  script: |
275
- const M = JSON.parse(process.env.PAW_MSG);
275
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
276
276
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
277
277
  const issueNumber = context.payload.issue.number;
278
278
  const errorMessage = process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_ERRORMESSAGE;
@@ -298,7 +298,7 @@ jobs:
298
298
  with:
299
299
  github-token: ${{ secrets.GITHUB_TOKEN }}
300
300
  script: |
301
- const M = JSON.parse(process.env.PAW_MSG);
301
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
302
302
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
303
303
  const branchName = process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_BRANCHNAME;
304
304
  const runId = process.env.PAW_IN_GITHUB_RUN_ID;
@@ -335,7 +335,7 @@ jobs:
335
335
  with:
336
336
  github-token: ${{ secrets.GITHUB_TOKEN }}
337
337
  script: |
338
- const M = JSON.parse(process.env.PAW_MSG);
338
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
339
339
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
340
340
  const issueNumber = context.payload.issue.number;
341
341
  const branchName = process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_BRANCHNAME;
@@ -378,7 +378,7 @@ jobs:
378
378
  with:
379
379
  github-token: ${{ secrets.GITHUB_TOKEN }}
380
380
  script: |
381
- const M = JSON.parse(process.env.PAW_MSG);
381
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
382
382
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
383
383
  const sourceNumber = parseInt(process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_SOURCENUMBER);
384
384
  const sourceType = process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_SOURCETYPE;
@@ -459,7 +459,7 @@ jobs:
459
459
  with:
460
460
  github-token: ${{ secrets.GITHUB_TOKEN }}
461
461
  script: |
462
- const M = JSON.parse(process.env.PAW_MSG);
462
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
463
463
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
464
464
  const sourceType = process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_SOURCETYPE;
465
465
  const sourceNumber = process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_SOURCENUMBER;
@@ -505,7 +505,7 @@ jobs:
505
505
  with:
506
506
  github-token: ${{ secrets.GITHUB_TOKEN }}
507
507
  script: |
508
- const M = JSON.parse(process.env.PAW_MSG);
508
+ const M = JSON.parse(require('fs').readFileSync(`${process.env.RUNNER_TEMP}/paw-msg.json`, 'utf8'));
509
509
  const msg = (k, p = {}) => M[k].replace(/\{(\w+)\}/g, (s, n) => (n in p ? p[n] : s));
510
510
  const sourceType = process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_SOURCETYPE;
511
511
  const sourceNumber = process.env.PAW_IN_STEPS_SOURCE_INFO_OUTPUTS_SOURCENUMBER;