climaybe 1.0.0 → 1.3.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 (32) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -12
  3. package/package.json +45 -2
  4. package/src/commands/add-store.js +77 -4
  5. package/src/commands/init.js +150 -16
  6. package/src/commands/update-workflows.js +4 -2
  7. package/src/index.js +15 -3
  8. package/src/lib/config.js +17 -0
  9. package/src/lib/git.js +21 -3
  10. package/src/lib/github-secrets.js +263 -0
  11. package/src/lib/prompts.js +116 -6
  12. package/src/lib/workflows.js +23 -3
  13. package/src/workflows/build/build-pipeline.yml +57 -0
  14. package/src/workflows/build/create-release.yml +52 -0
  15. package/src/workflows/build/reusable-build.yml +52 -0
  16. package/src/workflows/multi/main-to-staging-stores.yml +44 -3
  17. package/src/workflows/multi/multistore-hotfix-to-main.yml +84 -0
  18. package/src/workflows/multi/pr-to-live.yml +82 -8
  19. package/src/workflows/multi/stores-to-root.yml +10 -3
  20. package/src/workflows/preview/pr-close.yml +63 -0
  21. package/src/workflows/preview/pr-update.yml +120 -0
  22. package/src/workflows/preview/reusable-cleanup-themes.yml +71 -0
  23. package/src/workflows/preview/reusable-comment-on-pr.yml +76 -0
  24. package/src/workflows/preview/reusable-extract-pr-number.yml +35 -0
  25. package/src/workflows/preview/reusable-rename-theme.yml +73 -0
  26. package/src/workflows/preview/reusable-share-theme.yml +94 -0
  27. package/src/workflows/shared/ai-changelog.yml +82 -24
  28. package/src/workflows/shared/version-bump.yml +22 -7
  29. package/src/workflows/single/nightly-hotfix.yml +38 -2
  30. package/src/workflows/single/post-merge-tag.yml +68 -15
  31. package/src/workflows/single/release-pr-check.yml +16 -6
  32. package/src/workflows/multi/hotfix-backport.yml +0 -100
