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.
- package/README.md +204 -0
- package/bin/cli.js +5 -0
- package/package.json +31 -0
- package/src/commands/add-store.js +55 -0
- package/src/commands/init.js +78 -0
- package/src/commands/switch.js +27 -0
- package/src/commands/sync.js +42 -0
- package/src/commands/update-workflows.js +18 -0
- package/src/index.js +44 -0
- package/src/lib/config.js +92 -0
- package/src/lib/git.js +90 -0
- package/src/lib/prompts.js +117 -0
- package/src/lib/store-sync.js +142 -0
- package/src/lib/workflows.js +99 -0
- package/src/workflows/multi/hotfix-backport.yml +100 -0
- package/src/workflows/multi/main-to-staging-stores.yml +118 -0
- package/src/workflows/multi/pr-to-live.yml +71 -0
- package/src/workflows/multi/root-to-stores.yml +84 -0
- package/src/workflows/multi/stores-to-root.yml +89 -0
- package/src/workflows/shared/ai-changelog.yml +108 -0
- package/src/workflows/shared/version-bump.yml +122 -0
- package/src/workflows/single/nightly-hotfix.yml +73 -0
- package/src/workflows/single/post-merge-tag.yml +96 -0
- package/src/workflows/single/release-pr-check.yml +136 -0
|
@@ -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
|