Skip to content

Commit 6fdb081

Browse files
Merge pull request #216 from cortexapps/jeff/entity-relationship-reprocess
fix: two-pass re-import for catalog entities with x-cortex-relationships
2 parents 12ac330 + d9e76e2 commit 6fdb081

4 files changed

Lines changed: 457 additions & 2 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
---
2+
name: cli-release
3+
description: Use before creating any commit intended for release — determines correct version bump, ensures commit prefix is right, and walks through the release flow. Triggers on "release", "bump version", "merge to staging", "merge to main", "create a release", "what version will this be", or any PR from a feature branch to staging or main.
4+
---
5+
6+
# CLI Release Skill
7+
8+
Use this skill **before writing the commit message** for any change headed to production.
9+
Getting the prefix wrong means the wrong version ships — and fixing it requires an amended force-push.
10+
11+
---
12+
13+
## Step 1: Determine the correct version bump
14+
15+
1. Check the current published tag:
16+
```bash
17+
git fetch origin --tags
18+
git describe --tags --abbrev=0
19+
```
20+
21+
2. Examine commits since that tag on your branch:
22+
```bash
23+
git log <last-tag>..HEAD --oneline
24+
```
25+
26+
3. Apply the bump rules (highest wins):
27+
| Commit prefix | Bump |
28+
|---|---|
29+
| `feat:` | **minor** — X.(Y+1).0 |
30+
| `fix:` | **patch** — X.Y.(Z+1) |
31+
| `chore:`, `docs:`, other | no bump (don't use for deliverable changes) |
32+
33+
4. **State the expected version out loud** before any commit:
34+
> "This will produce **1.20.1** because all commits use `fix:` (no `feat:`)."
35+
36+
---
37+
38+
## Step 2: Craft the commit message
39+
40+
- Use `fix:` for bug fixes, `feat:` for new features.
41+
- If the prefix and bump disagree, add an explicit keyword override:
42+
- `#patch` — force patch regardless of prefix
43+
- `#minor` — force minor regardless of prefix
44+
- `#major` — force major (rare)
45+
- **Only use `feat:`/`fix:` for commits that touch deliverable paths**: `cortexapps_cli/`, `pyproject.toml`, `poetry.lock`, `tests/`, `docker/`. Use `chore:` for anything else.
46+
47+
Example:
48+
```
49+
fix: two-pass re-import for catalog entities with x-cortex-relationships #patch
50+
```
51+
52+
---
53+
54+
## Step 3: Choose the release path
55+
56+
**Two valid paths — pick based on whether you're batching fixes:**
57+
58+
### Option A: Feature → main directly (single fix, ship now)
59+
Use this for most fixes. CI runs, tests pass, PR auto-merges, publish fires.
60+
61+
```bash
62+
gh pr create --base main --head <feature-branch> --title "fix: <description>"
63+
```
64+
65+
- `test-pr.yml` runs tests and auto-merges when they pass.
66+
- `publish.yml` triggers on the resulting push to `main`.
67+
- The workflow auto-syncs HISTORY.md back to `staging` after publish.
68+
69+
### Option B: Feature → staging → main (batching multiple fixes)
70+
Use this when you want to collect several fixes before cutting a release.
71+
72+
```bash
73+
# Step 1: land feature on staging (tests run + automerge)
74+
gh pr create --base staging --head <feature-branch> --title "fix: <description>"
75+
# Step 2: when ready to release, merge staging → main (triggers publish)
76+
gh pr create --base main --head staging --title "Release X.Y.Z: <description>"
77+
```
78+
79+
---
80+
81+
## Human review (when needed)
82+
83+
By default, PRs auto-merge when tests pass. When you need a human to review first:
84+
85+
1. Open the PR as a **draft** — tests run for fast feedback, but automerge is skipped.
86+
2. Share for review. Reviewer approves while still in draft.
87+
3. Convert to ready (`gh pr ready <PR_NUMBER>`) — tests re-run, automerge fires.
88+
89+
The reviewer approves *before* you convert, so you control the merge timing.
90+
91+
```bash
92+
# Open as draft
93+
gh pr create --draft --base main --head <feature-branch> --title "fix: <description>"
94+
95+
# After reviewer approves, convert to ready
96+
gh pr ready <PR_NUMBER>
97+
```
98+
99+
---
100+
101+
## Step 4: Monitor PR CI and auto-merge (no human in the loop)
102+
103+
CI takes ~5 minutes. Use this polling loop — it waits 5 minutes before the first check, then polls every 60 seconds. This keeps API calls to a minimum.
104+
105+
```bash
106+
echo "Waiting 5 minutes for CI..."; sleep 300
107+
while true; do
108+
STATUS=$(gh pr checks <PR_NUMBER> 2>/dev/null | awk '{print $2}' | sort -u)
109+
echo "$(date '+%H:%M:%S') checks: $STATUS"
110+
if echo "$STATUS" | grep -q "fail"; then
111+
echo "CI failed — inspect with: gh pr checks <PR_NUMBER>"; break
112+
fi
113+
if ! echo "$STATUS" | grep -qE "pending|in_progress|queued"; then
114+
echo "All checks passed — merging"
115+
gh pr merge <PR_NUMBER> --merge
116+
break
117+
fi
118+
sleep 60
119+
done
120+
```
121+
122+
**Critically: do NOT add any commits during or after this loop.** The publish workflow auto-commits `chore: update HISTORY.md for main` after merging. That commit is excluded from the `paths:` trigger filter (`HISTORY.md` is not in `cortexapps_cli/**`, `docker/**`, `pyproject.toml`, or `poetry.lock`), so it will **not** trigger a second publish run. Any commit you push that *does* touch those paths will trigger a new, unwanted build.
123+
124+
---
125+
126+
## Step 5: Monitor the publish workflow after merge
127+
128+
The publish workflow runs 5 parallel jobs under the same `GH_TOKEN` PAT:
129+
`pypi``pypi-deploy-event`, `docker`, `docker-deploy-event`, `homebrew`
130+
131+
Wait 2 minutes for the workflow to start, then poll:
132+
133+
```bash
134+
echo "Waiting 2 minutes for publish workflow to start..."; sleep 120
135+
while true; do
136+
gh run list --limit 5 --branch main --json status,conclusion,name,databaseId \
137+
--jq '.[] | "\(.name) \(.status) \(.conclusion // "running") \(.databaseId)"'
138+
echo "---"
139+
DONE=$(gh run list --limit 1 --branch main --json status --jq '.[0].status')
140+
[ "$DONE" = "completed" ] && break
141+
sleep 60
142+
done
143+
# Check final result
144+
gh run list --limit 1 --branch main --json conclusion --jq '.[0].conclusion'
145+
```
146+
147+
**If a job fails with a rate limit error — do NOT push a new commit.** Re-run only the failed jobs:
148+
```bash
149+
gh run rerun --failed <run-id>
150+
```
151+
152+
**Confirm the tag was cut after a successful run:**
153+
```bash
154+
git fetch origin --tags && git tag --sort=-version:refname | head -3
155+
```
156+
157+
---
158+
159+
## Step 5: Homebrew dependency caveat
160+
161+
`mislav/bump-homebrew-formula-action` **cannot** update `resource` blocks for Python dependencies.
162+
If `pyproject.toml` or `poetry.lock` changed dependency versions, manually update `cortexapps/homebrew-tap/Formula/cortexapps-cli.rb` resource blocks after release.
163+
164+
---
165+
166+
## Quick-reference: what lives where
167+
168+
| Thing | Location |
169+
|---|---|
170+
| Versioning rules (full reference) | `CLAUDE.md` lines 165–227 |
171+
| Changelog prefixes (`add:`, `change:`, `remove:`) | `CLAUDE.md` lines 219–224 |
172+
| GitHub Actions release workflow | `.github/workflows/publish.yml` |
173+
| Homebrew formula (local copy) | `homebrew/cortexapps-cli.rb` |
174+
| Re-run failed jobs | `gh run rerun --failed <run-id>` |

.github/workflows/test-pr.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ on:
1616
pull_request:
1717
branches:
1818
- staging
19+
- main
1920
paths:
2021
- 'cortexapps_cli/**'
2122
- 'tests/**'
@@ -68,3 +69,18 @@ jobs:
6869
- name: Test with pytest
6970
run: |
7071
just test-all
72+
73+
automerge:
74+
needs: test
75+
# Only auto-merge on non-draft PRs. For PRs requiring human review, keep as
76+
# draft until approved, then convert to ready — that re-triggers this workflow.
77+
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
78+
runs-on: ubuntu-latest
79+
permissions:
80+
pull-requests: write
81+
contents: write
82+
steps:
83+
- name: Auto-merge PR
84+
env:
85+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
86+
run: gh pr merge ${{ github.event.pull_request.number }} --merge --repo ${{ github.repository }}

cortexapps_cli/commands/backup.py

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import datetime
2+
import time
23
from typing import Optional
34
from typing import List
45
from typing_extensions import Annotated
@@ -7,7 +8,6 @@
78
import os
89
import tempfile
910
import sys
10-
from io import StringIO
1111
from contextlib import redirect_stdout, redirect_stderr
1212
from rich import print, print_json
1313
from rich.console import Console
@@ -471,6 +471,18 @@ def import_relationships_file(file_info):
471471

472472
return ("entity-relationships", len(results) - failed_count, [(fp, et, em) for rt, fp, et, em in results if et])
473473

474+
def _has_relationships(file_path):
475+
"""Check if a catalog YAML file contains x-cortex-relationships."""
476+
try:
477+
with open(file_path) as f:
478+
content = yaml.safe_load(f)
479+
info = content.get('info', {})
480+
relationships = info.get('x-cortex-relationships')
481+
return relationships is not None and len(relationships) > 0
482+
except Exception:
483+
return False
484+
485+
474486
def _import_catalog(ctx, directory):
475487
results = []
476488
failed_count = 0
@@ -493,7 +505,7 @@ def import_catalog_file(file_info):
493505
except Exception as e:
494506
return (filename, file_path, type(e).__name__, str(e))
495507

496-
# Import all files in parallel
508+
# Pass 1: Import all files
497509
with ThreadPoolExecutor(max_workers=30) as executor:
498510
futures = {executor.submit(import_catalog_file, file_info): file_info[0] for file_info in files}
499511
results = []
@@ -506,6 +518,113 @@ def import_catalog_file(file_info):
506518
if failed_count > 0:
507519
print(f"\n Total catalog import failures: {failed_count}")
508520

521+
# Pass 2: Delete and re-create entities with x-cortex-relationships.
522+
# On first creation (pass 1), the relationship processor runs synchronously but
523+
# the entity hasn't been committed to the DB yet, so it caches a failed state.
524+
# Subsequent updates don't clear this cache. Deleting the entity clears the cache,
525+
# so the fresh re-create triggers proper relationship processing.
526+
#
527+
# Two-wave approach handles multi-tier hierarchies: Wave 1 re-creates all
528+
# relationship entities (e.g., clusters → services, where services are stable from
529+
# pass 1). Wave 2 re-creates entities whose destinations were also recreated in
530+
# wave 1 (e.g., accounts → clusters), ensuring destinations exist before the
531+
# relationship processor runs for those dependent sources.
532+
relationship_files = [(fn, fp) for fn, fp in files if _has_relationships(fp)]
533+
if relationship_files:
534+
print(f"\n Re-importing {len(relationship_files)} entities with relationships...")
535+
536+
# Collect all tags being recreated in pass 2
537+
pass2_tags = set()
538+
for fn, fp in relationship_files:
539+
try:
540+
with open(fp) as f:
541+
content = yaml.safe_load(f)
542+
tag = content.get('info', {}).get('x-cortex-tag')
543+
if tag:
544+
pass2_tags.add(tag)
545+
except Exception:
546+
pass
547+
548+
def delete_entity(file_info):
549+
filename, file_path = file_info
550+
try:
551+
with open(file_path) as f:
552+
content = yaml.safe_load(f)
553+
tag = content.get('info', {}).get('x-cortex-tag')
554+
if tag:
555+
catalog.delete(ctx, tag=tag)
556+
except Exception:
557+
pass # Entity may not exist; that's fine
558+
559+
def create_entity(file_info):
560+
filename, file_path = file_info
561+
print(f" Re-importing: {filename}")
562+
try:
563+
with open(file_path) as f:
564+
catalog.create(ctx, file_input=f, _print=False)
565+
return (filename, file_path, None, None)
566+
except typer.Exit as e:
567+
return (filename, file_path, "HTTP", "Validation or HTTP error")
568+
except Exception as e:
569+
return (filename, file_path, type(e).__name__, str(e))
570+
571+
def has_pass2_destination(file_path):
572+
"""Return True if any of this entity's relationship destinations are
573+
also being recreated in pass 2 (i.e., they need to exist first)."""
574+
try:
575+
with open(file_path) as f:
576+
content = yaml.safe_load(f)
577+
info = content.get('info', {})
578+
for rel in info.get('x-cortex-relationships', []):
579+
for dest in rel.get('destinations', []):
580+
if dest.get('tag') in pass2_tags:
581+
return True
582+
except Exception:
583+
pass
584+
return False
585+
586+
reprocess_results = []
587+
588+
# Wave 1: Delete all, then create all. This establishes every entity
589+
# in the DB and resolves relationships whose destinations are stable
590+
# (i.e., not being recreated in this pass).
591+
with ThreadPoolExecutor(max_workers=30) as executor:
592+
futures = {executor.submit(delete_entity, fi): fi[0] for fi in relationship_files}
593+
for future in as_completed(futures):
594+
future.result()
595+
596+
time.sleep(2) # Allow deletes to commit before re-creating
597+
598+
with ThreadPoolExecutor(max_workers=30) as executor:
599+
futures = {executor.submit(create_entity, fi): fi[0] for fi in relationship_files}
600+
for future in as_completed(futures):
601+
reprocess_results.append(future.result())
602+
603+
# Wave 2: For entities whose destinations were also recreated in wave 1,
604+
# do another delete+create now that those destinations exist in the DB.
605+
dependent_files = [(fn, fp) for fn, fp in relationship_files if has_pass2_destination(fp)]
606+
if dependent_files:
607+
time.sleep(5) # Let wave 1 entities settle in the DB
608+
609+
with ThreadPoolExecutor(max_workers=30) as executor:
610+
futures = {executor.submit(delete_entity, fi): fi[0] for fi in dependent_files}
611+
for future in as_completed(futures):
612+
future.result()
613+
614+
time.sleep(2)
615+
616+
with ThreadPoolExecutor(max_workers=30) as executor:
617+
futures = {executor.submit(create_entity, fi): fi[0] for fi in dependent_files}
618+
for future in as_completed(futures):
619+
reprocess_results.append(future.result())
620+
621+
reprocess_failed = sum(1 for fn, fp, et, em in reprocess_results if et)
622+
if reprocess_failed > 0:
623+
print(f"\n Total catalog re-import failures: {reprocess_failed}")
624+
failed_count += reprocess_failed
625+
else:
626+
print(f" Note: relationships are processed asynchronously and will be visible within 30 seconds.")
627+
509628
return ("catalog", len(results) - failed_count, [(fp, et, em) for fn, fp, et, em in results if et])
510629

511630
def _import_plugins(ctx, directory):

0 commit comments

Comments
 (0)