climaybe 1.0.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.
@@ -0,0 +1,117 @@
1
+ import prompts from 'prompts';
2
+ import pc from 'picocolors';
3
+
4
+ /**
5
+ * Extract the subdomain (storeKey) from a Shopify domain.
6
+ * "voldt-staging.myshopify.com" → "voldt-staging"
7
+ */
8
+ export function extractAlias(domain) {
9
+ return domain.replace(/\.myshopify\.com$/i, '').trim();
10
+ }
11
+
12
+ /**
13
+ * Normalize a store domain input.
14
+ * Appends ".myshopify.com" if not present.
15
+ */
16
+ export function normalizeDomain(input) {
17
+ const trimmed = input.trim().toLowerCase();
18
+ if (trimmed.endsWith('.myshopify.com')) return trimmed;
19
+ return `${trimmed}.myshopify.com`;
20
+ }
21
+
22
+ /**
23
+ * Prompt the user for a single store URL + alias pair.
24
+ * Returns { alias, domain } or null if cancelled.
25
+ */
26
+ export async function promptStore(defaultDomain = '') {
27
+ const { domain } = await prompts({
28
+ type: 'text',
29
+ name: 'domain',
30
+ message: 'Store URL',
31
+ initial: defaultDomain,
32
+ validate: (v) =>
33
+ v.trim().length > 0 ? true : 'Store URL is required',
34
+ });
35
+
36
+ if (!domain) return null;
37
+
38
+ const normalized = normalizeDomain(domain);
39
+ const suggestedAlias = extractAlias(normalized);
40
+
41
+ const { alias } = await prompts({
42
+ type: 'text',
43
+ name: 'alias',
44
+ message: `Alias`,
45
+ initial: suggestedAlias,
46
+ validate: (v) => {
47
+ const val = v.trim();
48
+ if (!val) return 'Alias is required';
49
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(val)) return 'Alias must be lowercase alphanumeric with hyphens';
50
+ return true;
51
+ },
52
+ });
53
+
54
+ if (!alias) return null;
55
+
56
+ return { alias: alias.trim(), domain: normalized };
57
+ }
58
+
59
+ /**
60
+ * Prompt the user for one or more stores in a loop.
61
+ * Returns an array of { alias, domain } objects.
62
+ */
63
+ export async function promptStoreLoop() {
64
+ const stores = [];
65
+
66
+ console.log(pc.cyan('\n Configure your Shopify store(s)\n'));
67
+
68
+ // First store is required
69
+ const first = await promptStore();
70
+ if (!first) {
71
+ console.log(pc.red(' Setup cancelled.'));
72
+ process.exit(1);
73
+ }
74
+ stores.push(first);
75
+
76
+ // Ask for more stores
77
+ while (true) {
78
+ const { another } = await prompts({
79
+ type: 'confirm',
80
+ name: 'another',
81
+ message: 'Add another store?',
82
+ initial: false,
83
+ });
84
+
85
+ if (!another) break;
86
+
87
+ const store = await promptStore();
88
+ if (!store) break;
89
+
90
+ if (stores.some((s) => s.alias === store.alias)) {
91
+ console.log(pc.yellow(` Alias "${store.alias}" already exists, skipping.`));
92
+ continue;
93
+ }
94
+
95
+ stores.push(store);
96
+ }
97
+
98
+ return stores;
99
+ }
100
+
101
+ /**
102
+ * Prompt for a single new store (used by add-store command).
103
+ * Takes existing aliases to prevent duplicates.
104
+ */
105
+ export async function promptNewStore(existingAliases = []) {
106
+ console.log(pc.cyan('\n Add a new store\n'));
107
+
108
+ const store = await promptStore();
109
+ if (!store) return null;
110
+
111
+ if (existingAliases.includes(store.alias)) {
112
+ console.log(pc.red(` Alias "${store.alias}" already exists.`));
113
+ return null;
114
+ }
115
+
116
+ return store;
117
+ }
@@ -0,0 +1,142 @@
1
+ import { existsSync, mkdirSync, readdirSync, copyFileSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import pc from 'picocolors';
4
+
5
+ /**
6
+ * Directories that participate in the root <-> stores/ sync.
7
+ * These are the JSON-holding directories in a Shopify theme.
8
+ */
9
+ const SYNC_DIRS = ['config', 'templates', 'sections'];
10
+
11
+ /**
12
+ * Files explicitly excluded from sync — they travel via branch history.
13
+ */
14
+ const EXCLUDED_FILES = [
15
+ 'config/settings_schema.json',
16
+ ];
17
+
18
+ /**
19
+ * Patterns to exclude from sync (directory-level).
20
+ */
21
+ const EXCLUDED_DIRS = ['locales'];
22
+
23
+ /**
24
+ * Check if a file path should be excluded from sync.
25
+ */
26
+ function isExcluded(relativePath) {
27
+ // Check direct file exclusions
28
+ if (EXCLUDED_FILES.includes(relativePath)) return true;
29
+
30
+ // Check directory exclusions
31
+ for (const dir of EXCLUDED_DIRS) {
32
+ if (relativePath.startsWith(dir + '/')) return true;
33
+ }
34
+
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * Recursively collect all .json files in a directory.
40
+ * Returns paths relative to the base directory.
41
+ */
42
+ function collectJsonFiles(dir, base = dir) {
43
+ const files = [];
44
+ if (!existsSync(dir)) return files;
45
+
46
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
47
+ const full = join(dir, entry.name);
48
+ if (entry.isDirectory()) {
49
+ files.push(...collectJsonFiles(full, base));
50
+ } else if (entry.name.endsWith('.json')) {
51
+ files.push(relative(base, full));
52
+ }
53
+ }
54
+
55
+ return files;
56
+ }
57
+
58
+ /**
59
+ * Ensure a directory exists (recursive).
60
+ */
61
+ function ensureDir(dir) {
62
+ if (!existsSync(dir)) {
63
+ mkdirSync(dir, { recursive: true });
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Copy JSON files from stores/<alias>/ to the repo root.
69
+ * Used by: `climaybe switch`, `stores-to-root` workflow.
70
+ */
71
+ export function storesToRoot(alias, cwd = process.cwd()) {
72
+ const storeDir = join(cwd, 'stores', alias);
73
+
74
+ if (!existsSync(storeDir)) {
75
+ console.log(pc.red(` Store directory "stores/${alias}/" does not exist.`));
76
+ return false;
77
+ }
78
+
79
+ let copied = 0;
80
+
81
+ for (const syncDir of SYNC_DIRS) {
82
+ const sourceDir = join(storeDir, syncDir);
83
+ const jsonFiles = collectJsonFiles(sourceDir, storeDir);
84
+
85
+ for (const relPath of jsonFiles) {
86
+ if (isExcluded(relPath)) continue;
87
+
88
+ const src = join(storeDir, relPath);
89
+ const dest = join(cwd, relPath);
90
+
91
+ ensureDir(join(dest, '..'));
92
+ copyFileSync(src, dest);
93
+ copied++;
94
+ }
95
+ }
96
+
97
+ console.log(pc.green(` Copied ${copied} file(s) from stores/${alias}/ → root`));
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * Copy JSON files from the repo root to stores/<alias>/.
103
+ * Used by: `climaybe sync`, `root-to-stores` workflow.
104
+ */
105
+ export function rootToStores(alias, cwd = process.cwd()) {
106
+ const storeDir = join(cwd, 'stores', alias);
107
+
108
+ let copied = 0;
109
+
110
+ for (const syncDir of SYNC_DIRS) {
111
+ const sourceDir = join(cwd, syncDir);
112
+ const jsonFiles = collectJsonFiles(sourceDir, cwd);
113
+
114
+ for (const relPath of jsonFiles) {
115
+ if (isExcluded(relPath)) continue;
116
+
117
+ const src = join(cwd, relPath);
118
+ const dest = join(storeDir, relPath);
119
+
120
+ ensureDir(join(dest, '..'));
121
+ copyFileSync(src, dest);
122
+ copied++;
123
+ }
124
+ }
125
+
126
+ console.log(pc.green(` Copied ${copied} file(s) from root → stores/${alias}/`));
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * Create the store directory structure for a new store.
132
+ */
133
+ export function createStoreDirectories(alias, cwd = process.cwd()) {
134
+ const storeDir = join(cwd, 'stores', alias);
135
+
136
+ for (const dir of SYNC_DIRS) {
137
+ const target = join(storeDir, dir);
138
+ ensureDir(target);
139
+ }
140
+
141
+ console.log(pc.green(` Created store directory: stores/${alias}/`));
142
+ }
@@ -0,0 +1,99 @@
1
+ import { existsSync, mkdirSync, readdirSync, copyFileSync, rmSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import pc from 'picocolors';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const TEMPLATES_DIR = join(__dirname, '..', 'workflows');
8
+
9
+ /**
10
+ * Get the path to the target repo's .github/workflows/ directory.
11
+ */
12
+ function ghWorkflowsDir(cwd = process.cwd()) {
13
+ return join(cwd, '.github', 'workflows');
14
+ }
15
+
16
+ /**
17
+ * Collect all .yml files in a directory (non-recursive).
18
+ */
19
+ function listYmls(dir) {
20
+ if (!existsSync(dir)) return [];
21
+ return readdirSync(dir).filter((f) => f.endsWith('.yml'));
22
+ }
23
+
24
+ /**
25
+ * Copy a single workflow template to the target repo.
26
+ */
27
+ function copyWorkflow(srcDir, fileName, destDir) {
28
+ const src = join(srcDir, fileName);
29
+ const dest = join(destDir, fileName);
30
+ copyFileSync(src, dest);
31
+ }
32
+
33
+ /**
34
+ * Remove all climaybe-managed workflow files.
35
+ * We prefix a comment header in each workflow so we can identify them,
36
+ * but for simplicity we track known filenames instead.
37
+ */
38
+ function getKnownWorkflowFiles() {
39
+ const dirs = ['shared', 'single', 'multi'];
40
+ const files = new Set();
41
+ for (const dir of dirs) {
42
+ const dirPath = join(TEMPLATES_DIR, dir);
43
+ for (const f of listYmls(dirPath)) {
44
+ files.add(f);
45
+ }
46
+ }
47
+ return files;
48
+ }
49
+
50
+ /**
51
+ * Remove previously scaffolded climaybe workflows from target.
52
+ */
53
+ function cleanWorkflows(cwd = process.cwd()) {
54
+ const dest = ghWorkflowsDir(cwd);
55
+ if (!existsSync(dest)) return;
56
+
57
+ const known = getKnownWorkflowFiles();
58
+ for (const file of readdirSync(dest)) {
59
+ if (known.has(file)) {
60
+ rmSync(join(dest, file));
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Scaffold the correct set of GitHub Actions workflows based on mode.
67
+ * - Always copies shared/ workflows.
68
+ * - Copies single/ or multi/ (+ single/) based on mode.
69
+ */
70
+ export function scaffoldWorkflows(mode = 'single', cwd = process.cwd()) {
71
+ const dest = ghWorkflowsDir(cwd);
72
+ mkdirSync(dest, { recursive: true });
73
+
74
+ // Clean previously scaffolded files
75
+ cleanWorkflows(cwd);
76
+
77
+ // Always copy shared workflows
78
+ const sharedDir = join(TEMPLATES_DIR, 'shared');
79
+ for (const f of listYmls(sharedDir)) {
80
+ copyWorkflow(sharedDir, f, dest);
81
+ }
82
+
83
+ // Single-store workflows (used in both modes)
84
+ const singleDir = join(TEMPLATES_DIR, 'single');
85
+ for (const f of listYmls(singleDir)) {
86
+ copyWorkflow(singleDir, f, dest);
87
+ }
88
+
89
+ if (mode === 'multi') {
90
+ const multiDir = join(TEMPLATES_DIR, 'multi');
91
+ for (const f of listYmls(multiDir)) {
92
+ copyWorkflow(multiDir, f, dest);
93
+ }
94
+ }
95
+
96
+ const total = readdirSync(dest).filter((f) => f.endsWith('.yml')).length;
97
+ console.log(pc.green(` Scaffolded ${total} workflow(s) → .github/workflows/`));
98
+ console.log(pc.dim(` Mode: ${mode}-store`));
99
+ }
@@ -0,0 +1,100 @@
1
+ # climaybe — Hotfix Backport (Multi-store)
2
+ # After root-to-stores completes on a live-* branch,
3
+ # creates a PR to main with [hotfix-backport] flag.
4
+ # This ensures hotfixes on live stores are propagated back to the dev branch.
5
+ # Triggers a patch version bump on main.
6
+
7
+ name: Hotfix Backport
8
+
9
+ on:
10
+ workflow_run:
11
+ workflows: ["Root to Stores"]
12
+ types: [completed]
13
+ branches:
14
+ - 'live-*'
15
+
16
+ jobs:
17
+ backport:
18
+ if: github.event.workflow_run.conclusion == 'success'
19
+ runs-on: ubuntu-latest
20
+ permissions:
21
+ contents: write
22
+ pull-requests: write
23
+ env:
24
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ with:
28
+ ref: ${{ github.event.workflow_run.head_branch }}
29
+ fetch-depth: 0
30
+ token: ${{ secrets.GITHUB_TOKEN }}
31
+
32
+ - name: Extract store alias
33
+ id: alias
34
+ run: |
35
+ BRANCH="${{ github.event.workflow_run.head_branch }}"
36
+ ALIAS="${BRANCH#live-}"
37
+ echo "alias=$ALIAS" >> $GITHUB_OUTPUT
38
+ echo "live_branch=$BRANCH" >> $GITHUB_OUTPUT
39
+
40
+ - name: Check if backport is needed
41
+ id: check
42
+ run: |
43
+ LIVE="${{ steps.alias.outputs.live_branch }}"
44
+
45
+ # Get the last common ancestor with main
46
+ MERGE_BASE=$(git merge-base origin/main origin/$LIVE 2>/dev/null || echo "")
47
+
48
+ if [ -z "$MERGE_BASE" ]; then
49
+ echo "needs_backport=false" >> $GITHUB_OUTPUT
50
+ exit 0
51
+ fi
52
+
53
+ # Check for commits on live that aren't on main
54
+ COMMITS=$(git log --oneline ${MERGE_BASE}..origin/$LIVE -- . ':!stores/' 2>/dev/null | grep -v "\[stores-to-root\]" | grep -v "\[root-to-stores\]" | grep -v "chore(release)" || true)
55
+
56
+ if [ -n "$COMMITS" ]; then
57
+ echo "needs_backport=true" >> $GITHUB_OUTPUT
58
+ echo "Commits to backport:"
59
+ echo "$COMMITS"
60
+ else
61
+ echo "needs_backport=false" >> $GITHUB_OUTPUT
62
+ echo "No new commits to backport."
63
+ fi
64
+
65
+ - name: Create backport PR
66
+ if: steps.check.outputs.needs_backport == 'true'
67
+ run: |
68
+ LIVE="${{ steps.alias.outputs.live_branch }}"
69
+ ALIAS="${{ steps.alias.outputs.alias }}"
70
+
71
+ # Check for existing open backport PR
72
+ EXISTING_PR=$(gh pr list --base main --head "$LIVE" --state open --json number --jq '.[0].number' 2>/dev/null || echo "")
73
+
74
+ if [ -n "$EXISTING_PR" ]; then
75
+ echo "Backport PR #$EXISTING_PR already exists"
76
+ exit 0
77
+ fi
78
+
79
+ PR_URL=$(gh pr create \
80
+ --base main \
81
+ --head "$LIVE" \
82
+ --title "[hotfix-backport] ${ALIAS}: hotfix from live" \
83
+ --body "Backporting hotfix changes from **${LIVE}** to main.
84
+
85
+ This PR was automatically created because direct commits were detected on the live branch.
86
+
87
+ **Important:** Merging this will trigger a patch version bump on main.
88
+
89
+ *Generated by climaybe*" 2>/dev/null || echo "")
90
+
91
+ if [ -n "$PR_URL" ]; then
92
+ echo "Created backport PR: $PR_URL"
93
+ else
94
+ echo "PR creation failed or no diff between branches."
95
+ fi
96
+
97
+ # Patch version bump after backport merge
98
+ # This is handled by the nightly-hotfix workflow or can be triggered manually.
99
+ # The [hotfix-backport] flag in the commit message ensures main-to-staging-stores
100
+ # does NOT push this back to stores.
@@ -0,0 +1,118 @@
1
+ # climaybe — Main to Staging Stores (Multi-store)
2
+ # When a PR is merged into main (from staging), this workflow
3
+ # opens and auto-merges PRs from main to each staging-<alias> branch.
4
+ # Skips hotfix backport commits to prevent recursive triggers.
5
+
6
+ name: Main to Staging Stores
7
+
8
+ on:
9
+ push:
10
+ branches: [main]
11
+
12
+ # Prevent concurrent store pushes
13
+ concurrency:
14
+ group: main-to-staging-stores
15
+ cancel-in-progress: false
16
+
17
+ jobs:
18
+ # Gate: skip hotfix backports and version bump commits
19
+ gate:
20
+ runs-on: ubuntu-latest
21
+ outputs:
22
+ should_run: ${{ steps.check.outputs.should_run }}
23
+ steps:
24
+ - name: Check commit message
25
+ id: check
26
+ run: |
27
+ COMMIT_MSG="${{ github.event.head_commit.message }}"
28
+
29
+ if echo "$COMMIT_MSG" | grep -q "\[hotfix-backport\]"; then
30
+ echo "Skipping — hotfix backport commit"
31
+ echo "should_run=false" >> $GITHUB_OUTPUT
32
+ elif echo "$COMMIT_MSG" | grep -q "chore(release): bump version"; then
33
+ echo "Skipping — version bump commit"
34
+ echo "should_run=false" >> $GITHUB_OUTPUT
35
+ elif echo "$COMMIT_MSG" | grep -q "\[skip-store-sync\]"; then
36
+ echo "Skipping — store sync commit"
37
+ echo "should_run=false" >> $GITHUB_OUTPUT
38
+ else
39
+ echo "should_run=true" >> $GITHUB_OUTPUT
40
+ fi
41
+
42
+ # Read store list from package.json
43
+ config:
44
+ needs: gate
45
+ if: needs.gate.outputs.should_run == 'true'
46
+ runs-on: ubuntu-latest
47
+ outputs:
48
+ stores: ${{ steps.read.outputs.stores }}
49
+ steps:
50
+ - uses: actions/checkout@v4
51
+
52
+ - name: Read store aliases
53
+ id: read
54
+ run: |
55
+ STORES=$(node -e "
56
+ const pkg = require('./package.json');
57
+ const stores = Object.keys(pkg.config?.stores || {});
58
+ console.log(JSON.stringify(stores));
59
+ ")
60
+ echo "stores=$STORES" >> $GITHUB_OUTPUT
61
+ echo "Store aliases: $STORES"
62
+
63
+ # Create PRs to each staging-<alias> branch
64
+ sync:
65
+ needs: [gate, config]
66
+ if: needs.gate.outputs.should_run == 'true'
67
+ runs-on: ubuntu-latest
68
+ strategy:
69
+ matrix:
70
+ store: ${{ fromJson(needs.config.outputs.stores) }}
71
+ fail-fast: false
72
+ permissions:
73
+ contents: write
74
+ pull-requests: write
75
+ env:
76
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
77
+ steps:
78
+ - uses: actions/checkout@v4
79
+ with:
80
+ fetch-depth: 0
81
+
82
+ - name: Sync main to staging-${{ matrix.store }}
83
+ run: |
84
+ BRANCH="staging-${{ matrix.store }}"
85
+
86
+ # Check if branch exists
87
+ if ! git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
88
+ echo "Branch $BRANCH does not exist on remote, skipping."
89
+ exit 0
90
+ fi
91
+
92
+ # Check for existing open PR
93
+ EXISTING_PR=$(gh pr list --base "$BRANCH" --head main --state open --json number --jq '.[0].number' 2>/dev/null || echo "")
94
+
95
+ if [ -n "$EXISTING_PR" ]; then
96
+ echo "PR #$EXISTING_PR already exists for main → $BRANCH"
97
+ # Update the existing PR by force
98
+ gh pr close "$EXISTING_PR" --delete-branch=false 2>/dev/null || true
99
+ fi
100
+
101
+ # Create PR
102
+ PR_URL=$(gh pr create \
103
+ --base "$BRANCH" \
104
+ --head main \
105
+ --title "Sync main → $BRANCH" \
106
+ --body "Automated sync from main to $BRANCH after staging merge.
107
+
108
+ *Generated by climaybe*" 2>/dev/null || echo "")
109
+
110
+ if [ -n "$PR_URL" ]; then
111
+ echo "Created PR: $PR_URL"
112
+
113
+ # Auto-merge
114
+ PR_NUM=$(echo "$PR_URL" | grep -oP '\d+$')
115
+ gh pr merge "$PR_NUM" --merge --admin 2>/dev/null || echo "Auto-merge failed — manual review may be needed."
116
+ else
117
+ echo "No changes to sync for $BRANCH"
118
+ fi
@@ -0,0 +1,71 @@
1
+ # climaybe — PR to Live (Multi-store)
2
+ # After stores-to-root completes on a staging-* branch,
3
+ # opens a PR from staging-<alias> to live-<alias>.
4
+
5
+ name: PR to Live
6
+
7
+ on:
8
+ workflow_run:
9
+ workflows: ["Stores to Root"]
10
+ types: [completed]
11
+ branches:
12
+ - 'staging-*'
13
+
14
+ jobs:
15
+ create-pr:
16
+ # Only run if the triggering workflow succeeded
17
+ if: github.event.workflow_run.conclusion == 'success'
18
+ runs-on: ubuntu-latest
19
+ permissions:
20
+ contents: read
21
+ pull-requests: write
22
+ env:
23
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+
27
+ - name: Extract store alias
28
+ id: alias
29
+ run: |
30
+ BRANCH="${{ github.event.workflow_run.head_branch }}"
31
+ ALIAS="${BRANCH#staging-}"
32
+ echo "alias=$ALIAS" >> $GITHUB_OUTPUT
33
+ echo "staging_branch=$BRANCH" >> $GITHUB_OUTPUT
34
+ echo "live_branch=live-${ALIAS}" >> $GITHUB_OUTPUT
35
+
36
+ - name: Create PR to live branch
37
+ run: |
38
+ STAGING="${{ steps.alias.outputs.staging_branch }}"
39
+ LIVE="${{ steps.alias.outputs.live_branch }}"
40
+ ALIAS="${{ steps.alias.outputs.alias }}"
41
+
42
+ # Check if live branch exists
43
+ if ! git ls-remote --heads origin "$LIVE" | grep -q "$LIVE"; then
44
+ echo "Live branch $LIVE does not exist, skipping."
45
+ exit 0
46
+ fi
47
+
48
+ # Check for existing open PR
49
+ EXISTING_PR=$(gh pr list --base "$LIVE" --head "$STAGING" --state open --json number --jq '.[0].number' 2>/dev/null || echo "")
50
+
51
+ if [ -n "$EXISTING_PR" ]; then
52
+ echo "PR #$EXISTING_PR already open for $STAGING → $LIVE"
53
+ exit 0
54
+ fi
55
+
56
+ # Create PR
57
+ PR_URL=$(gh pr create \
58
+ --base "$LIVE" \
59
+ --head "$STAGING" \
60
+ --title "Deploy to ${ALIAS}" \
61
+ --body "Deployment PR for **${ALIAS}** store.
62
+
63
+ Review the changes and merge to deploy.
64
+
65
+ *Generated by climaybe*" 2>/dev/null || echo "")
66
+
67
+ if [ -n "$PR_URL" ]; then
68
+ echo "Created PR: $PR_URL"
69
+ else
70
+ echo "No changes to deploy for $ALIAS or PR creation failed."
71
+ fi