@@ -0,0 +1,73 @@
1
+ name: Rename Theme
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ theme_id:
7
+ required: true
8
+ type: string
9
+ description: "Theme ID to rename"
10
+ theme_name:
11
+ required: true
12
+ type: string
13
+ description: "Current theme name"
14
+ pr_number:
15
+ required: true
16
+ type: string
17
+ description: "PR number to append"
18
+ store_alias:
19
+ required: false
20
+ type: string
21
+ description: "Store alias for scoped secret lookup"
22
+ store_alias_secret:
23
+ required: false
24
+ type: string
25
+ description: "Upper snake-case alias for scoped secret lookup"
26
+ secrets:
27
+ SHOPIFY_STORE_URL:
28
+ required: false
29
+ SHOPIFY_CLI_THEME_TOKEN:
30
+ required: false
31
+
32
+ jobs:
33
+ rename:
34
+ runs-on: ubuntu-latest
35
+ steps:
36
+ - name: Install Shopify CLI
37
+ run: npm install -g @shopify/cli @shopify/theme
38
+
39
+ - name: Rename theme
40
+ env:
41
+ SHOPIFY_STORE_URL: ${{ secrets.SHOPIFY_STORE_URL }}
42
+ SHOPIFY_CLI_THEME_TOKEN: ${{ secrets.SHOPIFY_CLI_THEME_TOKEN }}
43
+ THEME_ID: ${{ inputs.theme_id }}
44
+ THEME_NAME: ${{ inputs.theme_name }}
45
+ PR_NUMBER: ${{ inputs.pr_number }}
46
+ run: |
47
+ if [ -z "$THEME_ID" ] || [ -z "$THEME_NAME" ]; then
48
+ echo "Missing theme_id/theme_name."
49
+ exit 1
50
+ fi
51
+ if [ -z "$SHOPIFY_STORE_URL" ] || [ -z "$SHOPIFY_CLI_THEME_TOKEN" ]; then
52
+ echo "Missing Shopify store URL/token for rename."
53
+ exit 1
54
+ fi
55
+
56
+ NEW_THEME_NAME="${THEME_NAME}-PR${PR_NUMBER}"
57
+ echo "Renaming theme $THEME_ID to '$NEW_THEME_NAME'..."
58
+
59
+ if shopify theme rename \
60
+ --store "$SHOPIFY_STORE_URL" \
61
+ --password "$SHOPIFY_CLI_THEME_TOKEN" \
62
+ --theme "$THEME_ID" \
63
+ --name "$NEW_THEME_NAME" 2>&1; then
64
+ echo "Rename succeeded with password auth."
65
+ elif shopify theme rename \
66
+ --store "$SHOPIFY_STORE_URL" \
67
+ --theme "$THEME_ID" \
68
+ --name "$NEW_THEME_NAME" 2>&1; then
69
+ echo "Rename succeeded with authenticated session."
70
+ else
71
+ echo "Failed to rename theme."
72
+ exit 1
73
+ fi
@@ -0,0 +1,94 @@
1
+ name: Share Theme
2
+
3
+ on:
4
+ workflow_call:
5
+ inputs:
6
+ pr_number:
7
+ required: false
8
+ type: string
9
+ description: "PR number for theme naming context"
10
+ store_alias:
11
+ required: false
12
+ type: string
13
+ description: "Store alias for scoped secret lookup"
14
+ store_alias_secret:
15
+ required: false
16
+ type: string
17
+ description: "Upper snake-case alias for scoped secret lookup"
18
+ outputs:
19
+ theme_id:
20
+ description: "Shared theme ID"
21
+ value: ${{ jobs.share.outputs.theme_id }}
22
+ theme_name:
23
+ description: "Shared theme name"
24
+ value: ${{ jobs.share.outputs.theme_name }}
25
+ share_output:
26
+ description: "Raw share command output"
27
+ value: ${{ jobs.share.outputs.share_output }}
28
+ secrets:
29
+ SHOPIFY_STORE_URL:
30
+ required: false
31
+ SHOPIFY_CLI_THEME_TOKEN:
32
+ required: false
33
+
34
+ jobs:
35
+ share:
36
+ runs-on: ubuntu-latest
37
+ outputs:
38
+ theme_id: ${{ steps.share.outputs.theme_id }}
39
+ theme_name: ${{ steps.share.outputs.theme_name }}
40
+ share_output: ${{ steps.share.outputs.share_output }}
41
+ steps:
42
+ - name: Checkout code
43
+ uses: actions/checkout@v4
44
+
45
+ - name: Validate theme root
46
+ run: |
47
+ if [ ! -f "layout/theme.liquid" ]; then
48
+ echo "layout/theme.liquid not found. Ensure workflow runs at theme repository root."
49
+ exit 1
50
+ fi
51
+
52
+ - name: Install Shopify CLI
53
+ run: npm install -g @shopify/cli @shopify/theme
54
+
55
+ - name: Share theme
56
+ id: share
57
+ env:
58
+ SHOPIFY_STORE_URL: ${{ secrets.SHOPIFY_STORE_URL }}
59
+ SHOPIFY_CLI_THEME_TOKEN: ${{ secrets.SHOPIFY_CLI_THEME_TOKEN }}
60
+ run: |
61
+ if [ -z "$SHOPIFY_STORE_URL" ] || [ -z "$SHOPIFY_CLI_THEME_TOKEN" ]; then
62
+ echo "Missing Shopify secrets."
63
+ exit 1
64
+ fi
65
+
66
+ OUTPUT=$(shopify theme share \
67
+ --store "$SHOPIFY_STORE_URL" \
68
+ --password "$SHOPIFY_CLI_THEME_TOKEN" 2>&1)
69
+ STATUS=$?
70
+
71
+ echo "$OUTPUT"
72
+ if [ $STATUS -ne 0 ]; then
73
+ echo "Theme share failed."
74
+ exit $STATUS
75
+ fi
76
+
77
+ THEME_NAME=$(echo "$OUTPUT" | sed -n "s/.*The theme '\([^']*\)'.*/\1/p" | head -1)
78
+ THEME_ID=$(echo "$OUTPUT" | sed -n 's/.*#\([0-9]*\).*/\1/p' | head -1)
79
+
80
+ if [ -z "$THEME_ID" ]; then
81
+ echo "Could not parse theme id from share output."
82
+ exit 1
83
+ fi
84
+
85
+ echo "theme_id=$THEME_ID" >> $GITHUB_OUTPUT
86
+ if [ -n "$THEME_NAME" ]; then
87
+ echo "theme_name=$THEME_NAME" >> $GITHUB_OUTPUT
88
+ fi
89
+
90
+ {
91
+ echo "share_output<<SHARE_EOF"
92
+ echo "$OUTPUT"
93
+ echo "SHARE_EOF"
94
+ } >> $GITHUB_OUTPUT
@@ -1,5 +1,5 @@
1
1
  # climaybe — AI Changelog Generator (Reusable Workflow)
2
- # Uses Google Gemini to classify commits and generate a changelog.
2
+ # Uses Gemini when available, otherwise falls back to GitHub Models.
3
3
 
4
4
  name: AI Changelog
5
5
 
@@ -21,11 +21,14 @@ on:
21
21
  value: ${{ jobs.generate.outputs.changelog }}
22
22
  secrets:
23
23
  GEMINI_API_KEY:
24
- required: true
24
+ required: false
25
25
 
26
26
  jobs:
27
27
  generate:
28
28
  runs-on: ubuntu-latest
29
+ permissions:
30
+ contents: read
31
+ models: read
29
32
  outputs:
30
33
  changelog: ${{ steps.ai.outputs.changelog }}
31
34
 
@@ -43,17 +46,23 @@ jobs:
43
46
  echo "commits=" >> $GITHUB_OUTPUT
44
47
  exit 0
45
48
  fi
46
- # Escape for JSON
47
- COMMITS_ESCAPED=$(echo "$COMMITS" | jq -Rs .)
48
- echo "commits=$COMMITS_ESCAPED" >> $GITHUB_OUTPUT
49
+ # Keep commits as multiline plain text
50
+ {
51
+ echo "commits<<COMMITS_EOF"
52
+ echo "$COMMITS"
53
+ echo "COMMITS_EOF"
54
+ } >> $GITHUB_OUTPUT
49
55
 
50
- - name: Generate changelog with Gemini
56
+ - name: Generate changelog (Gemini -> GitHub Models fallback)
51
57
  id: ai
52
58
  if: steps.commits.outputs.commits != ''
53
59
  env:
54
60
  GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
61
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62
+ COMMITS_RAW: ${{ steps.commits.outputs.commits }}
55
63
  run: |
56
- COMMITS=${{ steps.commits.outputs.commits }}
64
+ COMMITS="$COMMITS_RAW"
65
+ PROVIDER="fallback"
57
66
 
58
67
  PROMPT=$(cat <<'PROMPT_EOF'
59
68
  You are a changelog generator for a Shopify theme repository.
@@ -71,30 +80,79 @@ jobs:
71
80
  PROMPT_EOF
72
81
  )
73
82
 
74
- PAYLOAD=$(jq -n \
75
- --arg prompt "$PROMPT" \
76
- --argjson commits "$COMMITS" \
77
- '{
78
- "contents": [{
79
- "parts": [{"text": ($prompt + "\n" + $commits)}]
80
- }],
81
- "generationConfig": {
83
+ CHANGELOG=""
84
+
85
+ # 1) Gemini path (if secret exists)
86
+ if [ -n "$GEMINI_API_KEY" ]; then
87
+ PAYLOAD=$(jq -n \
88
+ --arg prompt "$PROMPT" \
89
+ --arg commits "$COMMITS" \
90
+ '{
91
+ "contents": [{
92
+ "parts": [{"text": ($prompt + "\n" + $commits)}]
93
+ }],
94
+ "generationConfig": {
95
+ "temperature": 0.3,
96
+ "maxOutputTokens": 2048
97
+ }
98
+ }')
99
+
100
+ RESPONSE=$(curl -s -X POST \
101
+ "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}" \
102
+ -H "Content-Type: application/json" \
103
+ -d "$PAYLOAD")
104
+
105
+ CHANGELOG=$(echo "$RESPONSE" | jq -r '.candidates[0].content.parts[0].text // empty' 2>/dev/null || true)
106
+ if [ -n "$CHANGELOG" ] && [ "$CHANGELOG" != "null" ]; then
107
+ PROVIDER="gemini"
108
+ fi
109
+ fi
110
+
111
+ # 2) GitHub Models path (Copilot-backed models)
112
+ if [ -z "$CHANGELOG" ] || [ "$CHANGELOG" = "null" ]; then
113
+ GH_PAYLOAD=$(jq -n \
114
+ --arg prompt "$PROMPT" \
115
+ --arg commits "$COMMITS" \
116
+ '{
117
+ "model": "gpt-4o-mini",
118
+ "messages": [
119
+ {"role":"system","content":"You are a changelog generator for Shopify theme repos. Output markdown only."},
120
+ {"role":"user","content": ($prompt + "\n" + $commits)}
121
+ ],
82
122
  "temperature": 0.3,
83
- "maxOutputTokens": 2048
84
- }
85
- }')
123
+ "max_tokens": 1200
124
+ }')
125
+
126
+ GH_RESPONSE=$(curl -s -X POST \
127
+ "https://models.inference.ai.azure.com/chat/completions" \
128
+ -H "Content-Type: application/json" \
129
+ -H "Authorization: Bearer ${GITHUB_TOKEN}" \
130
+ -d "$GH_PAYLOAD")
86
131
 
87
- RESPONSE=$(curl -s -X POST \
88
- "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}" \
89
- -H "Content-Type: application/json" \
90
- -d "$PAYLOAD")
132
+ CHANGELOG=$(echo "$GH_RESPONSE" | jq -r '.choices[0].message.content // empty' 2>/dev/null || true)
133
+ if [ -n "$CHANGELOG" ] && [ "$CHANGELOG" != "null" ]; then
134
+ PROVIDER="github-models"
135
+ fi
136
+ fi
137
+
138
+ # 3) Safe fallback if both providers fail
139
+ if [ -z "$CHANGELOG" ] || [ "$CHANGELOG" = "null" ]; then
140
+ COMMIT_LINES="$COMMITS"
141
+ CHANGELOG=$(printf "## Chore\n")
142
+ while IFS= read -r line; do
143
+ [ -z "$line" ] && continue
144
+ SUBJECT="${line#* }"
145
+ CHANGELOG="${CHANGELOG}- ${SUBJECT}\n"
146
+ done <<< "$COMMIT_LINES"
147
+ PROVIDER="static-fallback"
148
+ fi
91
149
 
92
- CHANGELOG=$(echo "$RESPONSE" | jq -r '.candidates[0].content.parts[0].text // "Changelog generation failed."')
150
+ echo "Changelog provider: $PROVIDER"
93
151
 
94
152
  # Write to output (multiline)
95
153
  {
96
154
  echo "changelog<<CHANGELOG_EOF"
97
- echo "$CHANGELOG"
155
+ printf "%b\n" "$CHANGELOG"
98
156
  echo "CHANGELOG_EOF"
99
157
  } >> $GITHUB_OUTPUT
100
158
 
@@ -52,8 +52,12 @@ jobs:
52
52
  EXPLICIT_VERSION="${{ inputs.version }}"
53
53
 
54
54
  if [ -n "$EXPLICIT_VERSION" ]; then
55
- # Use explicit version, ensure it starts with v
55
+ # Use explicit version; ensure v prefix and always three-part (e.g. v3.2.0)
56
56
  NEW_VERSION="${EXPLICIT_VERSION#v}"
57
+ PARTS=$(echo "$NEW_VERSION" | tr '.' '\n' | wc -l)
58
+ if [ "$PARTS" -eq "2" ]; then
59
+ NEW_VERSION="${NEW_VERSION}.0"
60
+ fi
57
61
  NEW_VERSION="v${NEW_VERSION}"
58
62
  else
59
63
  # Get latest tag
@@ -102,6 +106,7 @@ jobs:
102
106
  - name: Commit and tag
103
107
  run: |
104
108
  NEW_VERSION="${{ steps.bump.outputs.new_version }}"
109
+ TAG_CREATED="false"
105
110
 
106
111
  # Stage changes
107
112
  git add -A
@@ -111,12 +116,22 @@ jobs:
111
116
  git commit -m "chore(release): bump version to ${NEW_VERSION}"
112
117
  fi
113
118
 
114
- # Create annotated tag
115
- CHANGELOG="${{ inputs.changelog }}"
116
- if [ -n "$CHANGELOG" ]; then
117
- git tag -a "$NEW_VERSION" -m "$CHANGELOG"
119
+ # Guard against duplicate tag creation (local or remote).
120
+ if git ls-remote --exit-code --tags origin "refs/tags/${NEW_VERSION}" >/dev/null 2>&1; then
121
+ echo "Tag ${NEW_VERSION} already exists on origin, reusing."
122
+ elif git rev-parse -q --verify "refs/tags/${NEW_VERSION}" >/dev/null; then
123
+ echo "Tag ${NEW_VERSION} already exists locally, reusing."
118
124
  else
119
- git tag -a "$NEW_VERSION" -m "Release ${NEW_VERSION}"
125
+ CHANGELOG="${{ inputs.changelog }}"
126
+ if [ -n "$CHANGELOG" ]; then
127
+ git tag -a "$NEW_VERSION" -m "$CHANGELOG"
128
+ else
129
+ git tag -a "$NEW_VERSION" -m "Release ${NEW_VERSION}"
130
+ fi
131
+ TAG_CREATED="true"
120
132
  fi
121
133
 
122
- git push origin HEAD --follow-tags
134
+ git push origin HEAD
135
+ if [ "$TAG_CREATED" = "true" ]; then
136
+ git push origin "$NEW_VERSION"
137
+ fi
@@ -30,6 +30,8 @@ jobs:
30
30
 
31
31
  - name: Check for untagged commits
32
32
  id: check
33
+ env:
34
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
33
35
  run: |
34
36
  LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
35
37
 
@@ -41,8 +43,42 @@ jobs:
41
43
 
42
44
  echo "last_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
43
45
 
44
- # Count commits since last tag, excluding version bump commits
45
- COMMITS=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%s" | grep -v "chore(release): bump version" | grep -v "\[hotfix-backport\]" || true)
46
+ # Collect candidate commits since last tag.
47
+ # Exclude known workflow/system commits first, then exclude commits
48
+ # that are associated with PRs whose source branch is staging-*.
49
+ CANDIDATES=$(git log ${LATEST_TAG}..HEAD --pretty=format:"%H%x09%s" | \
50
+ grep -v "chore(release): bump version" | \
51
+ grep -v "\[hotfix-backport\]" | \
52
+ grep -v "\[skip-store-sync\]" | \
53
+ grep -v "\[stores-to-root\]" | \
54
+ grep -v "\[root-to-stores\]" || true)
55
+
56
+ COMMITS=""
57
+ SKIPPED_UNKNOWN=0
58
+ while IFS=$'\t' read -r SHA SUBJECT; do
59
+ [ -z "$SHA" ] && continue
60
+
61
+ HEAD_REF=$(gh api \
62
+ repos/${{ github.repository }}/commits/$SHA/pulls \
63
+ --jq '.[0].head.ref // ""' 2>/dev/null || true)
64
+
65
+ if [ -z "$HEAD_REF" ]; then
66
+ SKIPPED_UNKNOWN=$((SKIPPED_UNKNOWN + 1))
67
+ echo "Skipping commit due to missing PR metadata: $SHA"
68
+ continue
69
+ fi
70
+
71
+ if [[ "$HEAD_REF" == staging-* ]]; then
72
+ echo "Skipping store-backport commit from $HEAD_REF: $SHA"
73
+ continue
74
+ fi
75
+
76
+ COMMITS="${COMMITS}${SUBJECT}"$'\n'
77
+ done <<< "$CANDIDATES"
78
+
79
+ if [ "$SKIPPED_UNKNOWN" -gt 0 ]; then
80
+ echo "Skipped $SKIPPED_UNKNOWN commit(s) due to missing PR metadata (fail-safe)."
81
+ fi
46
82
 
47
83
  if [ -n "$COMMITS" ]; then
48
84
  COMMIT_COUNT=$(echo "$COMMITS" | wc -l | tr -d ' ')
@@ -1,7 +1,7 @@
1
- # climaybe — Post-Merge Tag (Single-store)
2
- # After a staging → main PR is merged, detects the target version from the PR title
3
- # and bumps the minor version accordingly.
4
- # PR title convention: "Release v3.2" or "Release v3.2.0"
1
+ # climaybe — Post-Merge Tag (Single-store & Multi-store)
2
+ # After a staging → main PR is merged: detects target version from PR title, minor bump (e.g. v3.2.0).
3
+ # After staging-<store> or live-<store> is synced to main (multistore-hotfix-to-main): patch bump (e.g. v3.2.1).
4
+ # Version format is always three-part: v3.2.0. PR title convention for release: "Release v3.2" or "Release v3.2.0"
5
5
 
6
6
  name: Post-Merge Tag
7
7
 
@@ -20,8 +20,9 @@ jobs:
20
20
  outputs:
21
21
  is_release: ${{ steps.check.outputs.is_release }}
22
22
  version: ${{ steps.check.outputs.version }}
23
+ is_hotfix_backport: ${{ steps.check.outputs.is_hotfix_backport }}
23
24
  steps:
24
- - name: Check if this push is from a merged staging PR
25
+ - name: Check if this push is from a merged staging PR or hotfix backport
25
26
  id: check
26
27
  env:
27
28
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -29,19 +30,23 @@ jobs:
29
30
  # Check the commit message for merge indicator
30
31
  COMMIT_MSG=$(echo "${{ github.event.head_commit.message }}" | head -1)
31
32
 
32
- # Skip hotfix backports
33
- if echo "$COMMIT_MSG" | grep -q "\[hotfix-backport\]"; then
33
+ # Skip version bump commits from this workflow (avoid loop)
34
+ if echo "$COMMIT_MSG" | grep -q "chore(release): bump version"; then
35
+ echo "Skipping post-merge tag: version bump commit."
34
36
  echo "is_release=false" >> $GITHUB_OUTPUT
37
+ echo "is_hotfix_backport=false" >> $GITHUB_OUTPUT
35
38
  exit 0
36
39
  fi
37
40
 
38
- # Skip version bump commits from this workflow
39
- if echo "$COMMIT_MSG" | grep -q "chore(release): bump version"; then
41
+ # Automatic hotfix sync (multistore-hotfix-to-main merge commit)
42
+ if echo "$COMMIT_MSG" | grep -q "\[hotfix-backport\]"; then
40
43
  echo "is_release=false" >> $GITHUB_OUTPUT
44
+ echo "is_hotfix_backport=true" >> $GITHUB_OUTPUT
45
+ echo "Detected hotfix backport merge, will trigger patch bump."
41
46
  exit 0
42
47
  fi
43
48
 
44
- # Look for merged PR that targeted main from staging
49
+ # Look for merged PR that targeted main
45
50
  PR_NUMBER=$(echo "$COMMIT_MSG" | grep -oP '#\K\d+' | head -1 || true)
46
51
 
47
52
  if [ -n "$PR_NUMBER" ]; then
@@ -51,27 +56,36 @@ jobs:
51
56
  HEAD_REF=$(echo "$PR_DATA" | jq -r '.head.ref')
52
57
  TITLE=$(echo "$PR_DATA" | jq -r '.title')
53
58
 
59
+ # Staging → main: release (minor bump)
54
60
  if [ "$HEAD_REF" = "staging" ]; then
55
- # Extract version from PR title: "Release v3.2" or "Release v3.2.0"
56
61
  VERSION=$(echo "$TITLE" | grep -oP 'v\d+\.\d+(\.\d+)?' || true)
57
-
58
62
  if [ -n "$VERSION" ]; then
59
- # Ensure it has 3 parts
60
63
  PARTS=$(echo "$VERSION" | tr '.' '\n' | wc -l)
61
64
  if [ "$PARTS" -eq "2" ]; then
62
65
  VERSION="${VERSION}.0"
63
66
  fi
64
-
65
67
  echo "is_release=true" >> $GITHUB_OUTPUT
66
68
  echo "version=$VERSION" >> $GITHUB_OUTPUT
69
+ echo "is_hotfix_backport=false" >> $GITHUB_OUTPUT
67
70
  echo "Detected release version: $VERSION"
68
71
  exit 0
69
72
  fi
73
+ echo "Skipping post-merge tag: staging PR title missing release version."
74
+ fi
75
+
76
+ # live-<store> → main: hotfix backport (patch bump)
77
+ if [[ "$HEAD_REF" == live-* ]]; then
78
+ echo "is_release=false" >> $GITHUB_OUTPUT
79
+ echo "is_hotfix_backport=true" >> $GITHUB_OUTPUT
80
+ echo "Detected hotfix backport from $HEAD_REF, will trigger patch bump."
81
+ exit 0
70
82
  fi
71
83
  fi
72
84
  fi
73
85
 
86
+ echo "Skipping post-merge tag: push is not a merged staging release or hotfix backport PR."
74
87
  echo "is_release=false" >> $GITHUB_OUTPUT
88
+ echo "is_hotfix_backport=false" >> $GITHUB_OUTPUT
75
89
 
76
90
  # Generate changelog for the release
77
91
  changelog:
@@ -84,7 +98,7 @@ jobs:
84
98
  secrets:
85
99
  GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
86
100
 
87
- # Apply version bump
101
+ # Apply version bump (minor for release)
88
102
  bump:
89
103
  needs: [detect, changelog]
90
104
  if: needs.detect.outputs.is_release == 'true'
@@ -94,3 +108,42 @@ jobs:
94
108
  version: ${{ needs.detect.outputs.version }}
95
109
  changelog: ${{ needs.changelog.outputs.changelog }}
96
110
  secrets: inherit
111
+
112
+ # Resolve last tag for hotfix changelog (base_ref)
113
+ hotfix-base:
114
+ needs: detect
115
+ if: needs.detect.outputs.is_hotfix_backport == 'true'
116
+ runs-on: ubuntu-latest
117
+ outputs:
118
+ last_tag: ${{ steps.tag.outputs.last_tag }}
119
+ steps:
120
+ - uses: actions/checkout@v4
121
+ with:
122
+ fetch-depth: 0
123
+ - name: Get latest tag
124
+ id: tag
125
+ run: |
126
+ LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
127
+ echo "last_tag=$LAST_TAG" >> $GITHUB_OUTPUT
128
+ echo "Base ref for hotfix changelog: $LAST_TAG"
129
+
130
+ # Generate changelog for hotfix (since last tag)
131
+ changelog-hotfix:
132
+ needs: [detect, hotfix-base]
133
+ if: needs.detect.outputs.is_hotfix_backport == 'true'
134
+ uses: ./.github/workflows/ai-changelog.yml
135
+ with:
136
+ base_ref: ${{ needs.hotfix-base.outputs.last_tag }}
137
+ head_ref: HEAD
138
+ secrets:
139
+ GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
140
+
141
+ # Apply patch version bump after hotfix backport merge
142
+ bump-hotfix:
143
+ needs: [detect, hotfix-base, changelog-hotfix]
144
+ if: needs.detect.outputs.is_hotfix_backport == 'true'
145
+ uses: ./.github/workflows/version-bump.yml
146
+ with:
147
+ bump_type: patch
148
+ changelog: ${{ needs.changelog-hotfix.outputs.changelog }}
149
+ secrets: inherit
@@ -68,10 +68,16 @@ jobs:
68
68
  PATCH=$((PATCH + 1))
69
69
  NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
70
70
 
71
- git tag -a "$NEW_TAG" -m "Pre-release lock before staging merge"
72
- git push origin "$NEW_TAG"
73
- echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT
74
- echo "Created pre-release tag: $NEW_TAG"
71
+ # Avoid duplicate push attempts when a tag already exists.
72
+ if git rev-parse -q --verify "refs/tags/$NEW_TAG" >/dev/null; then
73
+ echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT
74
+ echo "Tag $NEW_TAG already exists, reusing it."
75
+ else
76
+ git tag -a "$NEW_TAG" -m "Pre-release lock before staging merge"
77
+ git push origin "$NEW_TAG"
78
+ echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT
79
+ echo "Created pre-release tag: $NEW_TAG"
80
+ fi
75
81
  else
76
82
  echo "new_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
77
83
  echo "No new commits since $LATEST_TAG, skipping tag."
@@ -98,10 +104,14 @@ jobs:
98
104
  steps:
99
105
  - name: Post changelog comment
100
106
  uses: actions/github-script@v7
107
+ env:
108
+ PRE_RELEASE_TAG: ${{ needs.pre-release-tag.outputs.new_tag }}
109
+ CHANGELOG_TEXT: ${{ needs.changelog.outputs.changelog }}
101
110
  with:
102
111
  script: |
103
- const tag = '${{ needs.pre-release-tag.outputs.new_tag }}';
104
- const changelog = `${{ needs.changelog.outputs.changelog }}`;
112
+ const tag = process.env.PRE_RELEASE_TAG || '';
113
+ const rawChangelog = process.env.CHANGELOG_TEXT || '';
114
+ const changelog = rawChangelog.trim() ? rawChangelog : 'No changes detected.';
105
115
 
106
116
  const body = `## Release Changelog\n\n` +
107
117
  `**Pre-release tag:** \`${tag}\`\n\n` +