From 42cb80c780381f864035a6bec65312046e6f548d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 10 Aug 2026 16:03:31 -0700 Subject: [PATCH 01/83] docs: add GitHub Actions deploy solution design spec Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- ...0-github-actions-deploy-solution-design.md | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-10-github-actions-deploy-solution-design.md diff --git a/docs/superpowers/specs/2026-08-10-github-actions-deploy-solution-design.md b/docs/superpowers/specs/2026-08-10-github-actions-deploy-solution-design.md new file mode 100644 index 0000000..acfafeb --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-github-actions-deploy-solution-design.md @@ -0,0 +1,277 @@ +# GitHub Actions Deploy Solution Design + +**Date:** 2026-08-10 +**Linear:** [CX-6](https://linear.app/cortexio/issue/CX-6/deploys-github-actions) + +## Overview + +A Cortex solution that demonstrates deploy tracking via GitHub Actions. Serves both pre-sales (polished demo, end-to-end in minutes) and post-sales (real-world template customers adapt for production). + +--- + +## Solution Structure + +``` +cortexapps_cli/solutions/github-actions-deploy/ +├── README.md +├── catalog/ +│ └── github-actions-demo.yaml +├── scorecards/ +│ └── deploy-health.yaml +├── _templates/ +│ └── cortex-deploy.yml # GH Actions workflow seeded into user's repo +└── setup.py # Post-install setup script + +cortexapps_cli/solutions/_lib/ +└── setup_base.py # Shared SolutionSetup base class +``` + +### Key Conventions +- `_templates/` — files destined for a user's external repo, not their Cortex workspace +- `_lib/` — shared infrastructure not installed into Cortex +- Entity creation handled by backup import format (same as all other solutions) +- `setup.py` presence signals to `cortex solutions install` that post-install setup is available + +--- + +## Entity: `github-actions-demo` + +A standard service entity scoped to the demo via a group tag: + +```yaml +openapi: "3.0.0" +info: + title: GitHub Actions Demo + x-cortex-tag: github-actions-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via GitHub Actions. + x-cortex-definition: {} + x-cortex-groups: + - demo-github-actions-deploys +``` + +--- + +## GitHub Actions Workflow (`_templates/cortex-deploy.yml`) + +Two jobs with explicit dependency — deploy notification only fires if build succeeds: + +```yaml +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: echo "Hello, Cortex deploys!" + + notify-cortex: + needs: build + runs-on: ubuntu-latest + steps: + - name: Register deploy in Cortex + run: | + curl -s -X POST \ + "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d '{ + "sha": "${{ github.sha }}", + "environment": "production", + "type": "DEPLOY", + "title": "Triggered by ${{ github.actor }}", + "deployer": { "name": "${{ github.actor }}" }, + "customData": { + "branch": "${{ github.ref_name }}", + "runId": "${{ github.run_id }}", + "runUrl": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "trigger": "${{ github.event_name }}" + } + }' +``` + +Two GitHub Actions secrets required: `CORTEX_API_KEY`, `CORTEX_BASE_URL`. + +--- + +## Scorecard: Deploy Health + +Scoped to the `demo-github-actions-deploys` group to avoid affecting existing services. + +```yaml +tag: deploy-health +name: Deploy Health +description: Measures deployment cadence. Scoped to demo-github-actions-deploys group by default. +filter: + kind: GENERIC + types: + include: + - service + query: hasGroup("demo-github-actions-deploys") +ladder: + levels: + - name: Bronze + rank: 1 + color: "#CD7F32" + - name: Silver + rank: 2 + color: "#C0C0C0" + - name: Gold + rank: 3 + color: "#D7AC58" +rules: + - title: Has at least one deploy + expression: deploys().count() > 0 + level: Bronze + + - title: Deployed in the last 30 days + expression: deploys(lookback=duration("P30D")).count() > 0 + level: Silver + + - title: Deployed in the last 7 days + expression: deploys(lookback=duration("P7D")).count() > 0 + level: Gold +``` + +**Production note (in README):** Remove the group filter to apply the scorecard to all services. Customers can also add `demo-github-actions-deploys` to any existing service to opt it in. + +--- + +## Setup Infrastructure + +### Shared Base Class (`solutions/_lib/setup_base.py`) + +```python +class SolutionSetup: + def steps(self) -> list[tuple[str, callable]]: + """Subclass returns ordered list of (label, fn) tuples.""" + ... + + def prompt(self, key, message, env_var=None, default=None, secret=False): + """Prompt with env var fallback. Masks secrets. Caches answers.""" + ... + + def confirm(self, message) -> bool: + """Y|N prompt, returns bool.""" + ... + + def already_done(self, key) -> bool: + """Check idempotency state from ~/.cortex/setup-.json.""" + ... + + def mark_done(self, key): + """Persist step completion to state file.""" + ... + + def run(self): + """Collect prompts, then execute steps with progress display and error handling.""" + ... +``` + +State file: `~/.cortex/setup-.json` — keyed per step, so re-runs skip completed steps and retry failed ones. + +### Solution Setup Script (`github-actions-deploy/setup.py`) + +Subclasses `SolutionSetup`. Prompts collected upfront: + +``` +GitHub token (or set GITHUB_TOKEN): ******** +GitHub org or username []: +Repository name [cortex-deploy-demo]: +Cortex API key (or set CORTEX_API_KEY): ******** +Cortex base URL (or set CORTEX_BASE_URL): https://api.getcortexapp.com +``` + +GitHub org/username defaults to the authenticated user derived via `GET https://api.github.com/user`. Supports org override for company repos. + +Step execution: + +``` +[1/5] Creating GitHub repository... ✓ (skips if already exists) +[2/5] Seeding Cortex deploy workflow... ✓ (skips if already seeded) +[3/5] Setting CORTEX_API_KEY secret... ✓ +[4/5] Setting CORTEX_BASE_URL secret... ✓ + +Ready to trigger your first workflow run? [Y/n]: Y +[5/5] Triggering workflow... ✓ + +Done! Watch your first deploy appear at: + https://.getcortexapp.com/catalog/github-actions-demo +``` + +All steps are idempotent: +- Repo creation: `GET /repos/{owner}/{repo}` first, skip if 200 +- Workflow seeding: check file SHA, skip if content unchanged +- Secrets: always safe to overwrite +- Workflow trigger: only on explicit Y confirmation + +--- + +## CLI Integration + +### `cortex solutions install` + +After backup import completes, if a `setup.py` exists in the solution: + +``` +This solution includes a GitHub Actions setup script. +Configure GitHub Actions now? [Y/n]: +``` + +- **Y** → runs setup script +- **N** → prints: `Run setup later with: cortex solutions post-install -s github-actions-deploy` +- **`--skip-post-install-setup`** flag → skips prompt entirely, same "run later" note + +### New: `cortex solutions post-install -s ` + +Invokes the solution's `setup.py` directly. Solutions without a `setup.py` return: +`No post-install setup available for this solution.` + +--- + +## README (Solution Browser + Reference) + +```markdown +--- +name: GitHub Actions Deploy Tracking +description: Track deployments from GitHub Actions in Cortex, with a deploy health + scorecard measuring delivery cadence. +--- + +## What's Included +- **Entity:** `github-actions-demo` service +- **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency +- **GitHub Actions workflow:** Two-job workflow (build → deploy notification) +- **Setup script:** Interactive wizard that creates and seeds a GitHub repo end-to-end + +## Quick Start +1. Install the solution: + cortex solutions install -s github-actions-deploy + +2. Follow the post-install setup prompts, or run later: + cortex solutions post-install -s github-actions-deploy + +## How It Works +The included GitHub Actions workflow fires a deploy event to Cortex after every +successful build. The `notify-cortex` job only runs if the `build` job succeeds, +demonstrating conditional deploy tracking. + +## Customizing for Production +- Point the workflow at your real entity by replacing `github-actions-demo` with your service tag +- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` secrets to your real repos +- The Deploy Health scorecard is scoped to `demo-github-actions-deploys` to avoid affecting + your existing services. To roll it out broadly, remove the group filter from the scorecard. + To opt in individual services, add the `demo-github-actions-deploys` group to them. +``` + +--- + +## What's Not In Scope + +- No Cortex workflow (in-app) — the GitHub Actions workflow is the demo artifact +- No plugin/visualization — deploys surface natively in the Cortex entity page +- No custom entity type — uses standard `service` type for broad scorecard applicability From da1da6d5b523c2698febfa33316b9ed366116b4a Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 10 Aug 2026 16:21:05 -0700 Subject: [PATCH 02/83] docs: add GitHub Actions deploy solution implementation plan Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- ...26-08-10-github-actions-deploy-solution.md | 1123 +++++++++++++++++ 1 file changed, 1123 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-github-actions-deploy-solution.md diff --git a/docs/superpowers/plans/2026-08-10-github-actions-deploy-solution.md b/docs/superpowers/plans/2026-08-10-github-actions-deploy-solution.md new file mode 100644 index 0000000..cafbf93 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-github-actions-deploy-solution.md @@ -0,0 +1,1123 @@ +# GitHub Actions Deploy Solution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `github-actions-deploy` Cortex solution with a demo entity, deploy health scorecard, GitHub Actions workflow template, and an interactive post-install setup script that creates and seeds a GitHub repo end-to-end. + +**Architecture:** Four layers: (1) solution content files installed via existing backup import; (2) a shared `SolutionSetup` base class in `solutions/_lib/` for reusable setup infrastructure; (3) a solution-specific `setup.py` using the GitHub API to create/seed a repo and set secrets; (4) CLI additions — `--skip-post-install-setup` on `install` and a new `post-install` subcommand. + +**Tech Stack:** Python 3.11+, Typer, requests (existing), PyNaCl (new — for GitHub secret encryption) + +## Global Constraints + +- All commits include `Linear: CX-6` in body +- Branch: `cx-6-github-actions-deploy-solution` off `main` +- `_`-prefixed dirs already excluded from `_list_solution_tags` (line 155 of solutions.py) — no change needed +- `requests` is already a dependency — no change needed +- Only new dependency: `PyNaCl >= 1.5.0` +- Solution tag: `github-actions-deploy` +- Demo entity tag: `github-actions-demo` +- Demo group: `demo-github-actions-deploys` +- State file: `~/.cortex/setup-{solution_tag}.json` + +--- + +## File Map + +**Create:** +- `cortexapps_cli/solutions/github-actions-deploy/README.md` +- `cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml` +- `cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml` +- `cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml` +- `cortexapps_cli/solutions/github-actions-deploy/setup.py` +- `cortexapps_cli/solutions/_lib/__init__.py` +- `cortexapps_cli/solutions/_lib/setup_base.py` +- `tests/test_setup_base.py` +- `tests/test_github_actions_setup.py` +- `tests/test_solutions_postinstall.py` + +**Modify:** +- `pyproject.toml` — add PyNaCl dependency +- `cortexapps_cli/commands/solutions.py` — add `_has_post_install`, `_run_post_install_script`, `post_install` subcommand, `--skip-post-install-setup` on `install` + +--- + +### Task 1: Create Feature Branch + +- [ ] **Step 1: Create branch** + +```bash +git checkout -b cx-6-github-actions-deploy-solution +``` + +- [ ] **Step 2: Verify** + +```bash +git branch --show-current +``` +Expected: `cx-6-github-actions-deploy-solution` + +--- + +### Task 2: Solution Content Files + +**Files:** +- Create: `cortexapps_cli/solutions/github-actions-deploy/README.md` +- Create: `cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml` +- Create: `cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml` +- Create: `cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml` + +**Interfaces:** +- Produces: installable solution discoverable by `cortex solutions list` and `cortex solutions info -s github-actions-deploy` + +- [ ] **Step 1: Create README.md** + +`cortexapps_cli/solutions/github-actions-deploy/README.md`: +```markdown +--- +name: GitHub Actions Deploy Tracking +description: Track deployments from GitHub Actions in Cortex, with a deploy health scorecard measuring delivery cadence. +--- + +## What's Included + +- **Entity:** `github-actions-demo` service — a sample entity to receive deploy events +- **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency +- **GitHub Actions workflow:** A two-job workflow (build → deploy notification) to seed into a GitHub repo +- **Setup script:** Interactive wizard that creates and seeds a GitHub repo end-to-end + +## Quick Start + +1. Install the solution: + + ``` + cortex solutions install -s github-actions-deploy + ``` + +2. Follow the post-install setup prompts, or run later: + + ``` + cortex solutions post-install -s github-actions-deploy + ``` + +## How It Works + +The included GitHub Actions workflow fires a deploy event to Cortex after every successful build. +The `notify-cortex` job only runs if the `build` job succeeds, demonstrating conditional deploy tracking. + +## Customizing for Production + +- Point the workflow at your real entity by replacing `github-actions-demo` with your service tag +- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` secrets to your real repos +- The Deploy Health scorecard is scoped to `demo-github-actions-deploys` to avoid affecting your + existing services. To roll it out broadly, remove the group filter from the scorecard. + To opt in individual services, add the `demo-github-actions-deploys` group to them. +``` + +- [ ] **Step 2: Create catalog entity** + +`cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml`: +```yaml +openapi: "3.0.0" +info: + title: GitHub Actions Demo + x-cortex-tag: github-actions-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via GitHub Actions. + x-cortex-definition: {} + x-cortex-groups: + - demo-github-actions-deploys +``` + +- [ ] **Step 3: Create scorecard** + +`cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml`: +```yaml +tag: deploy-health +name: Deploy Health +description: Measures deployment cadence for services using GitHub Actions deploy tracking. Scoped to demo-github-actions-deploys group by default — remove the filter to apply to all services. +draft: false +notifications: + enabled: true + scoreDropNotificationsEnabled: true +exemptions: + enabled: true + autoApprove: false +evaluation: + window: 24 +filter: + kind: GENERIC + types: + include: + - service + query: hasGroup("demo-github-actions-deploys") +ladder: + name: Default Ladder + levels: + - name: Bronze + rank: 1 + description: Service has at least one recorded deployment. + color: "#CD7F32" + - name: Silver + rank: 2 + description: Service has deployed within the last 30 days. + color: "#C0C0C0" + - name: Gold + rank: 3 + description: Service has deployed within the last 7 days. + color: "#D7AC58" +rules: + - title: Has at least one deploy + description: At least one deployment event has been recorded for this service. + expression: deploys().count() > 0 + weight: 1 + level: Bronze + + - title: Deployed in the last 30 days + description: A deployment was recorded within the past 30 days. + expression: deploys(lookback=duration("P30D")).count() > 0 + weight: 1 + level: Silver + + - title: Deployed in the last 7 days + description: A deployment was recorded within the past 7 days, indicating an active delivery cadence. + expression: deploys(lookback=duration("P7D")).count() > 0 + weight: 1 + level: Gold +``` + +- [ ] **Step 4: Create GitHub Actions workflow template** + +`cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml`: +```yaml +name: Cortex Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: echo "Hello, Cortex deploys!" + + notify-cortex: + needs: build + runs-on: ubuntu-latest + steps: + - name: Register deploy in Cortex + run: | + curl -s -f -X POST \ + "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d '{ + "sha": "${{ github.sha }}", + "environment": "production", + "type": "DEPLOY", + "title": "Triggered by ${{ github.actor }}", + "deployer": { "name": "${{ github.actor }}" }, + "customData": { + "branch": "${{ github.ref_name }}", + "runId": "${{ github.run_id }}", + "runUrl": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "trigger": "${{ github.event_name }}" + } + }' +``` + +- [ ] **Step 5: Verify solution is discoverable** + +```bash +poetry run cortex solutions list +poetry run cortex solutions info -s github-actions-deploy +``` +Expected: `github-actions-deploy` appears in list; README renders with "What's Included" section. + +- [ ] **Step 6: Commit** + +```bash +git add cortexapps_cli/solutions/github-actions-deploy/ +git commit -m "$(cat <<'EOF' +add: github-actions-deploy solution content files + +Catalog entity, deploy health scorecard, GitHub Actions workflow template, +and README for the GitHub Actions deploy tracking solution. + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +### Task 3: SolutionSetup Base Class + +**Files:** +- Create: `cortexapps_cli/solutions/_lib/__init__.py` +- Create: `cortexapps_cli/solutions/_lib/setup_base.py` +- Create: `tests/test_setup_base.py` + +**Interfaces:** +- Produces: + - `SolutionSetup` importable from `cortexapps_cli.solutions._lib.setup_base` + - `SolutionSetup.solution_tag: str` — set by subclass + - `SolutionSetup.prompt(key, message, env_var=None, default=None, secret=False) -> str` + - `SolutionSetup.confirm(message, default=True) -> bool` + - `SolutionSetup.already_done(key) -> bool` + - `SolutionSetup.mark_done(key) -> None` + - `SolutionSetup.collect_prompts() -> None` — abstract + - `SolutionSetup.steps() -> list[tuple[str, callable]]` — abstract + - `SolutionSetup.post_steps() -> None` — optional hook, default no-op + - `SolutionSetup.run() -> None` + +- [ ] **Step 1: Write failing tests** + +`tests/test_setup_base.py`: +```python +import json +import pytest +from pathlib import Path +from unittest.mock import patch +from cortexapps_cli.solutions._lib.setup_base import SolutionSetup + + +class ConcreteSetup(SolutionSetup): + solution_tag = "test-solution" + steps_called = [] + + def collect_prompts(self): + self._answers["name"] = self.prompt("name", "Your name", default="Alice") + + def steps(self): + return [("Do thing", lambda: ConcreteSetup.steps_called.append(True))] + + +def test_prompt_uses_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("MY_VAR", "from-env") + setup = ConcreteSetup(state_dir=tmp_path) + result = setup.prompt("key", "Enter value", env_var="MY_VAR") + assert result == "from-env" + + +def test_prompt_uses_default_on_empty_input(tmp_path, monkeypatch): + monkeypatch.delenv("MY_VAR", raising=False) + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + result = setup.prompt("key", "Enter value", default="default-val") + assert result == "default-val" + + +def test_prompt_uses_user_input(tmp_path, monkeypatch): + monkeypatch.delenv("MY_VAR", raising=False) + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="user-value"): + result = setup.prompt("key", "Enter value", default="default-val") + assert result == "user-value" + + +def test_confirm_returns_true_for_y(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="y"): + assert setup.confirm("Do it?") is True + + +def test_confirm_returns_false_for_n(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="n"): + assert setup.confirm("Do it?") is False + + +def test_confirm_uses_default_on_empty(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + assert setup.confirm("Do it?", default=True) is True + with patch("builtins.input", return_value=""): + assert setup.confirm("Do it?", default=False) is False + + +def test_already_done_false_initially(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + assert setup.already_done("step1") is False + + +def test_mark_done_persists(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + setup.mark_done("step1") + assert setup.already_done("step1") is True + + +def test_mark_done_persists_across_instances(tmp_path): + ConcreteSetup(state_dir=tmp_path).mark_done("step1") + assert ConcreteSetup(state_dir=tmp_path).already_done("step1") is True + + +def test_state_file_path(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + assert setup._state_file == tmp_path / "setup-test-solution.json" + + +def test_post_steps_called_after_steps(tmp_path): + post_called = [] + + class SetupWithPost(ConcreteSetup): + def post_steps(self): + post_called.append(True) + + setup = SetupWithPost(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + setup.run() + assert post_called == [True] +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +poetry run pytest tests/test_setup_base.py -v +``` +Expected: `ModuleNotFoundError` for `cortexapps_cli.solutions._lib.setup_base` + +- [ ] **Step 3: Create `_lib/__init__.py`** + +`cortexapps_cli/solutions/_lib/__init__.py` — empty file. + +- [ ] **Step 4: Implement `setup_base.py`** + +`cortexapps_cli/solutions/_lib/setup_base.py`: +```python +import json +import os +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + + +class SolutionSetup(ABC): + """ + Base class for solution post-install setup scripts. + Subclasses define solution_tag, collect_prompts(), and steps(). + """ + + solution_tag: str # must be set by subclass + + def __init__(self, state_dir: Optional[Path] = None): + self._answers: dict = {} + state_dir = state_dir or Path.home() / ".cortex" + state_dir.mkdir(parents=True, exist_ok=True) + self._state_file = state_dir / f"setup-{self.solution_tag}.json" + self._state: dict = self._load_state() + + def _load_state(self) -> dict: + if self._state_file.exists(): + try: + return json.loads(self._state_file.read_text()) + except (json.JSONDecodeError, OSError): + return {} + return {} + + def _save_state(self) -> None: + self._state_file.write_text(json.dumps(self._state, indent=2)) + + def prompt( + self, + key: str, + message: str, + env_var: Optional[str] = None, + default: Optional[str] = None, + secret: bool = False, + ) -> str: + """Prompt for a value. Uses env var if set, then prompts with optional default.""" + if env_var: + env_val = os.environ.get(env_var) + if env_val: + masked = "********" if secret else env_val + print(f"{message} [{masked} from {env_var}]") + self._answers[key] = env_val + return env_val + + prompt_str = message + if default: + prompt_str += f" [{default}]" + prompt_str += ": " + + value = input(prompt_str).strip() + if not value: + value = default or "" + self._answers[key] = value + return value + + def confirm(self, message: str, default: bool = True) -> bool: + """Y|N confirmation prompt.""" + hint = "[Y/n]" if default else "[y/N]" + response = input(f"{message} {hint}: ").strip().lower() + if not response: + return default + return response in ("y", "yes") + + def already_done(self, key: str) -> bool: + """Return True if this step was previously completed.""" + return self._state.get(key, False) + + def mark_done(self, key: str) -> None: + """Mark a step as completed in the persistent state file.""" + self._state[key] = True + self._save_state() + + @abstractmethod + def collect_prompts(self) -> None: + """Collect all user inputs upfront before executing steps.""" + + @abstractmethod + def steps(self) -> list[tuple[str, callable]]: + """Return ordered list of (label, callable) tuples.""" + + def post_steps(self) -> None: + """Optional hook called after all steps complete. Override in subclass.""" + + def run(self) -> None: + """Collect prompts then execute steps with progress display.""" + self.collect_prompts() + print() + step_list = self.steps() + total = len(step_list) + for i, (label, fn) in enumerate(step_list, 1): + try: + fn() + print(f"[{i}/{total}] {label}... \u2713") + except Exception as e: + print(f"[{i}/{total}] {label}... \u2717 {e}", file=sys.stderr) + raise SystemExit(1) + self.post_steps() +``` + +- [ ] **Step 5: Run tests — verify they pass** + +```bash +poetry run pytest tests/test_setup_base.py -v +``` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add cortexapps_cli/solutions/_lib/ tests/test_setup_base.py +git commit -m "$(cat <<'EOF' +add: SolutionSetup base class for reusable post-install setup scripts + +Provides prompt collection with env var fallback, Y/N confirmation, +idempotency state tracking via ~/.cortex/setup-{solution}.json, step +execution with progress display, and post_steps() hook for subclasses. + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +### Task 4: Add PyNaCl Dependency + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Add to `[tool.poetry.dependencies]` in `pyproject.toml`** + +```toml +PyNaCl = ">=1.5.0" +``` + +- [ ] **Step 2: Install** + +```bash +poetry install +``` + +- [ ] **Step 3: Verify** + +```bash +poetry run python -c "from nacl import encoding, public; print('PyNaCl OK')" +``` +Expected: `PyNaCl OK` + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml poetry.lock +git commit -m "$(cat <<'EOF' +add: PyNaCl dependency for GitHub secret encryption + +Required by github-actions-deploy setup script to encrypt secrets +before storing them via the GitHub API (libsodium sealed box). + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +### Task 5: GitHub Actions Deploy Setup Script + +**Files:** +- Create: `cortexapps_cli/solutions/github-actions-deploy/setup.py` +- Create: `tests/test_github_actions_setup.py` + +**Interfaces:** +- Consumes: `SolutionSetup` from `cortexapps_cli.solutions._lib.setup_base` +- Produces: `GitHubActionsSetup` class and `main()` callable by `cortex solutions post-install` + +- [ ] **Step 1: Write failing tests** + +`tests/test_github_actions_setup.py`: +```python +import base64 +import importlib.util +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def load_setup_module(): + spec = importlib.util.spec_from_file_location( + "github_actions_setup", + "cortexapps_cli/solutions/github-actions-deploy/setup.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def mod(): + return load_setup_module() + + +@pytest.fixture +def setup(mod, tmp_path): + instance = mod.GitHubActionsSetup(state_dir=tmp_path) + instance._answers = { + "github_token": "ghp_test", + "github_owner": "test-org", + "repo_name": "cortex-deploy-demo", + "cortex_api_key": "crt_testkey", + "cortex_base_url": "https://api.getcortexapp.com", + } + return instance + + +def test_get_authenticated_user(setup): + resp = MagicMock(status_code=200) + resp.json.return_value = {"login": "test-user"} + with patch("requests.get", return_value=resp): + assert setup._get_authenticated_user() == "test-user" + + +def test_create_repo_skips_if_exists(setup): + resp = MagicMock(status_code=200) + with patch("requests.get", return_value=resp) as mock_get, \ + patch("requests.post") as mock_post: + setup._create_repo() + mock_get.assert_called_once() + mock_post.assert_not_called() + + +def test_create_repo_creates_when_missing(setup): + user_resp = MagicMock(status_code=200) + user_resp.json.return_value = {"login": "test-org"} + check_resp = MagicMock(status_code=404) + post_resp = MagicMock(status_code=201) + post_resp.json.return_value = {"html_url": "https://github.com/test-org/cortex-deploy-demo"} + + get_responses = [check_resp, user_resp] + with patch("requests.get", side_effect=get_responses), \ + patch("requests.post", return_value=post_resp) as mock_post: + setup._create_repo() + mock_post.assert_called_once() + + +def test_seed_workflow_skips_if_unchanged(setup, tmp_path): + # Read the actual template to simulate matching content + template_path = Path("cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml") + content = template_path.read_text() + content_b64 = base64.b64encode(content.encode()).decode() + + resp = MagicMock(status_code=200) + resp.json.return_value = {"content": content_b64, "sha": "abc123"} + + with patch("requests.get", return_value=resp), \ + patch("requests.put") as mock_put: + setup._seed_workflow() + mock_put.assert_not_called() + + +def test_seed_workflow_creates_when_missing(setup): + get_resp = MagicMock(status_code=404) + put_resp = MagicMock(status_code=201) + put_resp.json.return_value = {"content": {"sha": "abc123"}} + + with patch("requests.get", return_value=get_resp), \ + patch("requests.put", return_value=put_resp) as mock_put: + setup._seed_workflow() + mock_put.assert_called_once() + + +def test_set_secret(setup): + # 32-byte key for valid libsodium public key + dummy_key = base64.b64encode(b"\x00" * 32).decode() + key_resp = MagicMock(status_code=200) + key_resp.json.return_value = {"key_id": "key123", "key": dummy_key} + key_resp.raise_for_status = MagicMock() + + put_resp = MagicMock(status_code=204) + + with patch("requests.get", return_value=key_resp), \ + patch("requests.put", return_value=put_resp) as mock_put: + setup._set_secret("CORTEX_API_KEY", "crt_testkey") + + mock_put.assert_called_once() + call_json = mock_put.call_args.kwargs["json"] + assert "encrypted_value" in call_json + assert call_json["key_id"] == "key123" + + +def test_trigger_workflow(setup): + resp = MagicMock(status_code=204) + with patch("requests.post", return_value=resp) as mock_post: + setup._trigger_workflow() + url = mock_post.call_args.args[0] + assert "dispatches" in url + assert mock_post.call_args.kwargs["json"] == {"ref": "main"} + + +def test_main_callable(mod): + assert callable(mod.main) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +poetry run pytest tests/test_github_actions_setup.py -v +``` +Expected: failures (setup.py missing) + +- [ ] **Step 3: Implement `setup.py`** + +`cortexapps_cli/solutions/github-actions-deploy/setup.py`: +```python +""" +Post-install setup script for the github-actions-deploy solution. +Creates and seeds a GitHub repo with the Cortex deploy workflow. +Run via: cortex solutions post-install -s github-actions-deploy +""" +import base64 +import sys +from pathlib import Path +from typing import Optional + +import requests +from nacl import encoding, public + +try: + from cortexapps_cli.solutions._lib.setup_base import SolutionSetup +except ImportError: + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + from _lib.setup_base import SolutionSetup + +GITHUB_API = "https://api.github.com" +TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" + + +def _encrypt_secret(public_key_b64: str, secret_value: str) -> str: + """Encrypt a secret using the repo's libsodium public key.""" + pk = public.PublicKey(public_key_b64.encode("utf-8"), encoding.Base64Encoder()) + sealed_box = public.SealedBox(pk) + encrypted = sealed_box.encrypt(secret_value.encode("utf-8")) + return base64.b64encode(encrypted).decode("utf-8") + + +class GitHubActionsSetup(SolutionSetup): + solution_tag = "github-actions-deploy" + + def _gh_headers(self) -> dict: + return { + "Authorization": f"Bearer {self._answers['github_token']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _get_authenticated_user(self) -> str: + resp = requests.get(f"{GITHUB_API}/user", headers=self._gh_headers()) + resp.raise_for_status() + return resp.json()["login"] + + def collect_prompts(self) -> None: + self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + + try: + default_owner = self._get_authenticated_user() + except Exception: + default_owner = None + + self.prompt("github_owner", "GitHub org or username", default=default_owner) + self.prompt("repo_name", "Repository name", default="cortex-deploy-demo") + self.prompt("cortex_api_key", "Cortex API key", env_var="CORTEX_API_KEY", secret=True) + self.prompt( + "cortex_base_url", + "Cortex base URL", + env_var="CORTEX_BASE_URL", + default="https://api.getcortexapp.com", + ) + + def steps(self) -> list[tuple[str, callable]]: + return [ + ("Creating GitHub repository", self._create_repo), + ("Seeding Cortex deploy workflow", self._seed_workflow), + ("Setting CORTEX_API_KEY secret", lambda: self._set_secret("CORTEX_API_KEY", self._answers["cortex_api_key"])), + ("Setting CORTEX_BASE_URL secret", lambda: self._set_secret("CORTEX_BASE_URL", self._answers["cortex_base_url"])), + ] + + def post_steps(self) -> None: + print() + if self.confirm("Ready to trigger your first workflow run?", default=True): + try: + self._trigger_workflow() + print(f"[5/5] Triggering workflow... \u2713") + except Exception as e: + print(f"Trigger failed: {e}", file=sys.stderr) + raise SystemExit(1) + + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + base_url = self._answers["cortex_base_url"].rstrip("/") + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + print(f"\nDone! Watch your first deploy appear at:") + print(f" {app_url}/catalog/github-actions-demo") + print(f"\nGitHub repo: https://github.com/{owner}/{repo}") + + def _create_repo(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + check = requests.get(f"{GITHUB_API}/repos/{owner}/{repo}", headers=self._gh_headers()) + if check.status_code == 200: + return # already exists + + user_login = self._get_authenticated_user() + url = f"{GITHUB_API}/user/repos" if owner == user_login else f"{GITHUB_API}/orgs/{owner}/repos" + + resp = requests.post( + url, + headers=self._gh_headers(), + json={ + "name": repo, + "description": "Cortex deploy tracking demo — created by cortex solutions post-install", + "private": False, + "auto_init": True, + }, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create repo: {resp.status_code} {resp.text}") + + def _seed_workflow(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + path = ".github/workflows/cortex-deploy.yml" + content = TEMPLATE_PATH.read_text() + content_b64 = base64.b64encode(content.encode()).decode() + + check = requests.get( + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", + headers=self._gh_headers(), + ) + + payload = {"message": "Add Cortex deploy notification workflow", "content": content_b64} + + if check.status_code == 200: + existing = check.json() + existing_content = base64.b64decode(existing["content"].replace("\n", "")).decode() + if existing_content == content: + return # unchanged + payload["sha"] = existing["sha"] + + resp = requests.put( + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", + headers=self._gh_headers(), + json=payload, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to seed workflow: {resp.status_code} {resp.text}") + + def _set_secret(self, secret_name: str, secret_value: str) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + key_resp = requests.get( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/public-key", + headers=self._gh_headers(), + ) + key_resp.raise_for_status() + key_data = key_resp.json() + + resp = requests.put( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/{secret_name}", + headers=self._gh_headers(), + json={ + "encrypted_value": _encrypt_secret(key_data["key"], secret_value), + "key_id": key_data["key_id"], + }, + ) + if resp.status_code not in (201, 204): + raise RuntimeError(f"Failed to set secret {secret_name}: {resp.status_code} {resp.text}") + + def _trigger_workflow(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + resp = requests.post( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/workflows/cortex-deploy.yml/dispatches", + headers=self._gh_headers(), + json={"ref": "main"}, + ) + if resp.status_code != 204: + raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + + +def main(): + GitHubActionsSetup().run() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +poetry run pytest tests/test_github_actions_setup.py -v +``` +Expected: all PASS + +- [ ] **Step 5: Commit** + +```bash +git add cortexapps_cli/solutions/github-actions-deploy/setup.py tests/test_github_actions_setup.py +git commit -m "$(cat <<'EOF' +add: github-actions-deploy post-install setup script + +Interactive wizard that creates a GitHub repo, seeds the Cortex deploy +workflow, sets CORTEX_API_KEY and CORTEX_BASE_URL secrets, and optionally +triggers the first workflow run. All steps are idempotent. + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +### Task 6: CLI Integration — `post-install` Subcommand + Install Hook + +**Files:** +- Modify: `cortexapps_cli/commands/solutions.py` +- Create: `tests/test_solutions_postinstall.py` + +**Interfaces:** +- Produces: + - `cortex solutions post-install -s github-actions-deploy` → runs setup script + - `cortex solutions post-install -s ai-agents` → "No post-install setup available" + - `cortex solutions install -s github-actions-deploy` → prompts for post-install after import + - `cortex solutions install -s github-actions-deploy --skip-post-install-setup` → skips prompt + +- [ ] **Step 1: Write failing tests** + +`tests/test_solutions_postinstall.py`: +```python +import pytest +from unittest.mock import patch, MagicMock +from typer.testing import CliRunner +from cortexapps_cli.cli import app + +runner = CliRunner() + + +def test_post_install_no_setup_for_ai_agents(): + result = runner.invoke(app, ["solutions", "post-install", "-s", "ai-agents"]) + assert result.exit_code == 0 + assert "No post-install setup available" in result.output + + +def test_post_install_unknown_solution(): + result = runner.invoke(app, ["solutions", "post-install", "-s", "nonexistent-xyz"]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + +def test_post_install_calls_run_for_github_actions(): + with patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run: + runner.invoke(app, ["solutions", "post-install", "-s", "github-actions-deploy"]) + mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +poetry run pytest tests/test_solutions_postinstall.py -v +``` +Expected: failures (`post-install` subcommand doesn't exist yet) + +- [ ] **Step 3: Add `_has_post_install` and `_run_post_install_script` helpers to `solutions.py`** + +Add after the existing `_get_readme` function (after line ~177): + +```python +def _has_post_install(tag: str, path: str | None = None) -> bool: + """Return True if this solution has a post-install setup.py.""" + try: + (_solutions_root(path) / tag / "setup.py").read_bytes() + return True + except Exception: + return False + + +def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None) -> None: + """Find and invoke the solution's setup.py main() function.""" + import importlib.util + + root = _solutions_root(solutions_dir) + try: + with as_file(root / solution_tag / "setup.py") as setup_path: + if not setup_path.exists(): + typer.echo("No post-install setup available for this solution.") + return + spec = importlib.util.spec_from_file_location(f"{solution_tag}_setup", setup_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.main() + except FileNotFoundError: + typer.echo("No post-install setup available for this solution.") +``` + +- [ ] **Step 4: Add `post_install` subcommand to `solutions.py`** + +Add after the `install` command (after line ~661): + +```python +@app.command(name="post-install") +def post_install( + ctx: typer.Context, + solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), +): + """Run post-install setup for a solution.""" + solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None + if solution not in _list_solution_tags(solutions_dir): + avail = ", ".join(_list_solution_tags(solutions_dir)) + typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") + raise typer.Exit(1) + _run_post_install_script(solution, solutions_dir=solutions_dir) +``` + +- [ ] **Step 5: Add `--skip-post-install-setup` to `install` and inject post-install hook** + +In the `install` command signature (line ~603), add the new option: +```python +skip_post_install_setup: bool = typer.Option( + False, + "--skip-post-install-setup", + help="Skip the post-install setup script prompt", + is_flag=True, +), +``` + +After line 644 (end of import report display), before the `if not no_prompt:` block, insert: +```python + # Post-install setup hook — runs before the informational menu + if not no_prompt and not skip_post_install_setup and _has_post_install(solution, solutions_dir): + typer.echo("\nThis solution includes a post-install setup script.") + if typer.confirm("Run setup now?", default=True): + _run_post_install_script(solution, solutions_dir=solutions_dir) + else: + typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") + elif skip_post_install_setup and _has_post_install(solution, solutions_dir): + typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") +``` + +- [ ] **Step 6: Run tests — verify they pass** + +```bash +poetry run pytest tests/test_solutions_postinstall.py -v +``` +Expected: all PASS + +- [ ] **Step 7: Run existing solutions tests for regressions** + +```bash +poetry run pytest tests/test_solutions.py -v +``` +Expected: all PASS + +- [ ] **Step 8: Smoke test CLI** + +```bash +poetry run cortex solutions list +poetry run cortex solutions info -s github-actions-deploy +poetry run cortex solutions post-install -s ai-agents +``` +Expected: `github-actions-deploy` in list; README renders; "No post-install setup available" for ai-agents. + +- [ ] **Step 9: Commit** + +```bash +git add cortexapps_cli/commands/solutions.py tests/test_solutions_postinstall.py +git commit -m "$(cat <<'EOF' +feat: add solutions post-install subcommand and install hook + +- New `cortex solutions post-install -s ` subcommand +- `cortex solutions install` prompts for post-install setup when setup.py present +- `--skip-post-install-setup` flag bypasses the prompt +- Helper functions _has_post_install and _run_post_install_script for reuse + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +## Self-Review Checklist + +| Spec Requirement | Task | +|---|---| +| Solution content files (entity, scorecard, template, README) | Task 2 | +| Entity with `demo-github-actions-deploys` group | Task 2 | +| Scorecard scoped to group with production note | Task 2 | +| `_templates/` convention for external files | Task 2 | +| `SolutionSetup` base class | Task 3 | +| `_lib/` shared directory, excluded from solutions list | Task 3 (already filtered) | +| Two-job GH workflow with `needs: build` | Task 2 | +| customData with branch/runId/runUrl/trigger | Task 2 | +| CORTEX_API_KEY + CORTEX_BASE_URL secrets | Tasks 2, 5 | +| Prompt for GH token, owner (derived), repo, API key, base URL | Task 5 | +| Idempotency per step via API checks | Task 5 | +| "Ready to trigger?" confirm via `post_steps()` | Tasks 3, 5 | +| PyNaCl for secret encryption | Task 4 | +| `cortex solutions post-install -s ` | Task 6 | +| `--skip-post-install-setup` flag | Task 6 | +| Post-install prompt in `install` flow | Task 6 | +| "No post-install setup available" for other solutions | Task 6 | +| "Run later" message when skipped | Task 6 | +| Reusable base class pattern for future solutions | Task 3 | From 69072e291dcc4678dbc274840ccc12b582d6a585 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 08:34:33 -0700 Subject: [PATCH 03/83] add: github-actions-deploy solution content files Catalog entity, deploy health scorecard, GitHub Actions workflow template, and README for the GitHub Actions deploy tracking solution. Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/README.md | 38 ++++++++++++++ .../_templates/cortex-deploy.yml | 37 ++++++++++++++ .../catalog/github-actions-demo.yaml | 9 ++++ .../scorecards/deploy-health.yaml | 51 +++++++++++++++++++ 4 files changed, 135 insertions(+) create mode 100644 cortexapps_cli/solutions/github-actions-deploy/README.md create mode 100644 cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml create mode 100644 cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml create mode 100644 cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml diff --git a/cortexapps_cli/solutions/github-actions-deploy/README.md b/cortexapps_cli/solutions/github-actions-deploy/README.md new file mode 100644 index 0000000..2053a4a --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/README.md @@ -0,0 +1,38 @@ +--- +name: GitHub Actions Deploy Tracking +description: Track deployments from GitHub Actions in Cortex, with a deploy health scorecard measuring delivery cadence. +--- + +## What's Included + +- **Entity:** `github-actions-demo` service — a sample entity to receive deploy events +- **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency +- **GitHub Actions workflow:** A two-job workflow (build → deploy notification) to seed into a GitHub repo +- **Setup script:** Interactive wizard that creates and seeds a GitHub repo end-to-end + +## Quick Start + +1. Install the solution: + + ``` + cortex solutions install -s github-actions-deploy + ``` + +2. Follow the post-install setup prompts, or run later: + + ``` + cortex solutions post-install -s github-actions-deploy + ``` + +## How It Works + +The included GitHub Actions workflow fires a deploy event to Cortex after every successful build. +The `notify-cortex` job only runs if the `build` job succeeds, demonstrating conditional deploy tracking. + +## Customizing for Production + +- Point the workflow at your real entity by replacing `github-actions-demo` with your service tag +- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` secrets to your real repos +- The Deploy Health scorecard is scoped to `demo-github-actions-deploys` to avoid affecting your + existing services. To roll it out broadly, remove the group filter from the scorecard. + To opt in individual services, add the `demo-github-actions-deploys` group to them. diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml new file mode 100644 index 0000000..415893e --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -0,0 +1,37 @@ +name: Cortex Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: echo "Hello, Cortex deploys!" + + notify-cortex: + needs: build + runs-on: ubuntu-latest + steps: + - name: Register deploy in Cortex + run: | + curl -s -f -X POST \ + "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d '{ + "sha": "${{ github.sha }}", + "environment": "production", + "type": "DEPLOY", + "title": "Triggered by ${{ github.actor }}", + "deployer": { "name": "${{ github.actor }}" }, + "customData": { + "branch": "${{ github.ref_name }}", + "runId": "${{ github.run_id }}", + "runUrl": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "trigger": "${{ github.event_name }}" + } + }' diff --git a/cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml b/cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml new file mode 100644 index 0000000..57b9bd4 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml @@ -0,0 +1,9 @@ +openapi: "3.0.0" +info: + title: GitHub Actions Demo + x-cortex-tag: github-actions-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via GitHub Actions. + x-cortex-definition: {} + x-cortex-groups: + - demo-github-actions-deploys diff --git a/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml b/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml new file mode 100644 index 0000000..b3c17e3 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml @@ -0,0 +1,51 @@ +tag: deploy-health +name: Deploy Health +description: Measures deployment cadence for services using GitHub Actions deploy tracking. Scoped to demo-github-actions-deploys group by default — remove the filter to apply to all services. +draft: false +notifications: + enabled: true + scoreDropNotificationsEnabled: true +exemptions: + enabled: true + autoApprove: false +evaluation: + window: 24 +filter: + kind: GENERIC + types: + include: + - service + query: hasGroup("demo-github-actions-deploys") +ladder: + name: Default Ladder + levels: + - name: Bronze + rank: 1 + description: Service has at least one recorded deployment. + color: "#CD7F32" + - name: Silver + rank: 2 + description: Service has deployed within the last 30 days. + color: "#C0C0C0" + - name: Gold + rank: 3 + description: Service has deployed within the last 7 days. + color: "#D7AC58" +rules: + - title: Has at least one deploy + description: At least one deployment event has been recorded for this service. + expression: deploys().count() > 0 + weight: 1 + level: Bronze + + - title: Deployed in the last 30 days + description: A deployment was recorded within the past 30 days. + expression: deploys(lookback=duration("P30D")).count() > 0 + weight: 1 + level: Silver + + - title: Deployed in the last 7 days + description: A deployment was recorded within the past 7 days, indicating an active delivery cadence. + expression: deploys(lookback=duration("P7D")).count() > 0 + weight: 1 + level: Gold From ff780bee5cb70b91576a2a255f6ddaba555fd3ec Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 08:37:05 -0700 Subject: [PATCH 04/83] add: SolutionSetup base class for reusable post-install setup scripts Provides prompt collection with env var fallback, Y/N confirmation, idempotency state tracking via ~/.cortex/setup-{solution}.json, step execution with progress display, and post_steps() hook for subclasses. Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/_lib/__init__.py | 0 cortexapps_cli/solutions/_lib/setup_base.py | 104 ++++++++++++++++++++ tests/test_setup_base.py | 93 +++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 cortexapps_cli/solutions/_lib/__init__.py create mode 100644 cortexapps_cli/solutions/_lib/setup_base.py create mode 100644 tests/test_setup_base.py diff --git a/cortexapps_cli/solutions/_lib/__init__.py b/cortexapps_cli/solutions/_lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py new file mode 100644 index 0000000..0f151c8 --- /dev/null +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -0,0 +1,104 @@ +import json +import os +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + + +class SolutionSetup(ABC): + """ + Base class for solution post-install setup scripts. + Subclasses define solution_tag, collect_prompts(), and steps(). + """ + + solution_tag: str # must be set by subclass + + def __init__(self, state_dir: Optional[Path] = None): + self._answers: dict = {} + state_dir = state_dir or Path.home() / ".cortex" + state_dir.mkdir(parents=True, exist_ok=True) + self._state_file = state_dir / f"setup-{self.solution_tag}.json" + self._state: dict = self._load_state() + + def _load_state(self) -> dict: + if self._state_file.exists(): + try: + return json.loads(self._state_file.read_text()) + except (json.JSONDecodeError, OSError): + return {} + return {} + + def _save_state(self) -> None: + self._state_file.write_text(json.dumps(self._state, indent=2)) + + def prompt( + self, + key: str, + message: str, + env_var: Optional[str] = None, + default: Optional[str] = None, + secret: bool = False, + ) -> str: + """Prompt for a value. Uses env var if set, then prompts with optional default.""" + if env_var: + env_val = os.environ.get(env_var) + if env_val: + masked = "********" if secret else env_val + print(f"{message} [{masked} from {env_var}]") + self._answers[key] = env_val + return env_val + + prompt_str = message + if default: + prompt_str += f" [{default}]" + prompt_str += ": " + + value = input(prompt_str).strip() + if not value: + value = default or "" + self._answers[key] = value + return value + + def confirm(self, message: str, default: bool = True) -> bool: + """Y|N confirmation prompt.""" + hint = "[Y/n]" if default else "[y/N]" + response = input(f"{message} {hint}: ").strip().lower() + if not response: + return default + return response in ("y", "yes") + + def already_done(self, key: str) -> bool: + """Return True if this step was previously completed.""" + return self._state.get(key, False) + + def mark_done(self, key: str) -> None: + """Mark a step as completed in the persistent state file.""" + self._state[key] = True + self._save_state() + + @abstractmethod + def collect_prompts(self) -> None: + """Collect all user inputs upfront before executing steps.""" + + @abstractmethod + def steps(self) -> list[tuple[str, callable]]: + """Return ordered list of (label, callable) tuples.""" + + def post_steps(self) -> None: + """Optional hook called after all steps complete. Override in subclass.""" + + def run(self) -> None: + """Collect prompts then execute steps with progress display.""" + self.collect_prompts() + print() + step_list = self.steps() + total = len(step_list) + for i, (label, fn) in enumerate(step_list, 1): + try: + fn() + print(f"[{i}/{total}] {label}... \u2713") + except Exception as e: + print(f"[{i}/{total}] {label}... \u2717 {e}", file=sys.stderr) + raise SystemExit(1) + self.post_steps() diff --git a/tests/test_setup_base.py b/tests/test_setup_base.py new file mode 100644 index 0000000..e88d8d3 --- /dev/null +++ b/tests/test_setup_base.py @@ -0,0 +1,93 @@ +import json +import pytest +from pathlib import Path +from unittest.mock import patch +from cortexapps_cli.solutions._lib.setup_base import SolutionSetup + + +class ConcreteSetup(SolutionSetup): + solution_tag = "test-solution" + steps_called = [] + + def collect_prompts(self): + self._answers["name"] = self.prompt("name", "Your name", default="Alice") + + def steps(self): + return [("Do thing", lambda: ConcreteSetup.steps_called.append(True))] + + +def test_prompt_uses_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("MY_VAR", "from-env") + setup = ConcreteSetup(state_dir=tmp_path) + result = setup.prompt("key", "Enter value", env_var="MY_VAR") + assert result == "from-env" + + +def test_prompt_uses_default_on_empty_input(tmp_path, monkeypatch): + monkeypatch.delenv("MY_VAR", raising=False) + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + result = setup.prompt("key", "Enter value", default="default-val") + assert result == "default-val" + + +def test_prompt_uses_user_input(tmp_path, monkeypatch): + monkeypatch.delenv("MY_VAR", raising=False) + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="user-value"): + result = setup.prompt("key", "Enter value", default="default-val") + assert result == "user-value" + + +def test_confirm_returns_true_for_y(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="y"): + assert setup.confirm("Do it?") is True + + +def test_confirm_returns_false_for_n(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="n"): + assert setup.confirm("Do it?") is False + + +def test_confirm_uses_default_on_empty(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + assert setup.confirm("Do it?", default=True) is True + with patch("builtins.input", return_value=""): + assert setup.confirm("Do it?", default=False) is False + + +def test_already_done_false_initially(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + assert setup.already_done("step1") is False + + +def test_mark_done_persists(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + setup.mark_done("step1") + assert setup.already_done("step1") is True + + +def test_mark_done_persists_across_instances(tmp_path): + ConcreteSetup(state_dir=tmp_path).mark_done("step1") + assert ConcreteSetup(state_dir=tmp_path).already_done("step1") is True + + +def test_state_file_path(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + assert setup._state_file == tmp_path / "setup-test-solution.json" + + +def test_post_steps_called_after_steps(tmp_path): + post_called = [] + + class SetupWithPost(ConcreteSetup): + def post_steps(self): + post_called.append(True) + + setup = SetupWithPost(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + setup.run() + assert post_called == [True] From 4890a31a3adf582c7d5eaa385462a8714ec3bb74 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 08:39:29 -0700 Subject: [PATCH 05/83] add: PyNaCl dependency for GitHub secret encryption Required by github-actions-deploy setup script to encrypt secrets before storing them via the GitHub API (libsodium sealed box). Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- poetry.lock | 171 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 171 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 736b113..0701c2d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -24,6 +24,120 @@ files = [ {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, ] +[[package]] +name = "cffi" +version = "2.1.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be"}, + {file = "cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9"}, + {file = "cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41"}, + {file = "cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa"}, + {file = "cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3"}, + {file = "cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0"}, + {file = "cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7"}, + {file = "cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac"}, + {file = "cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d"}, + {file = "cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13"}, + {file = "cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c"}, + {file = "cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48"}, + {file = "cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f"}, + {file = "cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4"}, + {file = "cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e"}, + {file = "cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7"}, + {file = "cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac"}, + {file = "cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"}, + {file = "cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5"}, + {file = "cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66"}, + {file = "cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3"}, + {file = "cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -531,6 +645,19 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pyee" version = "13.0.1" @@ -564,6 +691,48 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pynacl" +version = "1.6.2" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, + {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} + +[package.extras] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] + [[package]] name = "pyotp" version = "2.9.0" @@ -949,4 +1118,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "1d38560a601329ebf7335f055ebbb9629a94868da642e24d8bcb40dde535acd0" +content-hash = "a33f8151f2743a1985772b305babc1a056f9bc397278e90e53f4ec8ec134e163" diff --git a/pyproject.toml b/pyproject.toml index e0de63c..008ef74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ pyyaml = ">= 6.0.1, < 7" urllib3 = ">= 2.7.0" typer = ">=0.15,<1.0" typing_extensions = ">=3.7.4.3" +PyNaCl = ">=1.5.0" [tool.poetry.scripts] cortex = "cortexapps_cli.cli:app" From 5f90136048176043dec9453446eda4604323de0d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 08:41:42 -0700 Subject: [PATCH 06/83] add: github-actions-deploy post-install setup script Interactive wizard that creates a GitHub repo, seeds the Cortex deploy workflow, sets CORTEX_API_KEY and CORTEX_BASE_URL secrets, and optionally triggers the first workflow run. All steps are idempotent. Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 184 ++++++++++++++++++ tests/test_github_actions_setup.py | 123 ++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 cortexapps_cli/solutions/github-actions-deploy/setup.py create mode 100644 tests/test_github_actions_setup.py diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py new file mode 100644 index 0000000..0560e15 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -0,0 +1,184 @@ +""" +Post-install setup script for the github-actions-deploy solution. +Creates and seeds a GitHub repo with the Cortex deploy workflow. +Run via: cortex solutions post-install -s github-actions-deploy +""" +import base64 +import sys +from pathlib import Path +from typing import Optional + +import requests +from nacl import encoding, public + +try: + from cortexapps_cli.solutions._lib.setup_base import SolutionSetup +except ImportError: + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + from _lib.setup_base import SolutionSetup + +GITHUB_API = "https://api.github.com" +TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" + + +def _encrypt_secret(public_key_b64: str, secret_value: str) -> str: + """Encrypt a secret using the repo's libsodium public key.""" + pk = public.PublicKey(public_key_b64.encode("utf-8"), encoding.Base64Encoder()) + sealed_box = public.SealedBox(pk) + encrypted = sealed_box.encrypt(secret_value.encode("utf-8")) + return base64.b64encode(encrypted).decode("utf-8") + + +class GitHubActionsSetup(SolutionSetup): + solution_tag = "github-actions-deploy" + + def _gh_headers(self) -> dict: + return { + "Authorization": f"Bearer {self._answers['github_token']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _get_authenticated_user(self) -> str: + resp = requests.get(f"{GITHUB_API}/user", headers=self._gh_headers()) + resp.raise_for_status() + return resp.json()["login"] + + def collect_prompts(self) -> None: + self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + + try: + default_owner = self._get_authenticated_user() + except Exception: + default_owner = None + + self.prompt("github_owner", "GitHub org or username", default=default_owner) + self.prompt("repo_name", "Repository name", default="cortex-deploy-demo") + self.prompt("cortex_api_key", "Cortex API key", env_var="CORTEX_API_KEY", secret=True) + self.prompt( + "cortex_base_url", + "Cortex base URL", + env_var="CORTEX_BASE_URL", + default="https://api.getcortexapp.com", + ) + + def steps(self) -> list[tuple[str, callable]]: + return [ + ("Creating GitHub repository", self._create_repo), + ("Seeding Cortex deploy workflow", self._seed_workflow), + ("Setting CORTEX_API_KEY secret", lambda: self._set_secret("CORTEX_API_KEY", self._answers["cortex_api_key"])), + ("Setting CORTEX_BASE_URL secret", lambda: self._set_secret("CORTEX_BASE_URL", self._answers["cortex_base_url"])), + ] + + def post_steps(self) -> None: + print() + if self.confirm("Ready to trigger your first workflow run?", default=True): + try: + self._trigger_workflow() + print(f"[5/5] Triggering workflow... \u2713") + except Exception as e: + print(f"Trigger failed: {e}", file=sys.stderr) + raise SystemExit(1) + + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + base_url = self._answers["cortex_base_url"].rstrip("/") + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + print(f"\nDone! Watch your first deploy appear at:") + print(f" {app_url}/catalog/github-actions-demo") + print(f"\nGitHub repo: https://github.com/{owner}/{repo}") + + def _create_repo(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + check = requests.get(f"{GITHUB_API}/repos/{owner}/{repo}", headers=self._gh_headers()) + if check.status_code == 200: + return # already exists + + user_login = self._get_authenticated_user() + url = f"{GITHUB_API}/user/repos" if owner == user_login else f"{GITHUB_API}/orgs/{owner}/repos" + + resp = requests.post( + url, + headers=self._gh_headers(), + json={ + "name": repo, + "description": "Cortex deploy tracking demo — created by cortex solutions post-install", + "private": False, + "auto_init": True, + }, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create repo: {resp.status_code} {resp.text}") + + def _seed_workflow(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + path = ".github/workflows/cortex-deploy.yml" + content = TEMPLATE_PATH.read_text() + content_b64 = base64.b64encode(content.encode()).decode() + + check = requests.get( + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", + headers=self._gh_headers(), + ) + + payload = {"message": "Add Cortex deploy notification workflow", "content": content_b64} + + if check.status_code == 200: + existing = check.json() + existing_content = base64.b64decode(existing["content"].replace("\n", "")).decode() + if existing_content == content: + return # unchanged + payload["sha"] = existing["sha"] + + resp = requests.put( + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", + headers=self._gh_headers(), + json=payload, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to seed workflow: {resp.status_code} {resp.text}") + + def _set_secret(self, secret_name: str, secret_value: str) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + key_resp = requests.get( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/public-key", + headers=self._gh_headers(), + ) + key_resp.raise_for_status() + key_data = key_resp.json() + + resp = requests.put( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/{secret_name}", + headers=self._gh_headers(), + json={ + "encrypted_value": _encrypt_secret(key_data["key"], secret_value), + "key_id": key_data["key_id"], + }, + ) + if resp.status_code not in (201, 204): + raise RuntimeError(f"Failed to set secret {secret_name}: {resp.status_code} {resp.text}") + + def _trigger_workflow(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + resp = requests.post( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/workflows/cortex-deploy.yml/dispatches", + headers=self._gh_headers(), + json={"ref": "main"}, + ) + if resp.status_code != 204: + raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + + +def main(): + GitHubActionsSetup().run() + + +if __name__ == "__main__": + main() diff --git a/tests/test_github_actions_setup.py b/tests/test_github_actions_setup.py new file mode 100644 index 0000000..5778064 --- /dev/null +++ b/tests/test_github_actions_setup.py @@ -0,0 +1,123 @@ +import base64 +import importlib.util +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def load_setup_module(): + spec = importlib.util.spec_from_file_location( + "github_actions_setup", + "cortexapps_cli/solutions/github-actions-deploy/setup.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def mod(): + return load_setup_module() + + +@pytest.fixture +def setup(mod, tmp_path): + instance = mod.GitHubActionsSetup(state_dir=tmp_path) + instance._answers = { + "github_token": "ghp_test", + "github_owner": "test-org", + "repo_name": "cortex-deploy-demo", + "cortex_api_key": "crt_testkey", + "cortex_base_url": "https://api.getcortexapp.com", + } + return instance + + +def test_get_authenticated_user(setup): + resp = MagicMock(status_code=200) + resp.json.return_value = {"login": "test-user"} + with patch("requests.get", return_value=resp): + assert setup._get_authenticated_user() == "test-user" + + +def test_create_repo_skips_if_exists(setup): + resp = MagicMock(status_code=200) + with patch("requests.get", return_value=resp) as mock_get, \ + patch("requests.post") as mock_post: + setup._create_repo() + mock_get.assert_called_once() + mock_post.assert_not_called() + + +def test_create_repo_creates_when_missing(setup): + user_resp = MagicMock(status_code=200) + user_resp.json.return_value = {"login": "test-org"} + check_resp = MagicMock(status_code=404) + post_resp = MagicMock(status_code=201) + post_resp.json.return_value = {"html_url": "https://github.com/test-org/cortex-deploy-demo"} + + get_responses = [check_resp, user_resp] + with patch("requests.get", side_effect=get_responses), \ + patch("requests.post", return_value=post_resp) as mock_post: + setup._create_repo() + mock_post.assert_called_once() + + +def test_seed_workflow_skips_if_unchanged(setup, tmp_path): + # Read the actual template to simulate matching content + template_path = Path("cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml") + content = template_path.read_text() + content_b64 = base64.b64encode(content.encode()).decode() + + resp = MagicMock(status_code=200) + resp.json.return_value = {"content": content_b64, "sha": "abc123"} + + with patch("requests.get", return_value=resp), \ + patch("requests.put") as mock_put: + setup._seed_workflow() + mock_put.assert_not_called() + + +def test_seed_workflow_creates_when_missing(setup): + get_resp = MagicMock(status_code=404) + put_resp = MagicMock(status_code=201) + put_resp.json.return_value = {"content": {"sha": "abc123"}} + + with patch("requests.get", return_value=get_resp), \ + patch("requests.put", return_value=put_resp) as mock_put: + setup._seed_workflow() + mock_put.assert_called_once() + + +def test_set_secret(setup): + # Valid Curve25519 public key (generated via nacl.public.PrivateKey.generate()) + from nacl.public import PrivateKey + dummy_key = base64.b64encode(PrivateKey.generate().public_key._public_key).decode() + key_resp = MagicMock(status_code=200) + key_resp.json.return_value = {"key_id": "key123", "key": dummy_key} + key_resp.raise_for_status = MagicMock() + + put_resp = MagicMock(status_code=204) + + with patch("requests.get", return_value=key_resp), \ + patch("requests.put", return_value=put_resp) as mock_put: + setup._set_secret("CORTEX_API_KEY", "crt_testkey") + + mock_put.assert_called_once() + call_json = mock_put.call_args.kwargs["json"] + assert "encrypted_value" in call_json + assert call_json["key_id"] == "key123" + + +def test_trigger_workflow(setup): + resp = MagicMock(status_code=204) + with patch("requests.post", return_value=resp) as mock_post: + setup._trigger_workflow() + url = mock_post.call_args.args[0] + assert "dispatches" in url + assert mock_post.call_args.kwargs["json"] == {"ref": "main"} + + +def test_main_callable(mod): + assert callable(mod.main) From ee5332e778bb4b8f35934d9285072f8b11fc6465 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 08:43:57 -0700 Subject: [PATCH 07/83] fix: enforce 404-only repo creation, drop unused import, use public key bytes() - _create_repo now raises RuntimeError on any non-200, non-404 response when checking repo existence (previously proceeded to POST on any non-200) - Remove unused `from typing import Optional` import from setup.py - Use bytes() instead of private ._public_key attribute when serializing the nacl PublicKey in test_set_secret - Add test_create_repo_raises_on_unexpected_status to cover 403 case Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 4 ++-- tests/test_github_actions_setup.py | 12 +++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 0560e15..1b1975e 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -6,8 +6,6 @@ import base64 import sys from pathlib import Path -from typing import Optional - import requests from nacl import encoding, public @@ -95,6 +93,8 @@ def _create_repo(self) -> None: check = requests.get(f"{GITHUB_API}/repos/{owner}/{repo}", headers=self._gh_headers()) if check.status_code == 200: return # already exists + if check.status_code != 404: + raise RuntimeError(f"Unexpected status checking repo existence: {check.status_code} {check.text}") user_login = self._get_authenticated_user() url = f"{GITHUB_API}/user/repos" if owner == user_login else f"{GITHUB_API}/orgs/{owner}/repos" diff --git a/tests/test_github_actions_setup.py b/tests/test_github_actions_setup.py index 5778064..fa83ef3 100644 --- a/tests/test_github_actions_setup.py +++ b/tests/test_github_actions_setup.py @@ -64,6 +64,16 @@ def test_create_repo_creates_when_missing(setup): mock_post.assert_called_once() +def test_create_repo_raises_on_unexpected_status(setup): + resp = MagicMock(status_code=403) + resp.text = "Forbidden" + with patch("requests.get", return_value=resp), \ + patch("requests.post") as mock_post: + with pytest.raises(RuntimeError, match="Unexpected status checking repo existence: 403"): + setup._create_repo() + mock_post.assert_not_called() + + def test_seed_workflow_skips_if_unchanged(setup, tmp_path): # Read the actual template to simulate matching content template_path = Path("cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml") @@ -93,7 +103,7 @@ def test_seed_workflow_creates_when_missing(setup): def test_set_secret(setup): # Valid Curve25519 public key (generated via nacl.public.PrivateKey.generate()) from nacl.public import PrivateKey - dummy_key = base64.b64encode(PrivateKey.generate().public_key._public_key).decode() + dummy_key = base64.b64encode(bytes(PrivateKey.generate().public_key)).decode() key_resp = MagicMock(status_code=200) key_resp.json.return_value = {"key_id": "key123", "key": dummy_key} key_resp.raise_for_status = MagicMock() From 35fbce9dc4405ef309deafb8ea3397c923aeb46e Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 08:46:48 -0700 Subject: [PATCH 08/83] feat: add solutions post-install subcommand and install hook - New `cortex solutions post-install -s ` subcommand - `cortex solutions install` prompts for post-install setup when setup.py present - `--skip-post-install-setup` flag bypasses the prompt - Helper functions _has_post_install and _run_post_install_script for reuse Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 56 ++++++++++++++++++++++++++++ tests/test_solutions_postinstall.py | 24 ++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 tests/test_solutions_postinstall.py diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index c8e7d1f..20f8c0c 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -175,6 +175,33 @@ def _get_readme(tag: str, path: str | None = None) -> str | None: return None +def _has_post_install(tag: str, path: str | None = None) -> bool: + """Return True if this solution has a post-install setup.py.""" + try: + (_solutions_root(path) / tag / "setup.py").read_bytes() + return True + except Exception: + return False + + +def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None) -> None: + """Find and invoke the solution's setup.py main() function.""" + import importlib.util + + root = _solutions_root(solutions_dir) + try: + with as_file(root / solution_tag / "setup.py") as setup_path: + if not setup_path.exists(): + typer.echo("No post-install setup available for this solution.") + return + spec = importlib.util.spec_from_file_location(f"{solution_tag}_setup", setup_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.main() + except FileNotFoundError: + typer.echo("No post-install setup available for this solution.") + + def _extract_first_codeblock(text: str) -> str | None: """Return content of the first fenced code block.""" m = re.search(r"```[^\n]*\n(.*?)```", text, re.DOTALL) @@ -604,6 +631,11 @@ def install( ctx: typer.Context, solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), no_prompt: bool = typer.Option(False, "--no-prompt", help="Skip the post-install interactive menu"), + skip_post_install_setup: bool = typer.Option( + False, + "--skip-post-install-setup", + help="Skip the post-install setup script prompt", + ), ): """Install a solution.""" solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None @@ -643,6 +675,16 @@ def _do_import() -> None: else: typer.echo(output) + # Post-install setup hook — runs before the informational menu + if not no_prompt and not skip_post_install_setup and _has_post_install(solution, solutions_dir): + typer.echo("\nThis solution includes a post-install setup script.") + if typer.confirm("Run setup now?", default=True): + _run_post_install_script(solution, solutions_dir=solutions_dir) + else: + typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") + elif skip_post_install_setup and _has_post_install(solution, solutions_dir): + typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") + if not no_prompt: readme = _get_readme(solution, solutions_dir) if readme: @@ -660,6 +702,20 @@ def _do_import() -> None: _post_install_menu(readme, import_report=output, entity_tags=entity_tags, ui_url=ui_url) +@app.command(name="post-install") +def post_install( + ctx: typer.Context, + solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), +): + """Run post-install setup for a solution.""" + solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None + if solution not in _list_solution_tags(solutions_dir): + avail = ", ".join(_list_solution_tags(solutions_dir)) + typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") + raise typer.Exit(1) + _run_post_install_script(solution, solutions_dir=solutions_dir) + + @app.command() def uninstall( ctx: typer.Context, diff --git a/tests/test_solutions_postinstall.py b/tests/test_solutions_postinstall.py new file mode 100644 index 0000000..dc5edc9 --- /dev/null +++ b/tests/test_solutions_postinstall.py @@ -0,0 +1,24 @@ +import pytest +from unittest.mock import patch, MagicMock +from typer.testing import CliRunner +from cortexapps_cli.cli import app + +runner = CliRunner() + + +def test_post_install_no_setup_for_ai_agents(): + result = runner.invoke(app, ["solutions", "post-install", "-s", "ai-agents"]) + assert result.exit_code == 0 + assert "No post-install setup available" in result.output + + +def test_post_install_unknown_solution(): + result = runner.invoke(app, ["solutions", "post-install", "-s", "nonexistent-xyz"]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + +def test_post_install_calls_run_for_github_actions(): + with patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run: + runner.invoke(app, ["solutions", "post-install", "-s", "github-actions-deploy"]) + mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None) From a2d41d24271bb980a699d40d74bd9fb1d7f94329 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 08:49:58 -0700 Subject: [PATCH 09/83] fix: address reviewer issues in solutions post-install - Add test coverage for --skip-post-install-setup flag (skips script, prints 'Run setup later' message) - Add test coverage for install prompt when setup.py present (user answers 'y', script is called) - Add is_flag=True to --skip-post-install-setup Option - Remove dead code: inner `if not setup_path.exists()` branch inside as_file() block in _run_post_install_script (unreachable; outer except FileNotFoundError already handles missing file) Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 4 +--- tests/test_solutions_postinstall.py | 31 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 20f8c0c..bdb978d 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -191,9 +191,6 @@ def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None root = _solutions_root(solutions_dir) try: with as_file(root / solution_tag / "setup.py") as setup_path: - if not setup_path.exists(): - typer.echo("No post-install setup available for this solution.") - return spec = importlib.util.spec_from_file_location(f"{solution_tag}_setup", setup_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) @@ -635,6 +632,7 @@ def install( False, "--skip-post-install-setup", help="Skip the post-install setup script prompt", + is_flag=True, ), ): """Install a solution.""" diff --git a/tests/test_solutions_postinstall.py b/tests/test_solutions_postinstall.py index dc5edc9..5c71552 100644 --- a/tests/test_solutions_postinstall.py +++ b/tests/test_solutions_postinstall.py @@ -22,3 +22,34 @@ def test_post_install_calls_run_for_github_actions(): with patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run: runner.invoke(app, ["solutions", "post-install", "-s", "github-actions-deploy"]) mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None) + + +def test_install_skip_post_install_setup_flag_skips_script(): + """--skip-post-install-setup prints 'Run setup later' and does NOT call the script.""" + with patch("cortexapps_cli.commands.solutions._has_post_install", return_value=True), \ + patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run, \ + patch("cortexapps_cli.commands.solutions._build_client", return_value=MagicMock()), \ + patch("cortexapps_cli.commands.backup.import_tenant"): + result = runner.invoke( + app, + ["-k", "fake", "solutions", "install", "-s", "github-actions-deploy", + "--skip-post-install-setup", "--no-prompt"], + ) + assert "Run setup later with: cortex solutions post-install -s github-actions-deploy" in result.output + mock_run.assert_not_called() + + +def test_install_prompts_and_runs_post_install_on_yes(): + """Without --skip-post-install-setup, answering 'y' at the prompt calls the script.""" + with patch("cortexapps_cli.commands.solutions._has_post_install", return_value=True), \ + patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run, \ + patch("cortexapps_cli.commands.solutions._build_client", return_value=MagicMock()), \ + patch("cortexapps_cli.commands.backup.import_tenant"), \ + patch("cortexapps_cli.commands.solutions._post_install_menu"): + result = runner.invoke( + app, + ["-k", "fake", "solutions", "install", "-s", "github-actions-deploy"], + input="y\n", + ) + assert "This solution includes a post-install setup script." in result.output + mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None) From d61c35df6013af5f0f76f26bb059cd86aff6c014 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 09:05:18 -0700 Subject: [PATCH 10/83] fix: use customMetrics expressions in deploy-health scorecard deploys() is not a valid Cortex CQL function. Replace with customMetrics(key="deploy-count", lookback=...) expressions, and update the GitHub Actions workflow template to post a deploy-count metric alongside the deploy event so the scorecard evaluates correctly. Linear: CX-6 Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/cortex-deploy.yml | 34 ++++++++++++------- .../scorecards/deploy-health.yaml | 12 +++---- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 415893e..36be12a 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -18,20 +18,30 @@ jobs: steps: - name: Register deploy in Cortex run: | + TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + + # Record the deploy event curl -s -f -X POST \ "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ -H "Content-Type: application/json" \ - -d '{ - "sha": "${{ github.sha }}", - "environment": "production", - "type": "DEPLOY", - "title": "Triggered by ${{ github.actor }}", - "deployer": { "name": "${{ github.actor }}" }, - "customData": { - "branch": "${{ github.ref_name }}", - "runId": "${{ github.run_id }}", - "runUrl": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", - "trigger": "${{ github.event_name }}" + -d "{ + \"sha\": \"${{ github.sha }}\", + \"environment\": \"production\", + \"type\": \"DEPLOY\", + \"title\": \"Triggered by ${{ github.actor }}\", + \"deployer\": { \"name\": \"${{ github.actor }}\" }, + \"customData\": { + \"branch\": \"${{ github.ref_name }}\", + \"runId\": \"${{ github.run_id }}\", + \"runUrl\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\", + \"trigger\": \"${{ github.event_name }}\" } - }' + }" + + # Post deploy-count metric (powers the Deploy Health scorecard) + curl -s -f -X POST \ + "${{ secrets.CORTEX_BASE_URL }}/api/v1/eng-intel/custom-metrics/deploy-count/entity/github-actions-demo" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d "{\"timestamp\": \"${TIMESTAMP}\", \"value\": 1}" diff --git a/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml b/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml index b3c17e3..9f97d85 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml @@ -1,6 +1,6 @@ tag: deploy-health name: Deploy Health -description: Measures deployment cadence for services using GitHub Actions deploy tracking. Scoped to demo-github-actions-deploys group by default — remove the filter to apply to all services. +description: Measures deployment cadence for services using GitHub Actions deploy tracking. The deploy-count custom metric is posted by the included GitHub Actions workflow on each successful build. Scoped to demo-github-actions-deploys group by default — remove the filter to apply to all services. draft: false notifications: enabled: true @@ -21,7 +21,7 @@ ladder: levels: - name: Bronze rank: 1 - description: Service has at least one recorded deployment. + description: Service has at least one recorded deployment in the last year. color: "#CD7F32" - name: Silver rank: 2 @@ -33,19 +33,19 @@ ladder: color: "#D7AC58" rules: - title: Has at least one deploy - description: At least one deployment event has been recorded for this service. - expression: deploys().count() > 0 + description: At least one deployment has been recorded via the deploy-count custom metric in the last year. + expression: customMetrics(key="deploy-count", lookback=duration("P1Y")).length > 0 weight: 1 level: Bronze - title: Deployed in the last 30 days description: A deployment was recorded within the past 30 days. - expression: deploys(lookback=duration("P30D")).count() > 0 + expression: customMetrics(key="deploy-count", lookback=duration("P30D")).length > 0 weight: 1 level: Silver - title: Deployed in the last 7 days description: A deployment was recorded within the past 7 days, indicating an active delivery cadence. - expression: deploys(lookback=duration("P7D")).count() > 0 + expression: customMetrics(key="deploy-count", lookback=duration("P7D")).length > 0 weight: 1 level: Gold From c9681cf5e519f5375499c6d92bc62d48f3eac116 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 10:08:33 -0700 Subject: [PATCH 11/83] fix: improve post-install setup UX for CX-6 - Use OSC 8 hyperlinks in post_steps output (clickable in iTerm2) - Secret prompts now hidden via getpass (no echo on terminal) - API key and base URL pre-filled from active CLI session - Show "Use current Cortex API key?" / "Use current Cortex base URL?" instead of generic env var prompts - Install hook shows solution-specific SETUP_DESCRIPTION instead of generic message - Pass CLI client context to post-install script for both install and post-install commands Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 36 ++++++++++--- cortexapps_cli/solutions/_lib/setup_base.py | 13 +++-- .../solutions/github-actions-deploy/setup.py | 54 +++++++++++++++---- 3 files changed, 82 insertions(+), 21 deletions(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index bdb978d..a935c0d 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -184,8 +184,8 @@ def _has_post_install(tag: str, path: str | None = None) -> bool: return False -def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None) -> None: - """Find and invoke the solution's setup.py main() function.""" +def _load_setup_module(solution_tag: str, solutions_dir: str | None = None): + """Load a solution's setup.py module. Returns the module or None if not found.""" import importlib.util root = _solutions_root(solutions_dir) @@ -194,9 +194,31 @@ def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None spec = importlib.util.spec_from_file_location(f"{solution_tag}_setup", setup_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - module.main() + return module except FileNotFoundError: + return None + + +def _get_setup_description(solution_tag: str, solutions_dir: str | None = None) -> str: + """Return the SETUP_DESCRIPTION from a solution's setup.py, or a generic fallback.""" + module = _load_setup_module(solution_tag, solutions_dir) + if module: + return getattr(module, "SETUP_DESCRIPTION", "This solution includes a post-install setup script.") + return "This solution includes a post-install setup script." + + +def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None, ctx=None) -> None: + """Find and invoke the solution's setup.py main() function.""" + module = _load_setup_module(solution_tag, solutions_dir) + if module is None: typer.echo("No post-install setup available for this solution.") + return + kwargs = {} + if ctx and ctx.obj and "client" in ctx.obj: + client = ctx.obj["client"] + kwargs["cortex_api_key"] = client.api_key + kwargs["cortex_base_url"] = client.base_url + module.main(**kwargs) def _extract_first_codeblock(text: str) -> str | None: @@ -675,9 +697,10 @@ def _do_import() -> None: # Post-install setup hook — runs before the informational menu if not no_prompt and not skip_post_install_setup and _has_post_install(solution, solutions_dir): - typer.echo("\nThis solution includes a post-install setup script.") + desc = _get_setup_description(solution, solutions_dir) + typer.echo(f"\n{desc}") if typer.confirm("Run setup now?", default=True): - _run_post_install_script(solution, solutions_dir=solutions_dir) + _run_post_install_script(solution, solutions_dir=solutions_dir, ctx=ctx) else: typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") elif skip_post_install_setup and _has_post_install(solution, solutions_dir): @@ -711,7 +734,8 @@ def post_install( avail = ", ".join(_list_solution_tags(solutions_dir)) typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") raise typer.Exit(1) - _run_post_install_script(solution, solutions_dir=solutions_dir) + ctx.obj["client"] = _build_client(ctx) + _run_post_install_script(solution, solutions_dir=solutions_dir, ctx=ctx) @app.command() diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py index 0f151c8..30c082f 100644 --- a/cortexapps_cli/solutions/_lib/setup_base.py +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -1,3 +1,4 @@ +import getpass import json import os import sys @@ -45,16 +46,20 @@ def prompt( env_val = os.environ.get(env_var) if env_val: masked = "********" if secret else env_val - print(f"{message} [{masked} from {env_var}]") - self._answers[key] = env_val - return env_val + if self.confirm(f"{message} [{masked} from {env_var}]", default=True): + self._answers[key] = env_val + return env_val + # User declined — fall through to manual prompt prompt_str = message if default: prompt_str += f" [{default}]" prompt_str += ": " - value = input(prompt_str).strip() + if secret: + value = getpass.getpass(prompt_str).strip() + else: + value = input(prompt_str).strip() if not value: value = default or "" self._answers[key] = value diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 1b1975e..17a2957 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -3,6 +3,11 @@ Creates and seeds a GitHub repo with the Cortex deploy workflow. Run via: cortex solutions post-install -s github-actions-deploy """ + +SETUP_DESCRIPTION = ( + "This solution includes a post-install setup script that will create a GitHub " + "repository, seed it with the Cortex deploy workflow, and configure the required secrets." +) import base64 import sys from pathlib import Path @@ -19,6 +24,12 @@ TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" +def _hyperlink(url: str, text: str = None) -> str: + """Return an OSC 8 hyperlink for terminals that support it (iTerm2, etc.).""" + label = text if text is not None else url + return f"\033]8;;{url}\033\\{label}\033]8;;\033\\" + + def _encrypt_secret(public_key_b64: str, secret_value: str) -> str: """Encrypt a secret using the repo's libsodium public key.""" pk = public.PublicKey(public_key_b64.encode("utf-8"), encoding.Base64Encoder()) @@ -30,6 +41,11 @@ def _encrypt_secret(public_key_b64: str, secret_value: str) -> str: class GitHubActionsSetup(SolutionSetup): solution_tag = "github-actions-deploy" + def __init__(self, cortex_api_key: str = None, cortex_base_url: str = None, **kwargs): + super().__init__(**kwargs) + self._session_api_key = cortex_api_key + self._session_base_url = cortex_base_url + def _gh_headers(self) -> dict: return { "Authorization": f"Bearer {self._answers['github_token']}", @@ -52,13 +68,27 @@ def collect_prompts(self) -> None: self.prompt("github_owner", "GitHub org or username", default=default_owner) self.prompt("repo_name", "Repository name", default="cortex-deploy-demo") - self.prompt("cortex_api_key", "Cortex API key", env_var="CORTEX_API_KEY", secret=True) - self.prompt( - "cortex_base_url", - "Cortex base URL", - env_var="CORTEX_BASE_URL", - default="https://api.getcortexapp.com", - ) + + if self._session_api_key: + if self.confirm("Use current Cortex API key?", default=True): + self._answers["cortex_api_key"] = self._session_api_key + else: + self.prompt("cortex_api_key", "Cortex API key", secret=True) + else: + self.prompt("cortex_api_key", "Cortex API key", env_var="CORTEX_API_KEY", secret=True) + + if self._session_base_url: + if self.confirm(f"Use current Cortex base URL [{self._session_base_url}]?", default=True): + self._answers["cortex_base_url"] = self._session_base_url + else: + self.prompt("cortex_base_url", "Cortex base URL", default=self._session_base_url) + else: + self.prompt( + "cortex_base_url", + "Cortex base URL", + env_var="CORTEX_BASE_URL", + default="https://api.getcortexapp.com", + ) def steps(self) -> list[tuple[str, callable]]: return [ @@ -82,9 +112,11 @@ def post_steps(self) -> None: repo = self._answers["repo_name"] base_url = self._answers["cortex_base_url"].rstrip("/") app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + cortex_url = f"{app_url}/catalog/github-actions-demo" + gh_url = f"https://github.com/{owner}/{repo}" print(f"\nDone! Watch your first deploy appear at:") - print(f" {app_url}/catalog/github-actions-demo") - print(f"\nGitHub repo: https://github.com/{owner}/{repo}") + print(f" {_hyperlink(cortex_url)}") + print(f"\nGitHub repo: {_hyperlink(gh_url)}") def _create_repo(self) -> None: owner = self._answers["github_owner"] @@ -176,8 +208,8 @@ def _trigger_workflow(self) -> None: raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") -def main(): - GitHubActionsSetup().run() +def main(**kwargs): + GitHubActionsSetup(**kwargs).run() if __name__ == "__main__": From e0a4bb9f27c56df4df4378c5a2c1a32edffe97be Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 10:14:44 -0700 Subject: [PATCH 12/83] fix: correct Cortex app URL pattern in post-install output for CX-6 Use /admin/resources?tag= instead of /catalog/ Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 17a2957..52ae022 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -112,7 +112,7 @@ def post_steps(self) -> None: repo = self._answers["repo_name"] base_url = self._answers["cortex_base_url"].rstrip("/") app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url - cortex_url = f"{app_url}/catalog/github-actions-demo" + cortex_url = f"{app_url}/admin/resources?tag=github-actions-demo" gh_url = f"https://github.com/{owner}/{repo}" print(f"\nDone! Watch your first deploy appear at:") print(f" {_hyperlink(cortex_url)}") From 22ec396cb7f731145010338552c09f696c89bfd7 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 10:20:51 -0700 Subject: [PATCH 13/83] feat: add Cortex async workflow for GitHub Actions deploy trigger for CX-6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New workflow: github-actions-trigger-deploy (HTTP_REQUEST_ASYNC) Triggers GitHub Actions dispatch and waits for callback from the workflow run. Surfaces conclusion, sha, and run URL in the Cortex workflow result. - Updated cortex-deploy.yml template: Accepts optional cortex_callback_url workflow_dispatch input. Final step in notify-cortex job POSTs back to the callback URL on success, completing the async Cortex workflow run with structured output. - Updated setup.py post_steps(): Replaces direct GitHub API trigger with Cortex workflow run via API. Polls for COMPLETED/FAILED/CANCELLED status (up to 5 min). Marks first_deploy in persistent state on success. Non-fatal on failure — prints retry instructions instead of exiting. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/cortex-deploy.yml | 20 ++++ .../solutions/github-actions-deploy/setup.py | 91 +++++++++++++++---- .../workflows/trigger-github-deploy.yaml | 43 +++++++++ 3 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 36be12a..fac3758 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -4,6 +4,11 @@ on: push: branches: [main] workflow_dispatch: + inputs: + cortex_callback_url: + description: 'Cortex async workflow callback URL (set automatically when triggered via Cortex workflow)' + required: false + default: '' jobs: build: @@ -45,3 +50,18 @@ jobs: -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ -H "Content-Type: application/json" \ -d "{\"timestamp\": \"${TIMESTAMP}\", \"value\": 1}" + + - name: Notify Cortex workflow callback + if: ${{ inputs.cortex_callback_url != '' }} + run: | + curl -s -f -X POST \ + "${{ inputs.cortex_callback_url }}" \ + -H "Content-Type: application/json" \ + -d "{ + \"output\": { + \"conclusion\": \"success\", + \"sha\": \"${{ github.sha }}\", + \"run_id\": \"${{ github.run_id }}\", + \"run_url\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\" + } + }" diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 52ae022..b6867e3 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -100,20 +100,38 @@ def steps(self) -> list[tuple[str, callable]]: def post_steps(self) -> None: print() - if self.confirm("Ready to trigger your first workflow run?", default=True): - try: - self._trigger_workflow() - print(f"[5/5] Triggering workflow... \u2713") - except Exception as e: - print(f"Trigger failed: {e}", file=sys.stderr) - raise SystemExit(1) - owner = self._answers["github_owner"] repo = self._answers["repo_name"] base_url = self._answers["cortex_base_url"].rstrip("/") app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url cortex_url = f"{app_url}/admin/resources?tag=github-actions-demo" gh_url = f"https://github.com/{owner}/{repo}" + + if self.confirm("Ready to trigger your first workflow run?", default=True): + print(" Starting Cortex workflow run (waiting for GitHub Actions to complete)...") + try: + result = self._trigger_via_cortex_workflow() + status = result.get("status", "").upper() + if status == "COMPLETED": + run_result = ( + result.get("actions", {}) + .get("trigger-deploy", {}) + .get("outputs", {}) + .get("result", {}) + .get("output", {}) + ) + conclusion = run_result.get("conclusion", "success") + run_url = run_result.get("run_url", "") + print(f"[5/5] Deploy complete: {conclusion} \u2713") + if run_url: + print(f" {_hyperlink(run_url, 'View GitHub Actions run')}") + self.mark_done("first_deploy") + else: + print(f"[5/5] Workflow ended with status: {status}", file=sys.stderr) + except Exception as e: + print(f"[5/5] Trigger failed: {e}", file=sys.stderr) + print(f" You can re-trigger via: cortex solutions post-install -s {self.solution_tag}", file=sys.stderr) + print(f"\nDone! Watch your first deploy appear at:") print(f" {_hyperlink(cortex_url)}") print(f"\nGitHub repo: {_hyperlink(gh_url)}") @@ -195,17 +213,58 @@ def _set_secret(self, secret_name: str, secret_value: str) -> None: if resp.status_code not in (201, 204): raise RuntimeError(f"Failed to set secret {secret_name}: {resp.status_code} {resp.text}") - def _trigger_workflow(self) -> None: - owner = self._answers["github_owner"] - repo = self._answers["repo_name"] + def _trigger_via_cortex_workflow(self) -> dict: + """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" + import time + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + cortex_headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + workflow_tag = "github-actions-trigger-deploy" + + body = { + "scope": {"type": "GLOBAL"}, + "initialContext": { + "variables": { + "github_token": self._answers["github_token"], + "github_owner": self._answers["github_owner"], + "repo_name": self._answers["repo_name"], + } + }, + } resp = requests.post( - f"{GITHUB_API}/repos/{owner}/{repo}/actions/workflows/cortex-deploy.yml/dispatches", - headers=self._gh_headers(), - json={"ref": "main"}, + f"{base_url}/api/v1/workflows/{workflow_tag}/runs", + json=body, + headers=cortex_headers, ) - if resp.status_code != 204: - raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to start workflow run: {resp.status_code} {resp.text}") + + run_id = resp.json().get("id") + if not run_id: + raise RuntimeError("No run ID returned from workflow start") + + terminal = {"COMPLETED", "FAILED", "CANCELLED"} + start = time.time() + dots = 0 + while time.time() - start < 300: + time.sleep(5) + r = requests.get( + f"{base_url}/api/v1/workflows/{workflow_tag}/runs/{run_id}", + headers=cortex_headers, + ) + r.raise_for_status() + status = r.json().get("status", "").upper() + dots += 1 + print(f"\r Waiting for GitHub Actions{'.' * (dots % 4)} ", end="", flush=True) + if status in terminal: + print() # newline after dots + return r.json() + + raise TimeoutError("Timed out waiting for workflow to complete (5 min)") def main(**kwargs): diff --git a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml new file mode 100644 index 0000000..bdecb51 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml @@ -0,0 +1,43 @@ +name: Trigger GitHub Actions Deploy +tag: github-actions-trigger-deploy +description: | + Triggers the Cortex deploy workflow on GitHub Actions and waits for the deployment to complete. + GitHub Actions calls back to Cortex when finished, surfacing the result directly in this workflow run. +isDraft: false +isRunnableViaApi: true +filter: + type: GLOBAL +variables: + - slug: github_token + type: STRING + - slug: github_owner + type: STRING + - slug: repo_name + type: STRING +runResponseTemplate: | + # GitHub Actions Deploy + + ## Result + + | Field | Value | + |-------|-------| + | **Conclusion** | {{actions.trigger-deploy.outputs.result.output.conclusion}} | + | **Commit** | {{actions.trigger-deploy.outputs.result.output.sha}} | + | **Workflow Run** | [View on GitHub]({{actions.trigger-deploy.outputs.result.output.run_url}}) | + +actions: + - name: Trigger GitHub Actions Deploy + slug: trigger-deploy + schema: + type: HTTP_REQUEST_ASYNC + httpMethod: POST + url: "https://api.github.com/repos/{{variables.github_owner}}/{{variables.repo_name}}/actions/workflows/cortex-deploy.yml/dispatches" + headers: + Authorization: "Bearer {{variables.github_token}}" + Accept: "application/vnd.github+json" + X-GitHub-Api-Version: "2022-11-28" + Content-Type: application/json + payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' + timeoutInSeconds: 300 + outgoingActions: [] + isRootAction: true From 4a7f9e9fb63c4d856c989e9e4d40b55e4d2fbd40 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 11:12:01 -0700 Subject: [PATCH 14/83] fix: flush captured import output to terminal on failure for CX-6 When backup.import_tenant() raises (e.g. invalid workflow YAML), _run_import_with_toggle was silently swallowing all captured output. Now flushes the buffer to real stdout before re-raising, so errors are always visible. Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index a935c0d..f3d2901 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -74,13 +74,26 @@ def getvalue(self) -> str: def _run_import_with_toggle(fn) -> str: - """Run fn() capturing stdout. On a TTY, Ctrl+o toggles live output.""" + """Run fn() capturing stdout. On a TTY, Ctrl+o toggles live output. + + If fn() raises for any reason, all buffered output is flushed to the + terminal before the exception propagates — no silent failures. + """ real_stdout = sys.stdout capture = _ToggleableCapture(real_stdout) if not (_TTY_SUPPORT and sys.stdin.isatty()): - with contextlib.redirect_stdout(capture): - fn() + success = False + try: + with contextlib.redirect_stdout(capture): + fn() + success = True + finally: + if not success: + buffered = capture.getvalue() + if buffered: + real_stdout.write(buffered) + real_stdout.flush() return capture.getvalue() done = threading.Event() @@ -112,9 +125,11 @@ def _listen() -> None: t = threading.Thread(target=_listen, daemon=True) t.start() + success = False try: with contextlib.redirect_stdout(capture): fn() + success = True finally: done.set() t.join(timeout=0.5) @@ -122,6 +137,11 @@ def _listen() -> None: _termios.tcsetattr(fd, _termios.TCSADRAIN, old_settings) except Exception: pass + if not success: + buffered = capture.getvalue() + if buffered: + real_stdout.write(buffered) + real_stdout.flush() return capture.getvalue() From dc58067de7cbf76e9c67b3746a344576b4d4ede7 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 11:19:16 -0700 Subject: [PATCH 15/83] fix: use hyphens in workflow variable slugs for CX-6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cortex only allows a-z, 0-9, and hyphens in workflow variable slugs. github_token/github_owner/repo_name → github-token/github-owner/repo-name Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 6 +++--- .../workflows/trigger-github-deploy.yaml | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index b6867e3..ea904f7 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -229,9 +229,9 @@ def _trigger_via_cortex_workflow(self) -> dict: "scope": {"type": "GLOBAL"}, "initialContext": { "variables": { - "github_token": self._answers["github_token"], - "github_owner": self._answers["github_owner"], - "repo_name": self._answers["repo_name"], + "github-token": self._answers["github_token"], + "github-owner": self._answers["github_owner"], + "repo-name": self._answers["repo_name"], } }, } diff --git a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml index bdecb51..f8401ba 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml @@ -8,11 +8,11 @@ isRunnableViaApi: true filter: type: GLOBAL variables: - - slug: github_token + - slug: github-token type: STRING - - slug: github_owner + - slug: github-owner type: STRING - - slug: repo_name + - slug: repo-name type: STRING runResponseTemplate: | # GitHub Actions Deploy @@ -31,9 +31,9 @@ actions: schema: type: HTTP_REQUEST_ASYNC httpMethod: POST - url: "https://api.github.com/repos/{{variables.github_owner}}/{{variables.repo_name}}/actions/workflows/cortex-deploy.yml/dispatches" + url: "https://api.github.com/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" headers: - Authorization: "Bearer {{variables.github_token}}" + Authorization: "Bearer {{variables.github-token}}" Accept: "application/vnd.github+json" X-GitHub-Api-Version: "2022-11-28" Content-Type: application/json From 548b0d045ce5c9e7483de0866b3f4c2bbca416e3 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 14:12:44 -0700 Subject: [PATCH 16/83] fix: pass workflow variables directly in initialContext for CX-6 The Cortex workflow run API expects variable values as top-level keys in initialContext, not nested under a "variables" wrapper. Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index ea904f7..6f849e4 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -228,11 +228,9 @@ def _trigger_via_cortex_workflow(self) -> dict: body = { "scope": {"type": "GLOBAL"}, "initialContext": { - "variables": { - "github-token": self._answers["github_token"], - "github-owner": self._answers["github_owner"], - "repo-name": self._answers["repo_name"], - } + "github-token": self._answers["github_token"], + "github-owner": self._answers["github_owner"], + "repo-name": self._answers["repo_name"], }, } resp = requests.post( From 723cb85ffcdd55cb963f0ca09094e169f08022c3 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 14:24:34 -0700 Subject: [PATCH 17/83] feat: use Cortex GitHub integration creds for async workflow trigger for CX-6 - collect_prompts: fetch GitHub integrations from Cortex API first. If found, present numbered list (default = isDefault), no GITHUB_TOKEN prompt. If none, fall through to GITHUB_TOKEN prompt as before. - Setup steps (create repo, seed, set secrets) use GITHUB_TOKEN from env var silently when an integration is configured; error naturally if not set. - Workflow YAML: replace github-token variable + Authorization header with integrationAlias: "{{variables.github-integration}}" (no raw token in workflow) - post_steps: when integration selected, triggers via Cortex workflow (async, waits for callback); when no integration, triggers GitHub API directly. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 133 ++++++++++++++---- .../workflows/trigger-github-deploy.yaml | 4 +- 2 files changed, 109 insertions(+), 28 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 6f849e4..abd8e0c 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -9,6 +9,7 @@ "repository, seed it with the Cortex deploy workflow, and configure the required secrets." ) import base64 +import os import sys from pathlib import Path import requests @@ -47,8 +48,9 @@ def __init__(self, cortex_api_key: str = None, cortex_base_url: str = None, **kw self._session_base_url = cortex_base_url def _gh_headers(self) -> dict: + token = self._answers.get("github_token") or os.environ.get("GITHUB_TOKEN", "") return { - "Authorization": f"Bearer {self._answers['github_token']}", + "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", } @@ -58,9 +60,65 @@ def _get_authenticated_user(self) -> str: resp.raise_for_status() return resp.json()["login"] + def _fetch_github_integrations(self) -> list: + """Fetch GitHub integrations configured in Cortex.""" + if not (self._session_api_key and self._session_base_url): + return [] + try: + resp = requests.get( + f"{self._session_base_url.rstrip('/')}/api/v1/github/configurations", + headers={"Authorization": f"Bearer {self._session_api_key}"}, + timeout=10, + ) + if resp.status_code == 200: + return resp.json().get("configurations", []) + except Exception: + pass + return [] + + def _select_github_integration(self, integrations: list) -> str: + """Present a numbered list and return the chosen alias.""" + default_idx = next( + (i for i, c in enumerate(integrations) if c.get("isDefault")), 0 + ) + print("\nGitHub integrations configured in Cortex:") + for i, cfg in enumerate(integrations): + marker = " *" if cfg.get("isDefault") else " " + type_label = cfg.get("type", "").replace("_", " ").title() + print(f" {marker}{i + 1}. {cfg['alias']} [{type_label}]") + print(" (* = default)") + + while True: + choice = input(f"\nSelect integration [{default_idx + 1}]: ").strip() + if not choice: + return integrations[default_idx]["alias"] + try: + idx = int(choice) - 1 + if 0 <= idx < len(integrations): + return integrations[idx]["alias"] + except ValueError: + pass + print(f" Enter a number between 1 and {len(integrations)}") + def collect_prompts(self) -> None: - self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + # 1. Check for GitHub integrations — prefer those over a raw token + integrations = self._fetch_github_integrations() + + if integrations: + alias = self._select_github_integration(integrations) + self._answers["github_integration_alias"] = alias + # Token still needed for the setup steps (create repo, seed, set secrets). + # Pull from env var silently; no prompt when integration is configured. + env_token = os.environ.get("GITHUB_TOKEN", "") + if env_token: + self._answers["github_token"] = env_token + # If GITHUB_TOKEN is not set the setup steps will fail with a clear + # error — user can export GITHUB_TOKEN and re-run post-install. + else: + # No integration — prompt for token (setup steps + workflow fallback) + self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + # 2. GitHub owner (derived from auth if token is available) try: default_owner = self._get_authenticated_user() except Exception: @@ -69,6 +127,7 @@ def collect_prompts(self) -> None: self.prompt("github_owner", "GitHub org or username", default=default_owner) self.prompt("repo_name", "Repository name", default="cortex-deploy-demo") + # 3. Cortex credentials from CLI session if self._session_api_key: if self.confirm("Use current Cortex API key?", default=True): self._answers["cortex_api_key"] = self._session_api_key @@ -108,29 +167,39 @@ def post_steps(self) -> None: gh_url = f"https://github.com/{owner}/{repo}" if self.confirm("Ready to trigger your first workflow run?", default=True): - print(" Starting Cortex workflow run (waiting for GitHub Actions to complete)...") - try: - result = self._trigger_via_cortex_workflow() - status = result.get("status", "").upper() - if status == "COMPLETED": - run_result = ( - result.get("actions", {}) - .get("trigger-deploy", {}) - .get("outputs", {}) - .get("result", {}) - .get("output", {}) - ) - conclusion = run_result.get("conclusion", "success") - run_url = run_result.get("run_url", "") - print(f"[5/5] Deploy complete: {conclusion} \u2713") - if run_url: - print(f" {_hyperlink(run_url, 'View GitHub Actions run')}") - self.mark_done("first_deploy") - else: - print(f"[5/5] Workflow ended with status: {status}", file=sys.stderr) - except Exception as e: - print(f"[5/5] Trigger failed: {e}", file=sys.stderr) - print(f" You can re-trigger via: cortex solutions post-install -s {self.solution_tag}", file=sys.stderr) + if self._answers.get("github_integration_alias"): + # Use Cortex async workflow — waits for GitHub Actions callback + print(" Starting Cortex workflow run (waiting for GitHub Actions to complete)...") + try: + result = self._trigger_via_cortex_workflow() + status = result.get("status", "").upper() + if status == "COMPLETED": + run_result = ( + result.get("actions", {}) + .get("trigger-deploy", {}) + .get("outputs", {}) + .get("result", {}) + .get("output", {}) + ) + conclusion = run_result.get("conclusion", "success") + run_url = run_result.get("run_url", "") + print(f"[5/5] Deploy complete: {conclusion} \u2713") + if run_url: + print(f" {_hyperlink(run_url, 'View GitHub Actions run')}") + self.mark_done("first_deploy") + else: + print(f"[5/5] Workflow ended with status: {status}", file=sys.stderr) + except Exception as e: + print(f"[5/5] Trigger failed: {e}", file=sys.stderr) + print(f" Re-trigger via: cortex solutions post-install -s {self.solution_tag}", file=sys.stderr) + else: + # No integration — trigger GitHub Actions directly + try: + self._trigger_direct() + print(f"[5/5] GitHub Actions workflow triggered \u2713") + print(" (No Cortex integration configured — cannot wait for completion)") + except Exception as e: + print(f"[5/5] Trigger failed: {e}", file=sys.stderr) print(f"\nDone! Watch your first deploy appear at:") print(f" {_hyperlink(cortex_url)}") @@ -213,6 +282,18 @@ def _set_secret(self, secret_name: str, secret_value: str) -> None: if resp.status_code not in (201, 204): raise RuntimeError(f"Failed to set secret {secret_name}: {resp.status_code} {resp.text}") + def _trigger_direct(self) -> None: + """Trigger the GitHub Actions workflow directly via the GitHub API.""" + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + resp = requests.post( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/workflows/cortex-deploy.yml/dispatches", + headers=self._gh_headers(), + json={"ref": "main"}, + ) + if resp.status_code != 204: + raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + def _trigger_via_cortex_workflow(self) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" import time @@ -228,7 +309,7 @@ def _trigger_via_cortex_workflow(self) -> dict: body = { "scope": {"type": "GLOBAL"}, "initialContext": { - "github-token": self._answers["github_token"], + "github-integration": self._answers["github_integration_alias"], "github-owner": self._answers["github_owner"], "repo-name": self._answers["repo_name"], }, diff --git a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml index f8401ba..e193a97 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml @@ -8,7 +8,7 @@ isRunnableViaApi: true filter: type: GLOBAL variables: - - slug: github-token + - slug: github-integration type: STRING - slug: github-owner type: STRING @@ -32,8 +32,8 @@ actions: type: HTTP_REQUEST_ASYNC httpMethod: POST url: "https://api.github.com/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" + integrationAlias: "{{variables.github-integration}}" headers: - Authorization: "Bearer {{variables.github-token}}" Accept: "application/vnd.github+json" X-GitHub-Api-Version: "2022-11-28" Content-Type: application/json From 15250fb068e1a8b011841004dbb42f38b9f678a6 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 14:27:56 -0700 Subject: [PATCH 18/83] fix: correct HTTP_REQUEST_ASYNC integration syntax for CX-6 - integration: GitHub (required alongside integrationAlias) - URL is relative (base URL prepended by integration automatically) - headers: {} (empty object, not list) Co-Authored-By: Claude Sonnet 4.6 --- .../workflows/trigger-github-deploy.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml index e193a97..a4c8d95 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml @@ -31,12 +31,10 @@ actions: schema: type: HTTP_REQUEST_ASYNC httpMethod: POST - url: "https://api.github.com/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" + url: "/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" + integration: GitHub integrationAlias: "{{variables.github-integration}}" - headers: - Accept: "application/vnd.github+json" - X-GitHub-Api-Version: "2022-11-28" - Content-Type: application/json + headers: {} payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' timeoutInSeconds: 300 outgoingActions: [] From 5723ecd34b2c72c8174a0c8441cc0c90b02ed008 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 14:28:26 -0700 Subject: [PATCH 19/83] fix: remove empty headers from workflow action for CX-6 Co-Authored-By: Claude Sonnet 4.6 --- .../github-actions-deploy/workflows/trigger-github-deploy.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml index a4c8d95..33d3598 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml @@ -34,7 +34,6 @@ actions: url: "/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" integration: GitHub integrationAlias: "{{variables.github-integration}}" - headers: {} payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' timeoutInSeconds: 300 outgoingActions: [] From 40303924afc76d43aa62291017053d55cb945080 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 14:35:39 -0700 Subject: [PATCH 20/83] fix: add Authorization header to Cortex callback step for CX-6 The callback URL requires auth like any Cortex API endpoint. Uses the CORTEX_API_KEY repo secret already set during post-install. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/_templates/cortex-deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index fac3758..b4f73a3 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -56,6 +56,7 @@ jobs: run: | curl -s -f -X POST \ "${{ inputs.cortex_callback_url }}" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ -H "Content-Type: application/json" \ -d "{ \"output\": { From 3c0ac85f8d6580437089065939860406914f0c3d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 15:43:04 -0700 Subject: [PATCH 21/83] fix: correct Cortex workflow import and async callback for GitHub Actions deploy - Move trigger-github-deploy.yaml to _templates/ (Cortex validates integrationAlias at import time and rejects template variable expressions like {{variables.xxx}}) - Add setup step to import the Cortex workflow with the selected alias substituted - Add always() to callback step so it fires even if deploy registration fails - Add required status/message fields to Cortex callback payload - Update runResponseTemplate to use variables (result is null for HTTP_REQUEST_ASYNC when initial request returns 204 with no body) - Fix headers: {} required even with integration auth Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/cortex-deploy.yml | 4 ++- .../trigger-github-deploy.yaml | 13 +++----- .../solutions/github-actions-deploy/setup.py | 31 +++++++++++++++++-- 3 files changed, 36 insertions(+), 12 deletions(-) rename cortexapps_cli/solutions/github-actions-deploy/{workflows => _templates}/trigger-github-deploy.yaml (69%) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index b4f73a3..898a212 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -52,13 +52,15 @@ jobs: -d "{\"timestamp\": \"${TIMESTAMP}\", \"value\": 1}" - name: Notify Cortex workflow callback - if: ${{ inputs.cortex_callback_url != '' }} + if: ${{ always() && inputs.cortex_callback_url != '' }} run: | curl -s -f -X POST \ "${{ inputs.cortex_callback_url }}" \ -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ -H "Content-Type: application/json" \ -d "{ + \"status\": \"SUCCESS\", + \"message\": \"\", \"output\": { \"conclusion\": \"success\", \"sha\": \"${{ github.sha }}\", diff --git a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml similarity index 69% rename from cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml rename to cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 33d3598..e058d18 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/workflows/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -8,8 +8,6 @@ isRunnableViaApi: true filter: type: GLOBAL variables: - - slug: github-integration - type: STRING - slug: github-owner type: STRING - slug: repo-name @@ -17,13 +15,9 @@ variables: runResponseTemplate: | # GitHub Actions Deploy - ## Result + Deploy triggered for **{{variables.github-owner}}/{{variables.repo-name}}** and completed successfully. - | Field | Value | - |-------|-------| - | **Conclusion** | {{actions.trigger-deploy.outputs.result.output.conclusion}} | - | **Commit** | {{actions.trigger-deploy.outputs.result.output.sha}} | - | **Workflow Run** | [View on GitHub]({{actions.trigger-deploy.outputs.result.output.run_url}}) | + [View GitHub Actions runs](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/actions) actions: - name: Trigger GitHub Actions Deploy @@ -33,7 +27,8 @@ actions: httpMethod: POST url: "/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" integration: GitHub - integrationAlias: "{{variables.github-integration}}" + integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" + headers: {} payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' timeoutInSeconds: 300 outgoingActions: [] diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index abd8e0c..ff367cc 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -23,6 +23,7 @@ GITHUB_API = "https://api.github.com" TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" +WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "trigger-github-deploy.yaml" def _hyperlink(url: str, text: str = None) -> str: @@ -150,12 +151,15 @@ def collect_prompts(self) -> None: ) def steps(self) -> list[tuple[str, callable]]: - return [ + steps = [ ("Creating GitHub repository", self._create_repo), ("Seeding Cortex deploy workflow", self._seed_workflow), ("Setting CORTEX_API_KEY secret", lambda: self._set_secret("CORTEX_API_KEY", self._answers["cortex_api_key"])), ("Setting CORTEX_BASE_URL secret", lambda: self._set_secret("CORTEX_BASE_URL", self._answers["cortex_base_url"])), ] + if self._answers.get("github_integration_alias"): + steps.append(("Importing Cortex trigger workflow", self._import_cortex_workflow)) + return steps def post_steps(self) -> None: print() @@ -294,6 +298,30 @@ def _trigger_direct(self) -> None: if resp.status_code != 204: raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + def _import_cortex_workflow(self) -> None: + """Import the Cortex trigger workflow with the selected GitHub integration alias.""" + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + alias = self._answers["github_integration_alias"] + + yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( + "PLACEHOLDER_INTEGRATION_ALIAS", alias + ) + + resp = requests.post( + f"{base_url}/api/v1/workflows", + data=yaml_content.encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/yaml", + }, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" + ) + def _trigger_via_cortex_workflow(self) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" import time @@ -309,7 +337,6 @@ def _trigger_via_cortex_workflow(self) -> dict: body = { "scope": {"type": "GLOBAL"}, "initialContext": { - "github-integration": self._answers["github_integration_alias"], "github-owner": self._answers["github_owner"], "repo-name": self._answers["repo_name"], }, From 9bebb21f4c55fa4c1ec15e60cd97e4bd26e92ee9 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Tue, 11 Aug 2026 16:48:43 -0700 Subject: [PATCH 22/83] fix: correct post_steps result extraction and step numbering actions is a list in the workflow run response, not a dict. Also remove stale [5/5] step numbers that became inaccurate when the import step was added. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index ff367cc..08b6707 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -178,32 +178,23 @@ def post_steps(self) -> None: result = self._trigger_via_cortex_workflow() status = result.get("status", "").upper() if status == "COMPLETED": - run_result = ( - result.get("actions", {}) - .get("trigger-deploy", {}) - .get("outputs", {}) - .get("result", {}) - .get("output", {}) - ) - conclusion = run_result.get("conclusion", "success") - run_url = run_result.get("run_url", "") - print(f"[5/5] Deploy complete: {conclusion} \u2713") - if run_url: - print(f" {_hyperlink(run_url, 'View GitHub Actions run')}") + gh_actions_url = f"https://github.com/{owner}/{repo}/actions" + print(f" Deploy complete \u2713") + print(f" {_hyperlink(gh_actions_url, 'View GitHub Actions runs')}") self.mark_done("first_deploy") else: - print(f"[5/5] Workflow ended with status: {status}", file=sys.stderr) + print(f" Workflow ended with status: {status}", file=sys.stderr) except Exception as e: - print(f"[5/5] Trigger failed: {e}", file=sys.stderr) + print(f" Trigger failed: {e}", file=sys.stderr) print(f" Re-trigger via: cortex solutions post-install -s {self.solution_tag}", file=sys.stderr) else: # No integration — trigger GitHub Actions directly try: self._trigger_direct() - print(f"[5/5] GitHub Actions workflow triggered \u2713") + print(f" GitHub Actions workflow triggered \u2713") print(" (No Cortex integration configured — cannot wait for completion)") except Exception as e: - print(f"[5/5] Trigger failed: {e}", file=sys.stderr) + print(f" Trigger failed: {e}", file=sys.stderr) print(f"\nDone! Watch your first deploy appear at:") print(f" {_hyperlink(cortex_url)}") From f641b52d9091f9b1b16988b2f7cb0a7d4e9a953e Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 08:40:01 -0700 Subject: [PATCH 23/83] fix: reflect actual job status in Cortex callback and surface run data in template - Map GitHub job.status to SUCCESS/FAILURE/CANCELLED before POSTing callback (was hardcoded SUCCESS, causing Cortex to show success on failed GH Actions runs) - Add conclusion, sha, run_id, run_url to callback output payload - Update runResponseTemplate to render conclusion, SHA, and direct run link via {{actions.trigger-deploy.outputs.output.*}} (callback output path) Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/cortex-deploy.yml | 12 ++++++++++-- .../_templates/trigger-github-deploy.yaml | 6 ++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 898a212..2475795 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -54,15 +54,23 @@ jobs: - name: Notify Cortex workflow callback if: ${{ always() && inputs.cortex_callback_url != '' }} run: | + if [ "${{ job.status }}" = "success" ]; then + CORTEX_STATUS="SUCCESS" + elif [ "${{ job.status }}" = "cancelled" ]; then + CORTEX_STATUS="CANCELLED" + else + CORTEX_STATUS="FAILURE" + fi + curl -s -f -X POST \ "${{ inputs.cortex_callback_url }}" \ -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ -H "Content-Type: application/json" \ -d "{ - \"status\": \"SUCCESS\", + \"status\": \"${CORTEX_STATUS}\", \"message\": \"\", \"output\": { - \"conclusion\": \"success\", + \"conclusion\": \"${{ job.status }}\", \"sha\": \"${{ github.sha }}\", \"run_id\": \"${{ github.run_id }}\", \"run_url\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\" diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index e058d18..1325544 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -15,9 +15,11 @@ variables: runResponseTemplate: | # GitHub Actions Deploy - Deploy triggered for **{{variables.github-owner}}/{{variables.repo-name}}** and completed successfully. + **{{variables.github-owner}}/{{variables.repo-name}}** — `{{actions.trigger-deploy.outputs.output.conclusion}}` - [View GitHub Actions runs](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/actions) + SHA: `{{actions.trigger-deploy.outputs.output.sha}}` + + [View run]({{actions.trigger-deploy.outputs.output.run_url}}) actions: - name: Trigger GitHub Actions Deploy From 92d853d76a3195632b98c69049e3d5e98c082676 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 08:44:46 -0700 Subject: [PATCH 24/83] fix: remove deploy-count custom metric, drive scorecard from deploys() CQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy data is already captured via the deploys API — no need for a redundant custom metric. Scorecard rules now use deploys(lookback=...) directly. Co-Authored-By: Claude Sonnet 4.6 --- .../github-actions-deploy/_templates/cortex-deploy.yml | 10 ---------- .../scorecards/deploy-health.yaml | 10 +++++----- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 2475795..2202fb7 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -23,9 +23,6 @@ jobs: steps: - name: Register deploy in Cortex run: | - TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) - - # Record the deploy event curl -s -f -X POST \ "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ @@ -44,13 +41,6 @@ jobs: } }" - # Post deploy-count metric (powers the Deploy Health scorecard) - curl -s -f -X POST \ - "${{ secrets.CORTEX_BASE_URL }}/api/v1/eng-intel/custom-metrics/deploy-count/entity/github-actions-demo" \ - -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ - -H "Content-Type: application/json" \ - -d "{\"timestamp\": \"${TIMESTAMP}\", \"value\": 1}" - - name: Notify Cortex workflow callback if: ${{ always() && inputs.cortex_callback_url != '' }} run: | diff --git a/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml b/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml index 9f97d85..fc1f0a2 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml @@ -1,6 +1,6 @@ tag: deploy-health name: Deploy Health -description: Measures deployment cadence for services using GitHub Actions deploy tracking. The deploy-count custom metric is posted by the included GitHub Actions workflow on each successful build. Scoped to demo-github-actions-deploys group by default — remove the filter to apply to all services. +description: Measures deployment cadence for services using GitHub Actions deploy tracking. Scoped to demo-github-actions-deploys group by default — remove the filter to apply to all services. draft: false notifications: enabled: true @@ -33,19 +33,19 @@ ladder: color: "#D7AC58" rules: - title: Has at least one deploy - description: At least one deployment has been recorded via the deploy-count custom metric in the last year. - expression: customMetrics(key="deploy-count", lookback=duration("P1Y")).length > 0 + description: At least one deployment has been recorded in the last year. + expression: deploys(lookback=duration("P1Y")).length > 0 weight: 1 level: Bronze - title: Deployed in the last 30 days description: A deployment was recorded within the past 30 days. - expression: customMetrics(key="deploy-count", lookback=duration("P30D")).length > 0 + expression: deploys(lookback=duration("P30D")).length > 0 weight: 1 level: Silver - title: Deployed in the last 7 days description: A deployment was recorded within the past 7 days, indicating an active delivery cadence. - expression: customMetrics(key="deploy-count", lookback=duration("P7D")).length > 0 + expression: deploys(lookback=duration("P7D")).length > 0 weight: 1 level: Gold From 413ac51cf4a5070b0bc1d5db9a6cd7e69580e25d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 08:46:24 -0700 Subject: [PATCH 25/83] fix: change workflow trigger prompt to not assume it is the first run Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 08b6707..ad40b42 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -170,7 +170,7 @@ def post_steps(self) -> None: cortex_url = f"{app_url}/admin/resources?tag=github-actions-demo" gh_url = f"https://github.com/{owner}/{repo}" - if self.confirm("Ready to trigger your first workflow run?", default=True): + if self.confirm("Trigger a workflow run now?", default=True): if self._answers.get("github_integration_alias"): # Use Cortex async workflow — waits for GitHub Actions callback print(" Starting Cortex workflow run (waiting for GitHub Actions to complete)...") From 14e319eba3f6aed5149a9628e83ab7fd44a98ab3 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 08:50:03 -0700 Subject: [PATCH 26/83] fix: use jq for callback JSON, needs.build.result for status, correct template path Three bugs fixed: - Fragile multi-line -d string caused output:null in callback; switch to jq - job.status reflected notify-cortex infra failures, not build outcome; use needs.build.result so only actual build failures report FAILURE - Template used outputs.output.* (wrong); correct path is outputs.result.output.* since Cortex stores the callback payload in result for HTTP_REQUEST_ASYNC Also adds continue-on-error on the register-deploy step so a Cortex API error doesn't pollute the callback status. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/cortex-deploy.yml | 53 ++++++++++--------- .../_templates/trigger-github-deploy.yaml | 6 +-- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 2202fb7..0eda920 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -22,31 +22,34 @@ jobs: runs-on: ubuntu-latest steps: - name: Register deploy in Cortex + continue-on-error: true run: | curl -s -f -X POST \ "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ -H "Content-Type: application/json" \ - -d "{ - \"sha\": \"${{ github.sha }}\", - \"environment\": \"production\", - \"type\": \"DEPLOY\", - \"title\": \"Triggered by ${{ github.actor }}\", - \"deployer\": { \"name\": \"${{ github.actor }}\" }, - \"customData\": { - \"branch\": \"${{ github.ref_name }}\", - \"runId\": \"${{ github.run_id }}\", - \"runUrl\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\", - \"trigger\": \"${{ github.event_name }}\" - } - }" + -d "$(jq -n \ + --arg sha '${{ github.sha }}' \ + --arg actor '${{ github.actor }}' \ + --arg branch '${{ github.ref_name }}' \ + --arg run_id '${{ github.run_id }}' \ + --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ + --arg trigger '${{ github.event_name }}' \ + '{ + sha: $sha, + environment: "production", + type: "DEPLOY", + title: ("Triggered by " + $actor), + deployer: {name: $actor}, + customData: {branch: $branch, runId: $run_id, runUrl: $run_url, trigger: $trigger} + }')" - name: Notify Cortex workflow callback if: ${{ always() && inputs.cortex_callback_url != '' }} run: | - if [ "${{ job.status }}" = "success" ]; then + if [ "${{ needs.build.result }}" = "success" ]; then CORTEX_STATUS="SUCCESS" - elif [ "${{ job.status }}" = "cancelled" ]; then + elif [ "${{ needs.build.result }}" = "cancelled" ]; then CORTEX_STATUS="CANCELLED" else CORTEX_STATUS="FAILURE" @@ -56,13 +59,13 @@ jobs: "${{ inputs.cortex_callback_url }}" \ -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ -H "Content-Type: application/json" \ - -d "{ - \"status\": \"${CORTEX_STATUS}\", - \"message\": \"\", - \"output\": { - \"conclusion\": \"${{ job.status }}\", - \"sha\": \"${{ github.sha }}\", - \"run_id\": \"${{ github.run_id }}\", - \"run_url\": \"${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}\" - } - }" + -d "$(jq -n \ + --arg status "$CORTEX_STATUS" \ + --arg sha '${{ github.sha }}' \ + --arg run_id '${{ github.run_id }}' \ + --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ + '{ + status: $status, + message: "", + output: {sha: $sha, run_id: $run_id, run_url: $run_url} + }')" diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 1325544..2f2ee57 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -15,11 +15,9 @@ variables: runResponseTemplate: | # GitHub Actions Deploy - **{{variables.github-owner}}/{{variables.repo-name}}** — `{{actions.trigger-deploy.outputs.output.conclusion}}` + **{{variables.github-owner}}/{{variables.repo-name}}** — SHA `{{actions.trigger-deploy.outputs.result.output.sha}}` - SHA: `{{actions.trigger-deploy.outputs.output.sha}}` - - [View run]({{actions.trigger-deploy.outputs.output.run_url}}) + [View run]({{actions.trigger-deploy.outputs.result.output.run_url}}) actions: - name: Trigger GitHub Actions Deploy From 7887ffba4b3904db97f33cf541d30246cc4654e9 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 08:57:10 -0700 Subject: [PATCH 27/83] feat: add IN_PROGRESS intermediate callbacks before terminal callback Sends two intermediate IN_PROGRESS updates (10s apart) before the final SUCCESS/FAILURE/CANCELLED callback, demonstrating Cortex's support for streaming progress updates from async actions. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/cortex-deploy.yml | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 0eda920..082c18f 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -55,17 +55,32 @@ jobs: CORTEX_STATUS="FAILURE" fi - curl -s -f -X POST \ - "${{ inputs.cortex_callback_url }}" \ - -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg status "$CORTEX_STATUS" \ - --arg sha '${{ github.sha }}' \ - --arg run_id '${{ github.run_id }}' \ - --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ - '{ - status: $status, - message: "", - output: {sha: $sha, run_id: $run_id, run_url: $run_url} - }')" + CALLBACK_URL="${{ inputs.cortex_callback_url }}" + CORTEX_API_KEY="${{ secrets.CORTEX_API_KEY }}" + + _callback() { + curl -s -f -X POST "$CALLBACK_URL" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$1" + } + + # Update 1 of 2 + _callback "$(jq -n '{status: "IN_PROGRESS", message: "Update 1 of 2: sleeping for 10 seconds", output: {}}')" + sleep 10 + + # Update 2 of 2 + _callback "$(jq -n '{status: "IN_PROGRESS", message: "Update 2 of 2: finishing up", output: {}}')" + sleep 10 + + # Final callback + _callback "$(jq -n \ + --arg status "$CORTEX_STATUS" \ + --arg sha '${{ github.sha }}' \ + --arg run_id '${{ github.run_id }}' \ + --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ + '{ + status: $status, + message: "", + output: {sha: $sha, run_id: $run_id, run_url: $run_url} + }')" From d52a414957cb80be1ca163fbaa59bde96f7b9718 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 09:11:29 -0700 Subject: [PATCH 28/83] fix: remove IN_PROGRESS callbacks (rejected by Cortex), reduce timeout to 120s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cortex callback endpoint only accepts terminal statuses (SUCCESS/FAILURE/CANCELLED). IN_PROGRESS caused a 4xx, killing the step before the terminal callback could fire. Reduced timeoutInSeconds from 300 to 120 — GH Actions run completes in ~20-30s. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/cortex-deploy.yml | 42 ++++++------------- .../_templates/trigger-github-deploy.yaml | 2 +- 2 files changed, 14 insertions(+), 30 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 082c18f..9c30dd9 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -55,32 +55,16 @@ jobs: CORTEX_STATUS="FAILURE" fi - CALLBACK_URL="${{ inputs.cortex_callback_url }}" - CORTEX_API_KEY="${{ secrets.CORTEX_API_KEY }}" - - _callback() { - curl -s -f -X POST "$CALLBACK_URL" \ - -H "Authorization: Bearer $CORTEX_API_KEY" \ - -H "Content-Type: application/json" \ - -d "$1" - } - - # Update 1 of 2 - _callback "$(jq -n '{status: "IN_PROGRESS", message: "Update 1 of 2: sleeping for 10 seconds", output: {}}')" - sleep 10 - - # Update 2 of 2 - _callback "$(jq -n '{status: "IN_PROGRESS", message: "Update 2 of 2: finishing up", output: {}}')" - sleep 10 - - # Final callback - _callback "$(jq -n \ - --arg status "$CORTEX_STATUS" \ - --arg sha '${{ github.sha }}' \ - --arg run_id '${{ github.run_id }}' \ - --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ - '{ - status: $status, - message: "", - output: {sha: $sha, run_id: $run_id, run_url: $run_url} - }')" + curl -s -f -X POST "${{ inputs.cortex_callback_url }}" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg status "$CORTEX_STATUS" \ + --arg sha '${{ github.sha }}' \ + --arg run_id '${{ github.run_id }}' \ + --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ + '{ + status: $status, + message: "", + output: {sha: $sha, run_id: $run_id, run_url: $run_url} + }')" diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 2f2ee57..c217f19 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -30,6 +30,6 @@ actions: integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" headers: {} payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' - timeoutInSeconds: 300 + timeoutInSeconds: 120 outgoingActions: [] isRootAction: true From 9b1717e225f0f667dcf091bc150fb278c94b363c Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 09:16:51 -0700 Subject: [PATCH 29/83] fix: sort GitHub integrations alphabetically by alias Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index ad40b42..0b07165 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -79,6 +79,7 @@ def _fetch_github_integrations(self) -> list: def _select_github_integration(self, integrations: list) -> str: """Present a numbered list and return the chosen alias.""" + integrations = sorted(integrations, key=lambda c: c.get("alias", "").lower()) default_idx = next( (i for i, c in enumerate(integrations) if c.get("isDefault")), 0 ) From 310a003543f2b601236c1cebedf863896b74af5c Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 09:19:48 -0700 Subject: [PATCH 30/83] fix: remove 'first' from deploy completion message Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 0b07165..0da3a46 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -197,7 +197,7 @@ def post_steps(self) -> None: except Exception as e: print(f" Trigger failed: {e}", file=sys.stderr) - print(f"\nDone! Watch your first deploy appear at:") + print(f"\nDone! Watch your deploy appear at:") print(f" {_hyperlink(cortex_url)}") print(f"\nGitHub repo: {_hyperlink(gh_url)}") From 222f99abe2b34b87c42907468427ffcd54592409 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 09:22:33 -0700 Subject: [PATCH 31/83] feat: link GitHub repo to Cortex entity during post-install setup Adds a setup step that PATCHes the entity descriptor via PUT /api/v1/open-api to add x-cortex-git.github.repository, enabling Cortex to discover GitHub Actions workflows for the entity. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 0da3a46..47238d2 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -157,6 +157,7 @@ def steps(self) -> list[tuple[str, callable]]: ("Seeding Cortex deploy workflow", self._seed_workflow), ("Setting CORTEX_API_KEY secret", lambda: self._set_secret("CORTEX_API_KEY", self._answers["cortex_api_key"])), ("Setting CORTEX_BASE_URL secret", lambda: self._set_secret("CORTEX_BASE_URL", self._answers["cortex_base_url"])), + ("Linking GitHub repository to entity", self._link_github_repo), ] if self._answers.get("github_integration_alias"): steps.append(("Importing Cortex trigger workflow", self._import_cortex_workflow)) @@ -290,6 +291,39 @@ def _trigger_direct(self) -> None: if resp.status_code != 204: raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + def _link_github_repo(self) -> None: + """PATCH the Cortex entity to link the GitHub repository so workflows are discovered.""" + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + + yaml_content = f"""\ +openapi: "3.0.0" +info: + title: GitHub Actions Demo + x-cortex-tag: github-actions-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via GitHub Actions. + x-cortex-definition: {{}} + x-cortex-groups: + - demo-github-actions-deploys + x-cortex-git: + github: + repository: "{owner}/{repo}" +""" + resp = requests.put( + f"{base_url}/api/v1/open-api", + data=yaml_content.encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/yaml", + }, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to link GitHub repo to entity: {resp.status_code} {resp.text}") + def _import_cortex_workflow(self) -> None: """Import the Cortex trigger workflow with the selected GitHub integration alias.""" base_url = self._answers["cortex_base_url"].rstrip("/") From cdea06605ebbd294ce91abbbcfa3827d13e9fc04 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 09:33:38 -0700 Subject: [PATCH 32/83] fix: add required timestamp field to deploys API call The Cortex deploys endpoint returns 400 'missing required field(s): timestamp'. When the register-deploy step was rewritten to use jq, timestamp was dropped. Co-Authored-By: Claude Sonnet 4.6 --- .../github-actions-deploy/_templates/cortex-deploy.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 9c30dd9..828b77f 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -35,8 +35,10 @@ jobs: --arg run_id '${{ github.run_id }}' \ --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ --arg trigger '${{ github.event_name }}' \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ '{ sha: $sha, + timestamp: $timestamp, environment: "production", type: "DEPLOY", title: ("Triggered by " + $actor), From 8ef5ee06edcef65bd8bdb86ce7c53317de8770f2 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 09:36:09 -0700 Subject: [PATCH 33/83] fix: use PATCH not PUT for entity git link (405 Method Not Allowed) Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 47238d2..1cf0378 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -312,7 +312,7 @@ def _link_github_repo(self) -> None: github: repository: "{owner}/{repo}" """ - resp = requests.put( + resp = requests.patch( f"{base_url}/api/v1/open-api", data=yaml_content.encode("utf-8"), headers={ From 55127c626ade7718dd6247e5e3064a2c41f51baf Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 09:51:32 -0700 Subject: [PATCH 34/83] fix: use application/openapi;charset=UTF-8 content type for entity PATCH application/yaml returns 415 Unsupported Media Type. Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 1cf0378..3b2d699 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -317,7 +317,7 @@ def _link_github_repo(self) -> None: data=yaml_content.encode("utf-8"), headers={ "Authorization": f"Bearer {api_key}", - "Content-Type": "application/yaml", + "Content-Type": "application/openapi;charset=UTF-8", }, timeout=15, ) From 929ba4ce140d15ade3b72d72b9dd5c8d6a95b951 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 11:45:49 -0700 Subject: [PATCH 35/83] fix: simplify runResponseTemplate to use only variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Callback output (result.output.*) does not resolve in runResponseTemplate — only variables.* is reliably available. Construct the GitHub Actions link from variables instead of relying on inaccessible callback data. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/trigger-github-deploy.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index c217f19..3d9afba 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -15,9 +15,9 @@ variables: runResponseTemplate: | # GitHub Actions Deploy - **{{variables.github-owner}}/{{variables.repo-name}}** — SHA `{{actions.trigger-deploy.outputs.result.output.sha}}` + Deploy complete for **{{variables.github-owner}}/{{variables.repo-name}}**. - [View run]({{actions.trigger-deploy.outputs.result.output.run_url}}) + [View GitHub Actions runs](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/actions) actions: - name: Trigger GitHub Actions Deploy From 5de4a6e66dd95ae5c6a660419d274a01f6209140 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 11:47:24 -0700 Subject: [PATCH 36/83] feat: embed cortex-deploy.yml in Cortex workflow run response template Shows the GitHub Actions workflow YAML inline in the completed run response. Uses env-var-style names instead of ${{ }} expressions to avoid Cortex's template engine parsing double braces. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/trigger-github-deploy.yaml | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 3d9afba..add0d17 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -19,6 +19,85 @@ runResponseTemplate: | [View GitHub Actions runs](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/actions) + --- + + ## GitHub workflow used for this deploy + + ```yaml + name: Cortex Deploy + + on: + push: + branches: [main] + workflow_dispatch: + inputs: + cortex_callback_url: + description: 'Cortex async workflow callback URL (set automatically when triggered via Cortex workflow)' + required: false + default: '' + + jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: echo "Hello, Cortex deploys!" + + notify-cortex: + needs: build + runs-on: ubuntu-latest + steps: + - name: Register deploy in Cortex + continue-on-error: true + run: | + curl -s -f -X POST \ + "$CORTEX_BASE_URL/api/v1/catalog/github-actions-demo/deploys" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg sha '$GITHUB_SHA' \ + --arg actor '$GITHUB_ACTOR' \ + --arg branch '$GITHUB_REF_NAME' \ + --arg run_id '$GITHUB_RUN_ID' \ + --arg run_url '$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID' \ + --arg trigger '$GITHUB_EVENT_NAME' \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{ + sha: $sha, + timestamp: $timestamp, + environment: "production", + type: "DEPLOY", + title: ("Triggered by " + $actor), + deployer: {name: $actor}, + customData: {branch: $branch, runId: $run_id, runUrl: $run_url, trigger: $trigger} + }')" + + - name: Notify Cortex workflow callback + if: always() && inputs.cortex_callback_url != '' + run: | + if [ "$NEEDS_BUILD_RESULT" = "success" ]; then + CORTEX_STATUS="SUCCESS" + elif [ "$NEEDS_BUILD_RESULT" = "cancelled" ]; then + CORTEX_STATUS="CANCELLED" + else + CORTEX_STATUS="FAILURE" + fi + + curl -s -f -X POST "$CORTEX_CALLBACK_URL" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg status "$CORTEX_STATUS" \ + --arg sha '$GITHUB_SHA' \ + --arg run_id '$GITHUB_RUN_ID' \ + --arg run_url '$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID' \ + '{ + status: $status, + message: "", + output: {sha: $sha, run_id: $run_id, run_url: $run_url} + }')" + ``` + actions: - name: Trigger GitHub Actions Deploy slug: trigger-deploy From 43793af427030b3caf745910924002149c78654f Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 11:56:37 -0700 Subject: [PATCH 37/83] feat: offer to create PAT GitHub integration when none is configured When post-install finds no GitHub integrations in Cortex, it now offers to create one inline. Prompts for a PAT and an alias (default: github-pat), POSTs to /api/v1/github/configurations/personal, then continues with the full integration-enabled flow. Falls back gracefully if creation fails. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 3b2d699..e9e24aa 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -117,8 +117,23 @@ def collect_prompts(self) -> None: # If GITHUB_TOKEN is not set the setup steps will fail with a clear # error — user can export GITHUB_TOKEN and re-run post-install. else: - # No integration — prompt for token (setup steps + workflow fallback) - self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + # No integrations configured + if self._session_api_key and self._session_base_url: + print("\nNo GitHub integration is configured in Cortex.") + if self.confirm("Set up a Personal Access Token (PAT) integration now?", default=True): + self.prompt("github_token", "GitHub Personal Access Token", env_var="GITHUB_TOKEN", secret=True) + self.prompt("github_integration_alias", "Integration alias", default="github-pat") + try: + self._create_github_pat_integration() + print(f" Integration '{self._answers['github_integration_alias']}' created \u2713") + except Exception as e: + print(f" Could not create integration: {e}", file=sys.stderr) + print(" Continuing without Cortex integration.", file=sys.stderr) + self._answers.pop("github_integration_alias", None) + else: + self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + else: + self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) # 2. GitHub owner (derived from auth if token is available) try: @@ -291,6 +306,21 @@ def _trigger_direct(self) -> None: if resp.status_code != 204: raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + def _create_github_pat_integration(self) -> None: + """Create a GitHub PAT integration in Cortex.""" + resp = requests.post( + f"{self._session_base_url.rstrip('/')}/api/v1/github/configurations/personal", + json={ + "alias": self._answers["github_integration_alias"], + "accessToken": self._answers["github_token"], + "isDefault": True, + }, + headers={"Authorization": f"Bearer {self._session_api_key}"}, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"{resp.status_code} {resp.text}") + def _link_github_repo(self) -> None: """PATCH the Cortex entity to link the GitHub repository so workflows are discovered.""" owner = self._answers["github_owner"] From 41b0bfeda98c60e0efc30c41f7f3436e2655833a Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 13:52:59 -0700 Subject: [PATCH 38/83] fix: substitute github-owner and repo-name defaults into Cortex workflow at import When triggered manually from the Cortex UI, blank variables caused a 404. Inject owner/repo as defaultValues at import time (like integrationAlias), so the run dialog pre-fills with the correct values and the user can edit them. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/trigger-github-deploy.yaml | 2 ++ cortexapps_cli/solutions/github-actions-deploy/setup.py | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index add0d17..305ce7e 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -10,8 +10,10 @@ filter: variables: - slug: github-owner type: STRING + defaultValue: PLACEHOLDER_GITHUB_OWNER - slug: repo-name type: STRING + defaultValue: PLACEHOLDER_REPO_NAME runResponseTemplate: | # GitHub Actions Deploy diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index e9e24aa..3ec31b7 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -360,8 +360,11 @@ def _import_cortex_workflow(self) -> None: api_key = self._answers["cortex_api_key"] alias = self._answers["github_integration_alias"] - yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( - "PLACEHOLDER_INTEGRATION_ALIAS", alias + yaml_content = ( + WORKFLOW_TEMPLATE_PATH.read_text() + .replace("PLACEHOLDER_INTEGRATION_ALIAS", alias) + .replace("PLACEHOLDER_GITHUB_OWNER", self._answers["github_owner"]) + .replace("PLACEHOLDER_REPO_NAME", self._answers["repo_name"]) ) resp = requests.post( From 9cdb333a435abe7038e449ff46cd0759130dcd8d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 13:57:58 -0700 Subject: [PATCH 39/83] feat: add UI/API branch to Cortex workflow for manual vs API invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variables default to "" instead of placeholder text. A CONDITIONAL_BRANCH as root action checks whether variables are set: - UI invocation (both empty): routes to USER_INPUT step to collect owner/repo - API invocation (both set): routes directly to trigger-deploy - Fallback (partial): errors with a clear message Reverts the owner/repo substitution in setup.py — no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/trigger-github-deploy.yaml | 106 ++++++++++++++---- .../solutions/github-actions-deploy/setup.py | 7 +- 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 305ce7e..d65a5b8 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -8,12 +8,14 @@ isRunnableViaApi: true filter: type: GLOBAL variables: - - slug: github-owner - type: STRING - defaultValue: PLACEHOLDER_GITHUB_OWNER - - slug: repo-name - type: STRING - defaultValue: PLACEHOLDER_REPO_NAME +- slug: github-owner + type: STRING + defaultValue: "" + description: GitHub organization or username that owns the repository. +- slug: repo-name + type: STRING + defaultValue: "" + description: Name of the GitHub repository to trigger the deploy workflow in. runResponseTemplate: | # GitHub Actions Deploy @@ -101,16 +103,82 @@ runResponseTemplate: | ``` actions: - - name: Trigger GitHub Actions Deploy - slug: trigger-deploy - schema: - type: HTTP_REQUEST_ASYNC - httpMethod: POST - url: "/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" - integration: GitHub - integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" - headers: {} - payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' - timeoutInSeconds: 120 - outgoingActions: [] - isRootAction: true +- name: Branch + slug: branch + schema: + branches: + - name: UI invocation + slug: ui-invocation + outgoingAction: inputs + expression: "variables[\"github-owner\"] == \"\" && variables[\"repo-name\"] == \"\"" + type: CONDITIONAL + - name: API invocation + slug: api-invocation + outgoingAction: trigger-deploy + expression: "variables[\"github-owner\"] != \"\" && variables[\"repo-name\"] != \"\"" + type: CONDITIONAL + fallbackBranch: + name: Invalid invocation + slug: invalid-invocation + outgoingAction: invalid-inputs + type: FALLBACK + joiningAction: trigger-deploy + type: CONDITIONAL_BRANCH + outgoingActions: + - inputs + - trigger-deploy + - invalid-inputs + isRootAction: true +- name: Inputs + slug: inputs + schema: + inputs: + - name: GitHub Owner + description: GitHub organization or username that owns the repository. + key: github-owner + required: true + defaultValue: null + placeholder: e.g. my-org + validationRegex: null + type: INPUT_FIELD + - name: Repository Name + description: Name of the GitHub repository containing the Cortex deploy workflow. + key: repo-name + required: true + defaultValue: null + placeholder: e.g. cortex-deploy-demo + validationRegex: null + type: INPUT_FIELD + inputOverrides: + - inputKey: github-owner + outputVariable: variables.github-owner + editable: true + type: VALUE + - inputKey: repo-name + outputVariable: variables.repo-name + editable: true + type: VALUE + type: USER_INPUT + outgoingActions: + - trigger-deploy + isRootAction: false +- name: Invalid inputs + slug: invalid-inputs + schema: + expression: error("github-owner and repo-name must both be provided for API invocation") + type: JQ + outgoingActions: [] + isRootAction: false +- name: Trigger GitHub Actions Deploy + slug: trigger-deploy + schema: + type: HTTP_REQUEST_ASYNC + httpMethod: POST + url: "/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" + integration: GitHub + integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" + headers: {} + payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' + timeoutInSeconds: 120 + outgoingActions: [] + isRootAction: false diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 3ec31b7..e9e24aa 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -360,11 +360,8 @@ def _import_cortex_workflow(self) -> None: api_key = self._answers["cortex_api_key"] alias = self._answers["github_integration_alias"] - yaml_content = ( - WORKFLOW_TEMPLATE_PATH.read_text() - .replace("PLACEHOLDER_INTEGRATION_ALIAS", alias) - .replace("PLACEHOLDER_GITHUB_OWNER", self._answers["github_owner"]) - .replace("PLACEHOLDER_REPO_NAME", self._answers["repo_name"]) + yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( + "PLACEHOLDER_INTEGRATION_ALIAS", alias ) resp = requests.post( From f79b9f1cd5c819f29f5c1a2a7333686b448a2141 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 14:08:22 -0700 Subject: [PATCH 40/83] fix: set joiningAction to null in trigger-github-deploy workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cortex rejects joiningAction when not all branches converge — the fallback (error) path is terminal so the branches don't join. Co-Authored-By: Claude Sonnet 4.6 --- .../github-actions-deploy/_templates/trigger-github-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index d65a5b8..6fc2337 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -122,7 +122,7 @@ actions: slug: invalid-invocation outgoingAction: invalid-inputs type: FALLBACK - joiningAction: trigger-deploy + joiningAction: null type: CONDITIONAL_BRANCH outgoingActions: - inputs From c7bc8051edeeefbe10afa3b183288c069849dfe1 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 14:57:02 -0700 Subject: [PATCH 41/83] fix: restructure workflow so trigger-deploy is outside the branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a JQ merge step as the joiningAction so all branch paths (inputs or pass-through) converge before the async HTTP request fires. Removes the invalid-inputs fallback error step — the OR condition on UI invocation covers all partial-input cases. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/trigger-github-deploy.yaml | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 6fc2337..07f3c70 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -110,24 +110,18 @@ actions: - name: UI invocation slug: ui-invocation outgoingAction: inputs - expression: "variables[\"github-owner\"] == \"\" && variables[\"repo-name\"] == \"\"" + expression: "variables[\"github-owner\"] == \"\" || variables[\"repo-name\"] == \"\"" type: CONDITIONAL - name: API invocation slug: api-invocation - outgoingAction: trigger-deploy + outgoingAction: pass-through expression: "variables[\"github-owner\"] != \"\" && variables[\"repo-name\"] != \"\"" type: CONDITIONAL - fallbackBranch: - name: Invalid invocation - slug: invalid-invocation - outgoingAction: invalid-inputs - type: FALLBACK - joiningAction: null + joiningAction: merge type: CONDITIONAL_BRANCH outgoingActions: - inputs - - trigger-deploy - - invalid-inputs + - pass-through isRootAction: true - name: Inputs slug: inputs @@ -160,14 +154,23 @@ actions: type: VALUE type: USER_INPUT outgoingActions: - - trigger-deploy + - merge isRootAction: false -- name: Invalid inputs - slug: invalid-inputs +- name: Pass through + slug: pass-through schema: - expression: error("github-owner and repo-name must both be provided for API invocation") + expression: "." type: JQ - outgoingActions: [] + outgoingActions: + - merge + isRootAction: false +- name: Merge branches + slug: merge + schema: + expression: "." + type: JQ + outgoingActions: + - trigger-deploy isRootAction: false - name: Trigger GitHub Actions Deploy slug: trigger-deploy From d1398c5589032e1756747c1dc230db5dec7bbdc1 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 16:11:55 -0700 Subject: [PATCH 42/83] fix: add SET_VARIABLES step to write UI inputs back to workflow variables inputOverrides write to actions.inputs.outputs.* but not to variables.* so the trigger-deploy URL template saw empty strings. Add a set-variables step (UI path only) that copies the collected values into variables before the branches merge. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/trigger-github-deploy.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 07f3c70..38c0f88 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -154,6 +154,22 @@ actions: type: VALUE type: USER_INPUT outgoingActions: + - set-variables + isRootAction: false +- name: Set variables + slug: set-variables + schema: + variables: + - slug: github-owner + source: + path: actions.inputs.outputs.github-owner + type: REFERENCE + - slug: repo-name + source: + path: actions.inputs.outputs.repo-name + type: REFERENCE + type: SET_VARIABLES + outgoingActions: - merge isRootAction: false - name: Pass through From 3e86e9236099fb73af9c44566595d68fc57da4f1 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 16:26:00 -0700 Subject: [PATCH 43/83] chore: add realistic build delay to cortex-deploy.yml Sleep 15s then print 3 progress updates every 5s so the demo shows visible deployment activity (~30s build) rather than instant completion. Co-Authored-By: Claude Sonnet 4.6 --- .../github-actions-deploy/_templates/cortex-deploy.yml | 9 ++++++++- .../_templates/trigger-github-deploy.yaml | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 828b77f..43a636f 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -15,7 +15,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Build - run: echo "Hello, Cortex deploys!" + run: | + echo "Starting deployment..." + sleep 15 + for i in 1 2 3; do + echo "Deployment progress: step $i/3" + sleep 5 + done + echo "Build complete!" notify-cortex: needs: build diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 38c0f88..017f558 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -45,7 +45,14 @@ runResponseTemplate: | runs-on: ubuntu-latest steps: - name: Build - run: echo "Hello, Cortex deploys!" + run: | + echo "Starting deployment..." + sleep 15 + for i in 1 2 3; do + echo "Deployment progress: step $i/3" + sleep 5 + done + echo "Build complete!" notify-cortex: needs: build From 88e65a3f35900666b8f79f4be808b19736d459ed Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 16:47:52 -0700 Subject: [PATCH 44/83] feat: add entity-aware deploy workflow and incremental GH callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy-from-entity.yaml: entity-scoped workflow that reads the linked GitHub repo from the entity's catalog config (no user input needed). Reads context.entity.tag → GET /api/v1/catalog/{tag} → split git.repository → trigger dispatch. HTTP_REQUEST outputs in .body.* - setup.py: imports both workflows when a GitHub integration is configured - cortex-deploy.yml: incremental IN_PROGRESS callbacks every 5s after 10s initial wait; sleep before callback so no trailing sleep at end Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/cortex-deploy.yml | 11 ++- .../_templates/deploy-from-entity.yaml | 84 +++++++++++++++++++ .../_templates/trigger-github-deploy.yaml | 11 ++- .../solutions/github-actions-deploy/setup.py | 28 +++++++ 4 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 cortexapps_cli/solutions/github-actions-deploy/_templates/deploy-from-entity.yaml diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 43a636f..6d758a7 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -17,10 +17,17 @@ jobs: - name: Build run: | echo "Starting deployment..." - sleep 15 + sleep 10 for i in 1 2 3; do - echo "Deployment progress: step $i/3" sleep 5 + echo "Deployment progress: step $i/3" + if [ -n "${{ inputs.cortex_callback_url }}" ]; then + curl -s -X POST "${{ inputs.cortex_callback_url }}" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg msg "Deployment progress: step $i/3" \ + '{"status": "IN_PROGRESS", "message": $msg, "output": {}}')" || true + fi done echo "Build complete!" diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/deploy-from-entity.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/deploy-from-entity.yaml new file mode 100644 index 0000000..2ff98b3 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/deploy-from-entity.yaml @@ -0,0 +1,84 @@ +name: Deploy from Entity +tag: github-actions-deploy-entity +description: | + Triggers the Cortex deploy workflow on GitHub Actions using the GitHub + repository already linked to this service. No inputs required — the + repo is read directly from the entity's catalog configuration. +isDraft: false +isRunnableViaApi: true +filter: + type: ENTITY +variables: +- slug: github-owner + type: STRING + defaultValue: "" + description: GitHub organization or username (derived from entity git config). +- slug: repo-name + type: STRING + defaultValue: "" + description: GitHub repository name (derived from entity git config). +runResponseTemplate: | + # GitHub Actions Deploy + + Deploy complete for **{{variables.github-owner}}/{{variables.repo-name}}**. + + [View GitHub Actions runs](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/actions) +actions: +- name: Get entity details + slug: get-entity-details + schema: + type: HTTP_REQUEST + httpMethod: GET + url: "https://api.getcortexapp.com/api/v1/catalog/{{context.entity.tag}}" + headers: + Authorization: "Bearer {{&context.secrets.cortex_api_key}}" + Content-Type: application/json + integration: null + integrationAlias: null + outgoingActions: + - parse-repo + isRootAction: true +- name: Parse repo info + slug: parse-repo + schema: + expression: | + .actions."get-entity-details".outputs.body.git.repository as $repo | + if ($repo == null or $repo == "") then + error("No GitHub repository linked to this entity. Add one via the catalog.") + else + ($repo | split("/")) as $parts | + {owner: $parts[0], repo: $parts[1]} + end + type: JQ + outgoingActions: + - set-variables + isRootAction: false +- name: Set variables + slug: set-variables + schema: + variables: + - slug: github-owner + source: + path: actions.parse-repo.outputs.result.owner + type: REFERENCE + - slug: repo-name + source: + path: actions.parse-repo.outputs.result.repo + type: REFERENCE + type: SET_VARIABLES + outgoingActions: + - trigger-deploy + isRootAction: false +- name: Trigger GitHub Actions Deploy + slug: trigger-deploy + schema: + type: HTTP_REQUEST_ASYNC + httpMethod: POST + url: "/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" + integration: GitHub + integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" + headers: {} + payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' + timeoutInSeconds: 120 + outgoingActions: [] + isRootAction: false diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml index 017f558..3c80186 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml @@ -47,10 +47,17 @@ runResponseTemplate: | - name: Build run: | echo "Starting deployment..." - sleep 15 + sleep 10 for i in 1 2 3; do - echo "Deployment progress: step $i/3" sleep 5 + echo "Deployment progress: step $i/3" + if [ -n "$CORTEX_CALLBACK_URL" ]; then + curl -s -X POST "$CORTEX_CALLBACK_URL" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg msg "Deployment progress: step $i/3" \ + '{"status": "IN_PROGRESS", "message": $msg, "output": {}}')" || true + fi done echo "Build complete!" diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index e9e24aa..f230e18 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -24,6 +24,7 @@ GITHUB_API = "https://api.github.com" TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "trigger-github-deploy.yaml" +ENTITY_WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "deploy-from-entity.yaml" def _hyperlink(url: str, text: str = None) -> str: @@ -176,6 +177,7 @@ def steps(self) -> list[tuple[str, callable]]: ] if self._answers.get("github_integration_alias"): steps.append(("Importing Cortex trigger workflow", self._import_cortex_workflow)) + steps.append(("Importing Cortex entity deploy workflow", self._import_entity_workflow)) return steps def post_steps(self) -> None: @@ -378,6 +380,32 @@ def _import_cortex_workflow(self) -> None: f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" ) + def _import_entity_workflow(self) -> None: + """Import the entity-scoped deploy workflow.""" + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + alias = self._answers["github_integration_alias"] + + yaml_content = ENTITY_WORKFLOW_TEMPLATE_PATH.read_text().replace( + "PLACEHOLDER_INTEGRATION_ALIAS", alias + ).replace( + "https://api.getcortexapp.com", base_url + ) + + resp = requests.post( + f"{base_url}/api/v1/workflows", + data=yaml_content.encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/yaml", + }, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to import entity workflow: {resp.status_code} {resp.text}" + ) + def _trigger_via_cortex_workflow(self) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" import time From 55bf031cfea8d1648a167fb0943926bc8c3c8f2f Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 19:01:43 -0700 Subject: [PATCH 45/83] chore: clarify workflow trigger prompt and show run URL - Show equivalent CLI/API call before triggering - Print run ID and link to Cortex workflow runs page after completion - Show GitHub Actions URL for both integration and direct-trigger paths Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index f230e18..938f21c 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -189,29 +189,47 @@ def post_steps(self) -> None: cortex_url = f"{app_url}/admin/resources?tag=github-actions-demo" gh_url = f"https://github.com/{owner}/{repo}" - if self.confirm("Trigger a workflow run now?", default=True): + workflow_tag = "github-actions-trigger-deploy" + workflows_url = f"{app_url}/admin/workflows" + + if self._answers.get("github_integration_alias"): + print(f"\nTo trigger manually later:") + print(f" cortex workflows runs create -t {workflow_tag} \\") + print(f" --variable github-owner={owner} --variable repo-name={repo}") + + if self.confirm("\nTrigger a workflow run now?", default=True): if self._answers.get("github_integration_alias"): # Use Cortex async workflow — waits for GitHub Actions callback - print(" Starting Cortex workflow run (waiting for GitHub Actions to complete)...") + print(f" Running: POST /api/v1/workflows/{workflow_tag}/runs") + print(f" github-owner={owner}, repo-name={repo}") try: result = self._trigger_via_cortex_workflow() status = result.get("status", "").upper() + run_id = result.get("_run_id", "") if status == "COMPLETED": gh_actions_url = f"https://github.com/{owner}/{repo}/actions" print(f" Deploy complete \u2713") + if run_id: + print(f" Run ID: {run_id}") + print(f" {_hyperlink(workflows_url, 'View workflow runs in Cortex')}") print(f" {_hyperlink(gh_actions_url, 'View GitHub Actions runs')}") self.mark_done("first_deploy") else: print(f" Workflow ended with status: {status}", file=sys.stderr) + if run_id: + print(f" Run ID: {run_id}", file=sys.stderr) + print(f" {_hyperlink(workflows_url, 'View workflow runs in Cortex')}", file=sys.stderr) except Exception as e: print(f" Trigger failed: {e}", file=sys.stderr) print(f" Re-trigger via: cortex solutions post-install -s {self.solution_tag}", file=sys.stderr) else: # No integration — trigger GitHub Actions directly + gh_dispatch_url = f"https://api.github.com/repos/{owner}/{repo}/actions/workflows/cortex-deploy.yml/dispatches" + print(f" Running: POST {gh_dispatch_url}") try: self._trigger_direct() print(f" GitHub Actions workflow triggered \u2713") - print(" (No Cortex integration configured — cannot wait for completion)") + print(f" {_hyperlink(f'https://github.com/{owner}/{repo}/actions', 'View GitHub Actions runs')}") except Exception as e: print(f" Trigger failed: {e}", file=sys.stderr) @@ -433,9 +451,11 @@ def _trigger_via_cortex_workflow(self) -> dict: if resp.status_code not in (200, 201): raise RuntimeError(f"Failed to start workflow run: {resp.status_code} {resp.text}") - run_id = resp.json().get("id") + run_data = resp.json() + run_id = run_data.get("id") if not run_id: raise RuntimeError("No run ID returned from workflow start") + workflow_cid = run_data.get("workflow", {}).get("cid", "") terminal = {"COMPLETED", "FAILED", "CANCELLED"} start = time.time() @@ -452,7 +472,10 @@ def _trigger_via_cortex_workflow(self) -> dict: print(f"\r Waiting for GitHub Actions{'.' * (dots % 4)} ", end="", flush=True) if status in terminal: print() # newline after dots - return r.json() + result = r.json() + result["_run_id"] = run_id + result["_workflow_cid"] = workflow_cid + return result raise TimeoutError("Timed out waiting for workflow to complete (5 min)") From 1e2d4c271d5c7f58dfe8d5a14dcdb18d2f46a6c3 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 08:36:08 -0700 Subject: [PATCH 46/83] chore: show per-step detail output in github-actions-deploy setup Each setup step now prints what was created or already existed, with a clickable URL to view it (GitHub repo, workflow file, secrets page, entity, Cortex workflows admin). --- cortexapps_cli/solutions/_lib/setup_base.py | 6 ++- .../solutions/github-actions-deploy/setup.py | 43 +++++++++++++++---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py index 30c082f..d58f6df 100644 --- a/cortexapps_cli/solutions/_lib/setup_base.py +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -101,8 +101,12 @@ def run(self) -> None: total = len(step_list) for i, (label, fn) in enumerate(step_list, 1): try: - fn() + detail = fn() print(f"[{i}/{total}] {label}... \u2713") + if detail: + lines = [detail] if isinstance(detail, str) else detail + for line in lines: + print(f" {line}") except Exception as e: print(f"[{i}/{total}] {label}... \u2717 {e}", file=sys.stderr) raise SystemExit(1) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 938f21c..44f54ae 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -237,13 +237,14 @@ def post_steps(self) -> None: print(f" {_hyperlink(cortex_url)}") print(f"\nGitHub repo: {_hyperlink(gh_url)}") - def _create_repo(self) -> None: + def _create_repo(self) -> str: owner = self._answers["github_owner"] repo = self._answers["repo_name"] + gh_url = f"https://github.com/{owner}/{repo}" check = requests.get(f"{GITHUB_API}/repos/{owner}/{repo}", headers=self._gh_headers()) if check.status_code == 200: - return # already exists + return f"Already exists: {_hyperlink(gh_url)}" if check.status_code != 404: raise RuntimeError(f"Unexpected status checking repo existence: {check.status_code} {check.text}") @@ -262,13 +263,15 @@ def _create_repo(self) -> None: ) if resp.status_code not in (200, 201): raise RuntimeError(f"Failed to create repo: {resp.status_code} {resp.text}") + return f"Created: {_hyperlink(gh_url)}" - def _seed_workflow(self) -> None: + def _seed_workflow(self) -> str: owner = self._answers["github_owner"] repo = self._answers["repo_name"] path = ".github/workflows/cortex-deploy.yml" content = TEMPLATE_PATH.read_text() content_b64 = base64.b64encode(content.encode()).decode() + file_url = f"https://github.com/{owner}/{repo}/blob/main/{path}" check = requests.get( f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", @@ -281,8 +284,11 @@ def _seed_workflow(self) -> None: existing = check.json() existing_content = base64.b64decode(existing["content"].replace("\n", "")).decode() if existing_content == content: - return # unchanged + return f"Already up to date: {_hyperlink(file_url)}" payload["sha"] = existing["sha"] + action = "Updated" + else: + action = "Added" resp = requests.put( f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", @@ -291,10 +297,12 @@ def _seed_workflow(self) -> None: ) if resp.status_code not in (200, 201): raise RuntimeError(f"Failed to seed workflow: {resp.status_code} {resp.text}") + return f"{action}: {_hyperlink(file_url)}" - def _set_secret(self, secret_name: str, secret_value: str) -> None: + def _set_secret(self, secret_name: str, secret_value: str) -> str: owner = self._answers["github_owner"] repo = self._answers["repo_name"] + secrets_url = f"https://github.com/{owner}/{repo}/settings/secrets/actions" key_resp = requests.get( f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/public-key", @@ -313,6 +321,8 @@ def _set_secret(self, secret_name: str, secret_value: str) -> None: ) if resp.status_code not in (201, 204): raise RuntimeError(f"Failed to set secret {secret_name}: {resp.status_code} {resp.text}") + action = "Created" if resp.status_code == 201 else "Updated" + return f"{action} GitHub Actions secret {secret_name}: {_hyperlink(secrets_url, 'View secrets')}" def _trigger_direct(self) -> None: """Trigger the GitHub Actions workflow directly via the GitHub API.""" @@ -341,12 +351,14 @@ def _create_github_pat_integration(self) -> None: if resp.status_code not in (200, 201): raise RuntimeError(f"{resp.status_code} {resp.text}") - def _link_github_repo(self) -> None: + def _link_github_repo(self) -> list: """PATCH the Cortex entity to link the GitHub repository so workflows are discovered.""" owner = self._answers["github_owner"] repo = self._answers["repo_name"] base_url = self._answers["cortex_base_url"].rstrip("/") api_key = self._answers["cortex_api_key"] + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + entity_url = f"{app_url}/admin/resources?tag=github-actions-demo" yaml_content = f"""\ openapi: "3.0.0" @@ -373,12 +385,21 @@ def _link_github_repo(self) -> None: ) if resp.status_code not in (200, 201): raise RuntimeError(f"Failed to link GitHub repo to entity: {resp.status_code} {resp.text}") + return [ + f"Patched entity github-actions-demo with:", + f" x-cortex-git:", + f" github:", + f' repository: "{owner}/{repo}"', + f"View entity: {_hyperlink(entity_url)}", + ] - def _import_cortex_workflow(self) -> None: + def _import_cortex_workflow(self) -> str: """Import the Cortex trigger workflow with the selected GitHub integration alias.""" base_url = self._answers["cortex_base_url"].rstrip("/") api_key = self._answers["cortex_api_key"] alias = self._answers["github_integration_alias"] + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + workflows_url = f"{app_url}/admin/workflows" yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( "PLACEHOLDER_INTEGRATION_ALIAS", alias @@ -397,12 +418,16 @@ def _import_cortex_workflow(self) -> None: raise RuntimeError( f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" ) + action = "Created" if resp.status_code == 201 else "Updated" + return f"{action} workflow 'github-actions-trigger-deploy': {_hyperlink(workflows_url, 'View workflows')}" - def _import_entity_workflow(self) -> None: + def _import_entity_workflow(self) -> str: """Import the entity-scoped deploy workflow.""" base_url = self._answers["cortex_base_url"].rstrip("/") api_key = self._answers["cortex_api_key"] alias = self._answers["github_integration_alias"] + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + workflows_url = f"{app_url}/admin/workflows" yaml_content = ENTITY_WORKFLOW_TEMPLATE_PATH.read_text().replace( "PLACEHOLDER_INTEGRATION_ALIAS", alias @@ -423,6 +448,8 @@ def _import_entity_workflow(self) -> None: raise RuntimeError( f"Failed to import entity workflow: {resp.status_code} {resp.text}" ) + action = "Created" if resp.status_code == 201 else "Updated" + return f"{action} workflow 'github-actions-deploy-entity': {_hyperlink(workflows_url, 'View workflows')}" def _trigger_via_cortex_workflow(self) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" From 528e295c9c6fc17652148e47443e09f41356c987 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:18:51 -0700 Subject: [PATCH 47/83] feat: persist solution answers to ~/.cortex/solutions/ with --no-prompt support Non-secret answers are saved to ~/.cortex/solutions/.json after each run. Re-running with --no-prompt skips all input prompts and uses saved values, still prompting for secrets that can't be persisted. Old ~/.cortex/setup-.json state files are migrated automatically. --- cortexapps_cli/commands/solutions.py | 11 ++- cortexapps_cli/solutions/_lib/setup_base.py | 75 ++++++++++++++++--- .../solutions/github-actions-deploy/setup.py | 4 +- 3 files changed, 74 insertions(+), 16 deletions(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index f3d2901..794a66f 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -227,7 +227,7 @@ def _get_setup_description(solution_tag: str, solutions_dir: str | None = None) return "This solution includes a post-install setup script." -def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None, ctx=None) -> None: +def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None, ctx=None, no_prompt: bool = False) -> None: """Find and invoke the solution's setup.py main() function.""" module = _load_setup_module(solution_tag, solutions_dir) if module is None: @@ -238,6 +238,7 @@ def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None client = ctx.obj["client"] kwargs["cortex_api_key"] = client.api_key kwargs["cortex_base_url"] = client.base_url + kwargs["no_prompt"] = no_prompt module.main(**kwargs) @@ -747,6 +748,12 @@ def _do_import() -> None: def post_install( ctx: typer.Context, solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), + no_prompt: bool = typer.Option( + False, + "--no-prompt", + "-N", + help="Use saved answers from ~/.cortex/solutions/.json without prompting.", + ), ): """Run post-install setup for a solution.""" solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None @@ -755,7 +762,7 @@ def post_install( typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") raise typer.Exit(1) ctx.obj["client"] = _build_client(ctx) - _run_post_install_script(solution, solutions_dir=solutions_dir, ctx=ctx) + _run_post_install_script(solution, solutions_dir=solutions_dir, ctx=ctx, no_prompt=no_prompt) @app.command() diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py index d58f6df..4c7a6b1 100644 --- a/cortexapps_cli/solutions/_lib/setup_base.py +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -15,14 +15,22 @@ class SolutionSetup(ABC): solution_tag: str # must be set by subclass - def __init__(self, state_dir: Optional[Path] = None): + def __init__(self, state_dir: Optional[Path] = None, no_prompt: bool = False): + self._no_prompt = no_prompt + self._secret_keys: set = set() self._answers: dict = {} - state_dir = state_dir or Path.home() / ".cortex" - state_dir.mkdir(parents=True, exist_ok=True) - self._state_file = state_dir / f"setup-{self.solution_tag}.json" - self._state: dict = self._load_state() - def _load_state(self) -> dict: + solutions_dir = state_dir or Path.home() / ".cortex" / "solutions" + solutions_dir.mkdir(parents=True, exist_ok=True) + self._state_file = solutions_dir / f"{self.solution_tag}.json" + + data = self._load_file() + self._answers = data.get("answers", {}) + self._state: dict = data.get("state", {}) + + self._migrate_old_state() + + def _load_file(self) -> dict: if self._state_file.exists(): try: return json.loads(self._state_file.read_text()) @@ -30,8 +38,29 @@ def _load_state(self) -> dict: return {} return {} + def _save_file(self) -> None: + data = { + "answers": {k: v for k, v in self._answers.items() if k not in self._secret_keys}, + "state": self._state, + } + self._state_file.write_text(json.dumps(data, indent=2)) + def _save_state(self) -> None: - self._state_file.write_text(json.dumps(self._state, indent=2)) + self._save_file() + + def _migrate_old_state(self) -> None: + """Move state from the old flat ~/.cortex/setup-.json into the new file.""" + old_file = Path.home() / ".cortex" / f"setup-{self.solution_tag}.json" + if not old_file.exists(): + return + try: + old_data = json.loads(old_file.read_text()) + if old_data and not self._state: + self._state.update(old_data) + self._save_file() + old_file.unlink() + except Exception: + pass def prompt( self, @@ -41,15 +70,25 @@ def prompt( default: Optional[str] = None, secret: bool = False, ) -> str: - """Prompt for a value. Uses env var if set, then prompts with optional default.""" + """Prompt for a value. Uses saved answer or env var when available.""" + if secret: + self._secret_keys.add(key) + + # Non-secret: use saved answer when --no-prompt + if self._no_prompt and not secret and key in self._answers: + return self._answers[key] + if env_var: env_val = os.environ.get(env_var) if env_val: masked = "********" if secret else env_val - if self.confirm(f"{message} [{masked} from {env_var}]", default=True): + if self._no_prompt or self.confirm(f"{message} [{masked} from {env_var}]", default=True): self._answers[key] = env_val return env_val - # User declined — fall through to manual prompt + + # Secrets in --no-prompt mode still need a prompt if no env var provided + if self._no_prompt and secret and key not in self._answers: + print(f" (secret required — no env var set for {key})", file=sys.stderr) prompt_str = message if default: @@ -66,7 +105,9 @@ def prompt( return value def confirm(self, message: str, default: bool = True) -> bool: - """Y|N confirmation prompt.""" + """Y|N confirmation prompt. Auto-accepts default when --no-prompt.""" + if self._no_prompt: + return default hint = "[Y/n]" if default else "[y/N]" response = input(f"{message} {hint}: ").strip().lower() if not response: @@ -80,7 +121,7 @@ def already_done(self, key: str) -> bool: def mark_done(self, key: str) -> None: """Mark a step as completed in the persistent state file.""" self._state[key] = True - self._save_state() + self._save_file() @abstractmethod def collect_prompts(self) -> None: @@ -95,7 +136,17 @@ def post_steps(self) -> None: def run(self) -> None: """Collect prompts then execute steps with progress display.""" + if self._no_prompt and self._answers: + saved = {k: v for k, v in self._answers.items() if k not in self._secret_keys} + if saved: + print("Using saved configuration:") + for k, v in saved.items(): + print(f" {k}: {v}") + print() + self.collect_prompts() + self._save_file() + print() step_list = self.steps() total = len(step_list) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 44f54ae..7891fb4 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -44,8 +44,8 @@ def _encrypt_secret(public_key_b64: str, secret_value: str) -> str: class GitHubActionsSetup(SolutionSetup): solution_tag = "github-actions-deploy" - def __init__(self, cortex_api_key: str = None, cortex_base_url: str = None, **kwargs): - super().__init__(**kwargs) + def __init__(self, cortex_api_key: str = None, cortex_base_url: str = None, no_prompt: bool = False, **kwargs): + super().__init__(no_prompt=no_prompt, **kwargs) self._session_api_key = cortex_api_key self._session_base_url = cortex_base_url From b1943bf23542e2779bb08abdb9c3f67e7377449a Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:20:57 -0700 Subject: [PATCH 48/83] chore: use saved answers as defaults in interactive prompts --- cortexapps_cli/solutions/_lib/setup_base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py index 4c7a6b1..2db5b1c 100644 --- a/cortexapps_cli/solutions/_lib/setup_base.py +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -78,6 +78,10 @@ def prompt( if self._no_prompt and not secret and key in self._answers: return self._answers[key] + # Non-secret: use saved answer as default in interactive mode + if not secret and key in self._answers and default is None: + default = self._answers[key] + if env_var: env_val = os.environ.get(env_var) if env_val: From ee486f7ace7861895a20054fdc664877933eb2e9 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:24:08 -0700 Subject: [PATCH 49/83] chore: add blank line after each setup step for readability --- cortexapps_cli/solutions/_lib/setup_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py index 2db5b1c..9b13555 100644 --- a/cortexapps_cli/solutions/_lib/setup_base.py +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -162,6 +162,7 @@ def run(self) -> None: lines = [detail] if isinstance(detail, str) else detail for line in lines: print(f" {line}") + print() except Exception as e: print(f"[{i}/{total}] {label}... \u2717 {e}", file=sys.stderr) raise SystemExit(1) From 5bf7e32d167ffbe5ca0f6695d2ce66fcd79c4299 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:26:40 -0700 Subject: [PATCH 50/83] chore: update workflow runs URL to include activeTab=runs query param --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 7891fb4..aaa998c 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -190,7 +190,7 @@ def post_steps(self) -> None: gh_url = f"https://github.com/{owner}/{repo}" workflow_tag = "github-actions-trigger-deploy" - workflows_url = f"{app_url}/admin/workflows" + workflows_url = f"{app_url}/admin/workflows?activeTab=runs" if self._answers.get("github_integration_alias"): print(f"\nTo trigger manually later:") @@ -399,7 +399,7 @@ def _import_cortex_workflow(self) -> str: api_key = self._answers["cortex_api_key"] alias = self._answers["github_integration_alias"] app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url - workflows_url = f"{app_url}/admin/workflows" + workflows_url = f"{app_url}/admin/workflows?activeTab=runs" yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( "PLACEHOLDER_INTEGRATION_ALIAS", alias @@ -427,7 +427,7 @@ def _import_entity_workflow(self) -> str: api_key = self._answers["cortex_api_key"] alias = self._answers["github_integration_alias"] app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url - workflows_url = f"{app_url}/admin/workflows" + workflows_url = f"{app_url}/admin/workflows?activeTab=runs" yaml_content = ENTITY_WORKFLOW_TEMPLATE_PATH.read_text().replace( "PLACEHOLDER_INTEGRATION_ALIAS", alias From e438b362a944d1c3bebd31ed21e004769b62c52e Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:28:17 -0700 Subject: [PATCH 51/83] chore: rename 'Importing' to 'Creating' in workflow step labels --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index aaa998c..c436540 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -176,8 +176,8 @@ def steps(self) -> list[tuple[str, callable]]: ("Linking GitHub repository to entity", self._link_github_repo), ] if self._answers.get("github_integration_alias"): - steps.append(("Importing Cortex trigger workflow", self._import_cortex_workflow)) - steps.append(("Importing Cortex entity deploy workflow", self._import_entity_workflow)) + steps.append(("Creating Cortex trigger workflow", self._import_cortex_workflow)) + steps.append(("Creating Cortex entity deploy workflow", self._import_entity_workflow)) return steps def post_steps(self) -> None: From d3c54af45ce51ac0e1c2dc0c7c1fca45f34bf220 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:29:44 -0700 Subject: [PATCH 52/83] docs: document both Cortex workflows and recommend Deploy from Entity --- .../solutions/github-actions-deploy/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/cortexapps_cli/solutions/github-actions-deploy/README.md b/cortexapps_cli/solutions/github-actions-deploy/README.md index 2053a4a..9ab7e12 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/README.md +++ b/cortexapps_cli/solutions/github-actions-deploy/README.md @@ -9,6 +9,8 @@ description: Track deployments from GitHub Actions in Cortex, with a deploy heal - **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency - **GitHub Actions workflow:** A two-job workflow (build → deploy notification) to seed into a GitHub repo - **Setup script:** Interactive wizard that creates and seeds a GitHub repo end-to-end +- **Cortex workflow — Deploy from Entity** _(recommended)_: triggers a GitHub Actions deploy directly from any entity that has a linked GitHub repository — no manual input required +- **Cortex workflow — Trigger GitHub Actions Deploy**: triggers a deploy by supplying the GitHub owner and repo name explicitly; handles both UI and API invocation via a branch + variables pattern ## Quick Start @@ -24,6 +26,23 @@ description: Track deployments from GitHub Actions in Cortex, with a deploy heal cortex solutions post-install -s github-actions-deploy ``` +## Cortex Workflows + +Two Cortex workflows are included. Both trigger the same GitHub Actions deploy and wait for a callback, but differ in how the target repository is resolved. + +### Deploy from Entity _(recommended)_ + +Reads the GitHub repository directly from the entity's catalog configuration — no user input needed. Run it from any entity that has a `x-cortex-git.github.repository` set. + +Use this workflow for day-to-day deploys. It will likely replace the trigger workflow below once entity-linked repos are the standard pattern. + +### Trigger GitHub Actions Deploy + +Prompts for a GitHub owner and repository name when triggered from the UI, or accepts them as variables when triggered via API. This workflow is a good reference example of: + +- **Variables + branch pattern**: a `CONDITIONAL_BRANCH` routes UI invocations through a `USER_INPUT` step to collect the owner and repo, while API invocations skip it entirely and pass through to the same merge point +- **SET_VARIABLES**: copies `USER_INPUT` outputs into workflow variables so they're available to later steps regardless of which path was taken + ## How It Works The included GitHub Actions workflow fires a deploy event to Cortex after every successful build. From 44fbac0b7c581b5222f4ef434af2e4a00fa5cd29 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:32:43 -0700 Subject: [PATCH 53/83] chore: fix saved answers not surfacing as defaults on re-run Saved answer now beats any derived default in prompt(). Integration selection also checks saved alias before falling back to isDefault. --- cortexapps_cli/solutions/_lib/setup_base.py | 4 ++-- cortexapps_cli/solutions/github-actions-deploy/setup.py | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py index 9b13555..d0645d5 100644 --- a/cortexapps_cli/solutions/_lib/setup_base.py +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -78,8 +78,8 @@ def prompt( if self._no_prompt and not secret and key in self._answers: return self._answers[key] - # Non-secret: use saved answer as default in interactive mode - if not secret and key in self._answers and default is None: + # Non-secret: saved answer takes precedence over any derived default + if not secret and key in self._answers: default = self._answers[key] if env_var: diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index c436540..e6410c9 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -81,7 +81,11 @@ def _fetch_github_integrations(self) -> list: def _select_github_integration(self, integrations: list) -> str: """Present a numbered list and return the chosen alias.""" integrations = sorted(integrations, key=lambda c: c.get("alias", "").lower()) + saved_alias = self._answers.get("github_integration_alias") default_idx = next( + (i for i, c in enumerate(integrations) if c.get("alias") == saved_alias), + None, + ) or next( (i for i, c in enumerate(integrations) if c.get("isDefault")), 0 ) print("\nGitHub integrations configured in Cortex:") From 08eb93c92f553c411d4fb33b5c8a5ea1ae9186cd Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:34:26 -0700 Subject: [PATCH 54/83] chore: use Deploy from Entity workflow for trigger; update manual instructions Post-steps now shows CLI and UI paths using the entity workflow (no variables needed). The demo trigger also switches to the entity workflow with entity scope instead of the global workflow with variables. --- .../solutions/github-actions-deploy/setup.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index e6410c9..ffe3596 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -194,18 +194,19 @@ def post_steps(self) -> None: gh_url = f"https://github.com/{owner}/{repo}" workflow_tag = "github-actions-trigger-deploy" + entity_workflow_tag = "github-actions-deploy-entity" + entity_url = f"{app_url}/admin/resources?tag=github-actions-demo" workflows_url = f"{app_url}/admin/workflows?activeTab=runs" if self._answers.get("github_integration_alias"): - print(f"\nTo trigger manually later:") - print(f" cortex workflows runs create -t {workflow_tag} \\") - print(f" --variable github-owner={owner} --variable repo-name={repo}") + print(f"\nTo trigger a deploy manually later:") + print(f" CLI: cortex workflows runs create -t {entity_workflow_tag} --entity github-actions-demo") + print(f" UI: {_hyperlink(entity_url, 'Open entity')} → Workflows tab → Deploy from Entity → Run") if self.confirm("\nTrigger a workflow run now?", default=True): if self._answers.get("github_integration_alias"): # Use Cortex async workflow — waits for GitHub Actions callback - print(f" Running: POST /api/v1/workflows/{workflow_tag}/runs") - print(f" github-owner={owner}, repo-name={repo}") + print(f" Running: POST /api/v1/workflows/{entity_workflow_tag}/runs") try: result = self._trigger_via_cortex_workflow() status = result.get("status", "").upper() @@ -465,14 +466,10 @@ def _trigger_via_cortex_workflow(self) -> dict: "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } - workflow_tag = "github-actions-trigger-deploy" + workflow_tag = "github-actions-deploy-entity" body = { - "scope": {"type": "GLOBAL"}, - "initialContext": { - "github-owner": self._answers["github_owner"], - "repo-name": self._answers["repo_name"], - }, + "scope": {"type": "ENTITY", "entityTag": "github-actions-demo"}, } resp = requests.post( f"{base_url}/api/v1/workflows/{workflow_tag}/runs", From 75453db93a98e5230300bb442731a31f6f6c9d65 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:36:02 -0700 Subject: [PATCH 55/83] chore: show entity tag in UI hyperlink text --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index ffe3596..6ddf467 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -201,7 +201,7 @@ def post_steps(self) -> None: if self._answers.get("github_integration_alias"): print(f"\nTo trigger a deploy manually later:") print(f" CLI: cortex workflows runs create -t {entity_workflow_tag} --entity github-actions-demo") - print(f" UI: {_hyperlink(entity_url, 'Open entity')} → Workflows tab → Deploy from Entity → Run") + print(f" UI: {_hyperlink(entity_url, 'Open github-actions-demo')} → Workflows tab → Deploy from Entity → Run") if self.confirm("\nTrigger a workflow run now?", default=True): if self._answers.get("github_integration_alias"): From 44e62ab5d0740faefc304adbdd9c0bde2da2a159 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:40:40 -0700 Subject: [PATCH 56/83] chore: consolidate to single deploy workflow; fix entity-scoped run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove trigger-github-deploy.yaml (variables+branch pattern preserved in skill) - Rename deploy-from-entity.yaml → github-actions-deploy.yaml, tag → github-actions-deploy - setup.py: single import step, entityId lookup before entity-scoped run --- ...entity.yaml => github-actions-deploy.yaml} | 2 +- .../_templates/trigger-github-deploy.yaml | 217 ------------------ .../solutions/github-actions-deploy/setup.py | 61 ++--- 3 files changed, 22 insertions(+), 258 deletions(-) rename cortexapps_cli/solutions/github-actions-deploy/_templates/{deploy-from-entity.yaml => github-actions-deploy.yaml} (98%) delete mode 100644 cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/deploy-from-entity.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml similarity index 98% rename from cortexapps_cli/solutions/github-actions-deploy/_templates/deploy-from-entity.yaml rename to cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index 2ff98b3..3757585 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/deploy-from-entity.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -1,5 +1,5 @@ name: Deploy from Entity -tag: github-actions-deploy-entity +tag: github-actions-deploy description: | Triggers the Cortex deploy workflow on GitHub Actions using the GitHub repository already linked to this service. No inputs required — the diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml deleted file mode 100644 index 3c80186..0000000 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/trigger-github-deploy.yaml +++ /dev/null @@ -1,217 +0,0 @@ -name: Trigger GitHub Actions Deploy -tag: github-actions-trigger-deploy -description: | - Triggers the Cortex deploy workflow on GitHub Actions and waits for the deployment to complete. - GitHub Actions calls back to Cortex when finished, surfacing the result directly in this workflow run. -isDraft: false -isRunnableViaApi: true -filter: - type: GLOBAL -variables: -- slug: github-owner - type: STRING - defaultValue: "" - description: GitHub organization or username that owns the repository. -- slug: repo-name - type: STRING - defaultValue: "" - description: Name of the GitHub repository to trigger the deploy workflow in. -runResponseTemplate: | - # GitHub Actions Deploy - - Deploy complete for **{{variables.github-owner}}/{{variables.repo-name}}**. - - [View GitHub Actions runs](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/actions) - - --- - - ## GitHub workflow used for this deploy - - ```yaml - name: Cortex Deploy - - on: - push: - branches: [main] - workflow_dispatch: - inputs: - cortex_callback_url: - description: 'Cortex async workflow callback URL (set automatically when triggered via Cortex workflow)' - required: false - default: '' - - jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Build - run: | - echo "Starting deployment..." - sleep 10 - for i in 1 2 3; do - sleep 5 - echo "Deployment progress: step $i/3" - if [ -n "$CORTEX_CALLBACK_URL" ]; then - curl -s -X POST "$CORTEX_CALLBACK_URL" \ - -H "Authorization: Bearer $CORTEX_API_KEY" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg msg "Deployment progress: step $i/3" \ - '{"status": "IN_PROGRESS", "message": $msg, "output": {}}')" || true - fi - done - echo "Build complete!" - - notify-cortex: - needs: build - runs-on: ubuntu-latest - steps: - - name: Register deploy in Cortex - continue-on-error: true - run: | - curl -s -f -X POST \ - "$CORTEX_BASE_URL/api/v1/catalog/github-actions-demo/deploys" \ - -H "Authorization: Bearer $CORTEX_API_KEY" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg sha '$GITHUB_SHA' \ - --arg actor '$GITHUB_ACTOR' \ - --arg branch '$GITHUB_REF_NAME' \ - --arg run_id '$GITHUB_RUN_ID' \ - --arg run_url '$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID' \ - --arg trigger '$GITHUB_EVENT_NAME' \ - --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '{ - sha: $sha, - timestamp: $timestamp, - environment: "production", - type: "DEPLOY", - title: ("Triggered by " + $actor), - deployer: {name: $actor}, - customData: {branch: $branch, runId: $run_id, runUrl: $run_url, trigger: $trigger} - }')" - - - name: Notify Cortex workflow callback - if: always() && inputs.cortex_callback_url != '' - run: | - if [ "$NEEDS_BUILD_RESULT" = "success" ]; then - CORTEX_STATUS="SUCCESS" - elif [ "$NEEDS_BUILD_RESULT" = "cancelled" ]; then - CORTEX_STATUS="CANCELLED" - else - CORTEX_STATUS="FAILURE" - fi - - curl -s -f -X POST "$CORTEX_CALLBACK_URL" \ - -H "Authorization: Bearer $CORTEX_API_KEY" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg status "$CORTEX_STATUS" \ - --arg sha '$GITHUB_SHA' \ - --arg run_id '$GITHUB_RUN_ID' \ - --arg run_url '$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID' \ - '{ - status: $status, - message: "", - output: {sha: $sha, run_id: $run_id, run_url: $run_url} - }')" - ``` - -actions: -- name: Branch - slug: branch - schema: - branches: - - name: UI invocation - slug: ui-invocation - outgoingAction: inputs - expression: "variables[\"github-owner\"] == \"\" || variables[\"repo-name\"] == \"\"" - type: CONDITIONAL - - name: API invocation - slug: api-invocation - outgoingAction: pass-through - expression: "variables[\"github-owner\"] != \"\" && variables[\"repo-name\"] != \"\"" - type: CONDITIONAL - joiningAction: merge - type: CONDITIONAL_BRANCH - outgoingActions: - - inputs - - pass-through - isRootAction: true -- name: Inputs - slug: inputs - schema: - inputs: - - name: GitHub Owner - description: GitHub organization or username that owns the repository. - key: github-owner - required: true - defaultValue: null - placeholder: e.g. my-org - validationRegex: null - type: INPUT_FIELD - - name: Repository Name - description: Name of the GitHub repository containing the Cortex deploy workflow. - key: repo-name - required: true - defaultValue: null - placeholder: e.g. cortex-deploy-demo - validationRegex: null - type: INPUT_FIELD - inputOverrides: - - inputKey: github-owner - outputVariable: variables.github-owner - editable: true - type: VALUE - - inputKey: repo-name - outputVariable: variables.repo-name - editable: true - type: VALUE - type: USER_INPUT - outgoingActions: - - set-variables - isRootAction: false -- name: Set variables - slug: set-variables - schema: - variables: - - slug: github-owner - source: - path: actions.inputs.outputs.github-owner - type: REFERENCE - - slug: repo-name - source: - path: actions.inputs.outputs.repo-name - type: REFERENCE - type: SET_VARIABLES - outgoingActions: - - merge - isRootAction: false -- name: Pass through - slug: pass-through - schema: - expression: "." - type: JQ - outgoingActions: - - merge - isRootAction: false -- name: Merge branches - slug: merge - schema: - expression: "." - type: JQ - outgoingActions: - - trigger-deploy - isRootAction: false -- name: Trigger GitHub Actions Deploy - slug: trigger-deploy - schema: - type: HTTP_REQUEST_ASYNC - httpMethod: POST - url: "/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" - integration: GitHub - integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" - headers: {} - payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' - timeoutInSeconds: 120 - outgoingActions: [] - isRootAction: false diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 6ddf467..3f6c1fc 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -23,8 +23,7 @@ GITHUB_API = "https://api.github.com" TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" -WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "trigger-github-deploy.yaml" -ENTITY_WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "deploy-from-entity.yaml" +WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "github-actions-deploy.yaml" def _hyperlink(url: str, text: str = None) -> str: @@ -180,8 +179,7 @@ def steps(self) -> list[tuple[str, callable]]: ("Linking GitHub repository to entity", self._link_github_repo), ] if self._answers.get("github_integration_alias"): - steps.append(("Creating Cortex trigger workflow", self._import_cortex_workflow)) - steps.append(("Creating Cortex entity deploy workflow", self._import_entity_workflow)) + steps.append(("Creating Cortex deploy workflow", self._import_cortex_workflow)) return steps def post_steps(self) -> None: @@ -193,20 +191,19 @@ def post_steps(self) -> None: cortex_url = f"{app_url}/admin/resources?tag=github-actions-demo" gh_url = f"https://github.com/{owner}/{repo}" - workflow_tag = "github-actions-trigger-deploy" - entity_workflow_tag = "github-actions-deploy-entity" + workflow_tag = "github-actions-deploy" entity_url = f"{app_url}/admin/resources?tag=github-actions-demo" workflows_url = f"{app_url}/admin/workflows?activeTab=runs" if self._answers.get("github_integration_alias"): print(f"\nTo trigger a deploy manually later:") - print(f" CLI: cortex workflows runs create -t {entity_workflow_tag} --entity github-actions-demo") + print(f" CLI: cortex workflows runs create -t {workflow_tag} --entity github-actions-demo") print(f" UI: {_hyperlink(entity_url, 'Open github-actions-demo')} → Workflows tab → Deploy from Entity → Run") if self.confirm("\nTrigger a workflow run now?", default=True): if self._answers.get("github_integration_alias"): # Use Cortex async workflow — waits for GitHub Actions callback - print(f" Running: POST /api/v1/workflows/{entity_workflow_tag}/runs") + print(f" Running: POST /api/v1/workflows/{workflow_tag}/runs") try: result = self._trigger_via_cortex_workflow() status = result.get("status", "").upper() @@ -408,34 +405,6 @@ def _import_cortex_workflow(self) -> str: yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( "PLACEHOLDER_INTEGRATION_ALIAS", alias - ) - - resp = requests.post( - f"{base_url}/api/v1/workflows", - data=yaml_content.encode("utf-8"), - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/yaml", - }, - timeout=15, - ) - if resp.status_code not in (200, 201): - raise RuntimeError( - f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" - ) - action = "Created" if resp.status_code == 201 else "Updated" - return f"{action} workflow 'github-actions-trigger-deploy': {_hyperlink(workflows_url, 'View workflows')}" - - def _import_entity_workflow(self) -> str: - """Import the entity-scoped deploy workflow.""" - base_url = self._answers["cortex_base_url"].rstrip("/") - api_key = self._answers["cortex_api_key"] - alias = self._answers["github_integration_alias"] - app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url - workflows_url = f"{app_url}/admin/workflows?activeTab=runs" - - yaml_content = ENTITY_WORKFLOW_TEMPLATE_PATH.read_text().replace( - "PLACEHOLDER_INTEGRATION_ALIAS", alias ).replace( "https://api.getcortexapp.com", base_url ) @@ -451,10 +420,10 @@ def _import_entity_workflow(self) -> str: ) if resp.status_code not in (200, 201): raise RuntimeError( - f"Failed to import entity workflow: {resp.status_code} {resp.text}" + f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" ) action = "Created" if resp.status_code == 201 else "Updated" - return f"{action} workflow 'github-actions-deploy-entity': {_hyperlink(workflows_url, 'View workflows')}" + return f"{action} workflow 'github-actions-deploy': {_hyperlink(workflows_url, 'View workflows')}" def _trigger_via_cortex_workflow(self) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" @@ -466,10 +435,22 @@ def _trigger_via_cortex_workflow(self) -> dict: "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } - workflow_tag = "github-actions-deploy-entity" + workflow_tag = "github-actions-deploy" + + # Entity-scoped runs require entityId (not entityTag) — look it up first + entity_resp = requests.get( + f"{base_url}/api/v1/catalog/github-actions-demo", + headers=cortex_headers, + timeout=10, + ) + if entity_resp.status_code != 200: + raise RuntimeError(f"Failed to fetch entity: {entity_resp.status_code} {entity_resp.text}") + entity_id = entity_resp.json().get("id") + if not entity_id: + raise RuntimeError("Entity 'github-actions-demo' has no id field in catalog response") body = { - "scope": {"type": "ENTITY", "entityTag": "github-actions-demo"}, + "scope": {"type": "ENTITY", "entityId": entity_id}, } resp = requests.post( f"{base_url}/api/v1/workflows/{workflow_tag}/runs", From ef2c2bfd8af39d038fcc5e08653ed4f4ccda3324 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:47:52 -0700 Subject: [PATCH 57/83] chore: fix CLI command and hyperlink text in post-steps output --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 3f6c1fc..b67b024 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -197,8 +197,8 @@ def post_steps(self) -> None: if self._answers.get("github_integration_alias"): print(f"\nTo trigger a deploy manually later:") - print(f" CLI: cortex workflows runs create -t {workflow_tag} --entity github-actions-demo") - print(f" UI: {_hyperlink(entity_url, 'Open github-actions-demo')} → Workflows tab → Deploy from Entity → Run") + print(f" CLI: cortex workflows run -t {workflow_tag} --scope ENTITY --entity github-actions-demo") + print(f" UI: {_hyperlink(entity_url, 'github-actions-demo')} → Workflows tab → Deploy from Entity → Run") if self.confirm("\nTrigger a workflow run now?", default=True): if self._answers.get("github_integration_alias"): From d3b5ac400b1fea0221854638fe17736d5c536f72 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:48:52 -0700 Subject: [PATCH 58/83] chore: rename workflow to 'Solution: Add Cortex Deploy from GitHub Actions' --- .../github-actions-deploy/_templates/github-actions-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index 3757585..cd0c561 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -1,4 +1,4 @@ -name: Deploy from Entity +name: "Solution: Add Cortex Deploy from GitHub Actions" tag: github-actions-deploy description: | Triggers the Cortex deploy workflow on GitHub Actions using the GitHub From c6ca7b10150bb0d239f5e4a6f685925e99401140 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:53:23 -0700 Subject: [PATCH 59/83] docs: add ASCII flow diagram and clean up README --- .../solutions/github-actions-deploy/README.md | 78 +++++++++++++------ 1 file changed, 56 insertions(+), 22 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/README.md b/cortexapps_cli/solutions/github-actions-deploy/README.md index 9ab7e12..1cb25ff 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/README.md +++ b/cortexapps_cli/solutions/github-actions-deploy/README.md @@ -3,14 +3,62 @@ name: GitHub Actions Deploy Tracking description: Track deployments from GitHub Actions in Cortex, with a deploy health scorecard measuring delivery cadence. --- +# GitHub Actions Deploy Tracking + +Trigger deploys from Cortex, track them as they run in GitHub Actions, and surface deploy health back in your service catalog. + +``` + ┌─────────────────────────────────┐ + │ Cortex Catalog │ + │ │ + │ github-actions-demo (service) │ + │ ├── x-cortex-git.github │ + │ │ repository: owner/repo │ + │ └── Scorecard: Deploy Health │ + │ Bronze / Silver / Gold │ + └──────────────┬──────────────────┘ + │ + │ Run workflow from entity page + │ (or: cortex workflows run -t + │ github-actions-deploy + │ --scope ENTITY --entity ) + ▼ + ┌─────────────────────────────────┐ + │ Cortex Workflow │ + │ Solution: Add Cortex Deploy │ + │ from GitHub Actions │ + │ │ + │ 1. Read linked repo from │ + │ entity catalog config │ + │ 2. POST workflow_dispatch │ + │ to GitHub Actions │ + │ 3. Wait for callback │ + └──────────────┬──────────────────┘ + │ POST /dispatches (GitHub integration) + ▼ + ┌─────────────────────────────────┐ + │ GitHub Actions │ + │ cortex-deploy.yml │ + │ │ + │ job: build │ + │ └── run your deploy steps │ + │ │ + │ job: notify-cortex │ + │ ├── POST /deploys │◄── registers deploy event + │ │ (entity: github-actions- │ on the Cortex entity + │ │ demo) │ + │ └── POST callbackUrl ───────┼──► Cortex marks workflow + │ status: SUCCESS/FAILURE │ run complete + └─────────────────────────────────┘ +``` + ## What's Included - **Entity:** `github-actions-demo` service — a sample entity to receive deploy events - **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency -- **GitHub Actions workflow:** A two-job workflow (build → deploy notification) to seed into a GitHub repo +- **GitHub Actions workflow:** `cortex-deploy.yml` — a two-job workflow (build → notify) seeded into your GitHub repo +- **Cortex workflow:** `github-actions-deploy` — reads the linked repo from the entity, triggers the GitHub Actions deploy, and waits for the result - **Setup script:** Interactive wizard that creates and seeds a GitHub repo end-to-end -- **Cortex workflow — Deploy from Entity** _(recommended)_: triggers a GitHub Actions deploy directly from any entity that has a linked GitHub repository — no manual input required -- **Cortex workflow — Trigger GitHub Actions Deploy**: triggers a deploy by supplying the GitHub owner and repo name explicitly; handles both UI and API invocation via a branch + variables pattern ## Quick Start @@ -26,27 +74,13 @@ description: Track deployments from GitHub Actions in Cortex, with a deploy heal cortex solutions post-install -s github-actions-deploy ``` -## Cortex Workflows - -Two Cortex workflows are included. Both trigger the same GitHub Actions deploy and wait for a callback, but differ in how the target repository is resolved. - -### Deploy from Entity _(recommended)_ - -Reads the GitHub repository directly from the entity's catalog configuration — no user input needed. Run it from any entity that has a `x-cortex-git.github.repository` set. - -Use this workflow for day-to-day deploys. It will likely replace the trigger workflow below once entity-linked repos are the standard pattern. - -### Trigger GitHub Actions Deploy - -Prompts for a GitHub owner and repository name when triggered from the UI, or accepts them as variables when triggered via API. This workflow is a good reference example of: - -- **Variables + branch pattern**: a `CONDITIONAL_BRANCH` routes UI invocations through a `USER_INPUT` step to collect the owner and repo, while API invocations skip it entirely and pass through to the same merge point -- **SET_VARIABLES**: copies `USER_INPUT` outputs into workflow variables so they're available to later steps regardless of which path was taken - ## How It Works -The included GitHub Actions workflow fires a deploy event to Cortex after every successful build. -The `notify-cortex` job only runs if the `build` job succeeds, demonstrating conditional deploy tracking. +The Cortex workflow reads `x-cortex-git.github.repository` from the entity's catalog config to determine which GitHub repo to deploy. It triggers `cortex-deploy.yml` via `workflow_dispatch` and waits asynchronously for a callback. + +GitHub Actions runs the build, then notifies Cortex twice on completion: +- **Deploy registration** (`POST /api/v1/catalog/{tag}/deploys`) — records the deploy event on the entity, feeding the Deploy Health scorecard +- **Workflow callback** — signals the Cortex workflow run as SUCCESS or FAILURE ## Customizing for Production From d0714cfcf821795f4054d8525ae540738fe3340b Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 09:58:14 -0700 Subject: [PATCH 60/83] chore: fix workflow name in post-steps UI instructions --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index b67b024..eafcc49 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -198,7 +198,7 @@ def post_steps(self) -> None: if self._answers.get("github_integration_alias"): print(f"\nTo trigger a deploy manually later:") print(f" CLI: cortex workflows run -t {workflow_tag} --scope ENTITY --entity github-actions-demo") - print(f" UI: {_hyperlink(entity_url, 'github-actions-demo')} → Workflows tab → Deploy from Entity → Run") + print(f" UI: {_hyperlink(entity_url, 'github-actions-demo')} → Workflows tab → Solution: Add Cortex Deploy from GitHub Actions → Run") if self.confirm("\nTrigger a workflow run now?", default=True): if self._answers.get("github_integration_alias"): From 3c61d245b5eb96c1f46b386a900c4d7bebb7189a Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 10:17:54 -0700 Subject: [PATCH 61/83] chore: remove hardcoded CQL blurb from next steps; add After Installing to README --- cortexapps_cli/commands/solutions.py | 11 ----------- .../solutions/github-actions-deploy/README.md | 6 ++++++ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 794a66f..6c89b20 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -605,17 +605,6 @@ def _show_next_steps(readme: str) -> None: section = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1 (\2)', section) console.print() console.print(Markdown(section)) - console.print() - console.print( - "[magenta]Planned for Q4 2027:[/magenta] CQL metadata traversal will enable scorecard rules " - "across relationship chains — for example, a Vulnerability Scorecard checking that no " - "deployed service-version has open Snyk issues:" - ) - console.print( - " [dim]entity.destinations(relationshipType = \"environments\", depth = 3)\n" - " .filter((d) => d.type == \"service-version\")\n" - " .all((sv) => sv.snyk.issues == 0)[/dim]" - ) def _post_install_menu( diff --git a/cortexapps_cli/solutions/github-actions-deploy/README.md b/cortexapps_cli/solutions/github-actions-deploy/README.md index 1cb25ff..0b58953 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/README.md +++ b/cortexapps_cli/solutions/github-actions-deploy/README.md @@ -82,6 +82,12 @@ GitHub Actions runs the build, then notifies Cortex twice on completion: - **Deploy registration** (`POST /api/v1/catalog/{tag}/deploys`) — records the deploy event on the entity, feeding the Deploy Health scorecard - **Workflow callback** — signals the Cortex workflow run as SUCCESS or FAILURE +## After Installing + +If you ran the post-install setup, you're already done — it created the GitHub repo, seeded the workflow, set secrets, linked the entity, and triggered a test deploy. + +To roll the pattern out to your own services, add `cortex-deploy.yml` to any GitHub repo and link that repo to its Cortex entity. The same **Solution: Add Cortex Deploy from GitHub Actions** workflow will work across all of them — it reads the linked repo from the entity automatically. + ## Customizing for Production - Point the workflow at your real entity by replacing `github-actions-demo` with your service tag From 862852b58aaaac3b70b62367ced840ef99d69116 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 10:24:35 -0700 Subject: [PATCH 62/83] feat: extract local GitHub Actions for callback and deploy registration Add cortex-async-callback and cortex-register-deploy as local composite actions seeded into .github/actions/. cortex-deploy.yml now uses both. setup.py seeds all three files via a generic _seed_file helper. --- .../_templates/cortex-async-callback.yml | 39 ++++++++++ .../_templates/cortex-deploy.yml | 73 +++++-------------- .../_templates/cortex-register-deploy.yml | 51 +++++++++++++ .../solutions/github-actions-deploy/setup.py | 32 ++++++-- 4 files changed, 133 insertions(+), 62 deletions(-) create mode 100644 cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-async-callback.yml create mode 100644 cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-register-deploy.yml diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-async-callback.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-async-callback.yml new file mode 100644 index 0000000..ab9aa97 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-async-callback.yml @@ -0,0 +1,39 @@ +name: Cortex Async Callback +description: Post a completion status back to a Cortex async workflow callback URL. +inputs: + cortex_api_key: + description: Cortex API key + required: true + cortex_callback_url: + description: Cortex async workflow callback URL + required: true + status: + description: Completion status — SUCCESS, FAILURE, or CANCELLED + required: true + message: + description: Optional status message shown in the Cortex workflow run + required: false + default: '' + sha: + description: Git commit SHA to include in callback output + required: false + default: '' + run_url: + description: GitHub Actions run URL to include in callback output + required: false + default: '' +runs: + using: composite + steps: + - name: Post callback + shell: bash + run: | + curl -s -f -X POST "${{ inputs.cortex_callback_url }}" \ + -H "Authorization: Bearer ${{ inputs.cortex_api_key }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg status "${{ inputs.status }}" \ + --arg message "${{ inputs.message }}" \ + --arg sha "${{ inputs.sha }}" \ + --arg run_url "${{ inputs.run_url }}" \ + '{status: $status, message: $message, output: {sha: $sha, run_url: $run_url}}')" diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index 6d758a7..bb0dd89 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -21,66 +21,31 @@ jobs: for i in 1 2 3; do sleep 5 echo "Deployment progress: step $i/3" - if [ -n "${{ inputs.cortex_callback_url }}" ]; then - curl -s -X POST "${{ inputs.cortex_callback_url }}" \ - -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg msg "Deployment progress: step $i/3" \ - '{"status": "IN_PROGRESS", "message": $msg, "output": {}}')" || true - fi done echo "Build complete!" notify-cortex: needs: build runs-on: ubuntu-latest + if: always() steps: - - name: Register deploy in Cortex - continue-on-error: true - run: | - curl -s -f -X POST \ - "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ - -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg sha '${{ github.sha }}' \ - --arg actor '${{ github.actor }}' \ - --arg branch '${{ github.ref_name }}' \ - --arg run_id '${{ github.run_id }}' \ - --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ - --arg trigger '${{ github.event_name }}' \ - --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '{ - sha: $sha, - timestamp: $timestamp, - environment: "production", - type: "DEPLOY", - title: ("Triggered by " + $actor), - deployer: {name: $actor}, - customData: {branch: $branch, runId: $run_id, runUrl: $run_url, trigger: $trigger} - }')" + - name: Checkout (for local actions) + uses: actions/checkout@v4 - - name: Notify Cortex workflow callback - if: ${{ always() && inputs.cortex_callback_url != '' }} - run: | - if [ "${{ needs.build.result }}" = "success" ]; then - CORTEX_STATUS="SUCCESS" - elif [ "${{ needs.build.result }}" = "cancelled" ]; then - CORTEX_STATUS="CANCELLED" - else - CORTEX_STATUS="FAILURE" - fi + - name: Register deploy in Cortex + if: needs.build.result == 'success' + uses: ./.github/actions/cortex-register-deploy + with: + cortex_api_key: ${{ secrets.CORTEX_API_KEY }} + cortex_base_url: ${{ secrets.CORTEX_BASE_URL }} + entity_tag: github-actions-demo - curl -s -f -X POST "${{ inputs.cortex_callback_url }}" \ - -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg status "$CORTEX_STATUS" \ - --arg sha '${{ github.sha }}' \ - --arg run_id '${{ github.run_id }}' \ - --arg run_url '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' \ - '{ - status: $status, - message: "", - output: {sha: $sha, run_id: $run_id, run_url: $run_url} - }')" + - name: Cortex async callback + if: inputs.cortex_callback_url != '' + uses: ./.github/actions/cortex-async-callback + with: + cortex_api_key: ${{ secrets.CORTEX_API_KEY }} + cortex_callback_url: ${{ inputs.cortex_callback_url }} + status: ${{ needs.build.result == 'success' && 'SUCCESS' || needs.build.result == 'cancelled' && 'CANCELLED' || 'FAILURE' }} + sha: ${{ github.sha }} + run_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-register-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-register-deploy.yml new file mode 100644 index 0000000..08c0e14 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-register-deploy.yml @@ -0,0 +1,51 @@ +name: Cortex Register Deploy +description: Register a deploy event on a Cortex entity. +inputs: + cortex_api_key: + description: Cortex API key + required: true + cortex_base_url: + description: Cortex base URL + required: false + default: https://api.getcortexapp.com + entity_tag: + description: Cortex entity tag to register the deploy against + required: true + environment: + description: Deployment environment + required: false + default: production + sha: + description: Git commit SHA (defaults to github.sha) + required: false + default: '' +runs: + using: composite + steps: + - name: Register deploy + shell: bash + run: | + SHA="${{ inputs.sha }}" + SHA="${SHA:-${{ github.sha }}}" + curl -s -f -X POST \ + "${{ inputs.cortex_base_url }}/api/v1/catalog/${{ inputs.entity_tag }}/deploys" \ + -H "Authorization: Bearer ${{ inputs.cortex_api_key }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg sha "$SHA" \ + --arg actor "${{ github.actor }}" \ + --arg branch "${{ github.ref_name }}" \ + --arg run_id "${{ github.run_id }}" \ + --arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --arg trigger "${{ github.event_name }}" \ + --arg env "${{ inputs.environment }}" \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{ + sha: $sha, + timestamp: $timestamp, + environment: $env, + type: "DEPLOY", + title: ("Triggered by " + $actor), + deployer: {name: $actor}, + customData: {branch: $branch, runId: $run_id, runUrl: $run_url, trigger: $trigger} + }')" diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index eafcc49..5c50930 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -25,6 +25,18 @@ TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "github-actions-deploy.yaml" +# Local GitHub Actions seeded into the customer repo +_GH_ACTION_TEMPLATES = [ + ( + Path(__file__).parent / "_templates" / "cortex-async-callback.yml", + ".github/actions/cortex-async-callback/action.yml", + ), + ( + Path(__file__).parent / "_templates" / "cortex-register-deploy.yml", + ".github/actions/cortex-register-deploy/action.yml", + ), +] + def _hyperlink(url: str, text: str = None) -> str: """Return an OSC 8 hyperlink for terminals that support it (iTerm2, etc.).""" @@ -174,6 +186,8 @@ def steps(self) -> list[tuple[str, callable]]: steps = [ ("Creating GitHub repository", self._create_repo), ("Seeding Cortex deploy workflow", self._seed_workflow), + ("Seeding cortex-async-callback action", lambda: self._seed_file(*_GH_ACTION_TEMPLATES[0])), + ("Seeding cortex-register-deploy action", lambda: self._seed_file(*_GH_ACTION_TEMPLATES[1])), ("Setting CORTEX_API_KEY secret", lambda: self._set_secret("CORTEX_API_KEY", self._answers["cortex_api_key"])), ("Setting CORTEX_BASE_URL secret", lambda: self._set_secret("CORTEX_BASE_URL", self._answers["cortex_base_url"])), ("Linking GitHub repository to entity", self._link_github_repo), @@ -267,20 +281,19 @@ def _create_repo(self) -> str: raise RuntimeError(f"Failed to create repo: {resp.status_code} {resp.text}") return f"Created: {_hyperlink(gh_url)}" - def _seed_workflow(self) -> str: + def _seed_file(self, template_path: Path, dest_path: str) -> str: owner = self._answers["github_owner"] repo = self._answers["repo_name"] - path = ".github/workflows/cortex-deploy.yml" - content = TEMPLATE_PATH.read_text() + content = template_path.read_text() content_b64 = base64.b64encode(content.encode()).decode() - file_url = f"https://github.com/{owner}/{repo}/blob/main/{path}" + file_url = f"https://github.com/{owner}/{repo}/blob/main/{dest_path}" check = requests.get( - f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{dest_path}", headers=self._gh_headers(), ) - payload = {"message": "Add Cortex deploy notification workflow", "content": content_b64} + payload = {"message": f"Add {dest_path}", "content": content_b64} if check.status_code == 200: existing = check.json() @@ -293,14 +306,17 @@ def _seed_workflow(self) -> str: action = "Added" resp = requests.put( - f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{dest_path}", headers=self._gh_headers(), json=payload, ) if resp.status_code not in (200, 201): - raise RuntimeError(f"Failed to seed workflow: {resp.status_code} {resp.text}") + raise RuntimeError(f"Failed to seed {dest_path}: {resp.status_code} {resp.text}") return f"{action}: {_hyperlink(file_url)}" + def _seed_workflow(self) -> str: + return self._seed_file(TEMPLATE_PATH, ".github/workflows/cortex-deploy.yml") + def _set_secret(self, secret_name: str, secret_value: str) -> str: owner = self._answers["github_owner"] repo = self._answers["repo_name"] From caa6f1b90843b4de12fd6ecccaf976e46037a8b0 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 10:40:05 -0700 Subject: [PATCH 63/83] chore: rewrite runResponseTemplate with deploy details and educational walkthrough --- .../_templates/github-actions-deploy.yaml | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index cd0c561..3dab554 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -18,11 +18,53 @@ variables: defaultValue: "" description: GitHub repository name (derived from entity git config). runResponseTemplate: | - # GitHub Actions Deploy + # GitHub Actions Deploy — Complete - Deploy complete for **{{variables.github-owner}}/{{variables.repo-name}}**. + **Repo:** {{variables.github-owner}}/{{variables.repo-name}} + **SHA:** `{{actions.trigger-deploy.outputs.result.output.sha}}` + **Run:** [View on GitHub]({{actions.trigger-deploy.outputs.result.output.run_url}}) - [View GitHub Actions runs](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/actions) + --- + + ## How this workflow works + + This Cortex workflow triggered a deploy in GitHub Actions and waited for it to finish. + Here's what happened end-to-end: + + **1. Cortex read the linked repo** + The workflow fetched this entity's catalog config and extracted `x-cortex-git.github.repository` + to determine which GitHub repo to deploy — no manual input required. + + **2. Cortex triggered GitHub Actions** + It called the GitHub API to dispatch `cortex-deploy.yml` via `workflow_dispatch`, passing a + one-time callback URL as an input (`cortex_callback_url`). + + **3. GitHub Actions ran the deploy** + `cortex-deploy.yml` runs two jobs: + - **build** — your deploy steps (replace the `sleep` placeholder with your real build/deploy) + - **notify-cortex** — runs after `build`, always, even on failure + + The `notify-cortex` job uses two local composite actions bundled in the repo: + + - **`.github/actions/cortex-register-deploy`** — POSTs a deploy event to the Cortex API, + recording the SHA, actor, branch, and run URL against this entity. This feeds the + Deploy Health scorecard. + + - **`.github/actions/cortex-async-callback`** — POSTs the final status (SUCCESS/FAILURE/CANCELLED) + back to the callback URL, which is how Cortex knew this workflow run was done. + + **4. Cortex received the callback** + When GitHub Actions posted to the callback URL, Cortex marked this workflow run complete + and surfaced the result here. + + --- + + ## Adapting this to your own repos + + 1. Copy `.github/actions/` into your repo + 2. Add a `notify-cortex` job to your existing workflow that calls both actions + 3. Set `CORTEX_API_KEY` and `CORTEX_BASE_URL` as repository secrets + 4. Link the repo to your Cortex entity via `x-cortex-git.github.repository` actions: - name: Get entity details slug: get-entity-details From 18fe7cbe4d64f530be0729ef392570c7cea3b749 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 10:43:15 -0700 Subject: [PATCH 64/83] chore: show saved config path before setup prompt; fix --no-prompt skipping integration select --- cortexapps_cli/commands/solutions.py | 3 +++ cortexapps_cli/solutions/github-actions-deploy/setup.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index 6c89b20..ad3c3e9 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -707,6 +707,9 @@ def _do_import() -> None: # Post-install setup hook — runs before the informational menu if not no_prompt and not skip_post_install_setup and _has_post_install(solution, solutions_dir): + state_file = Path.home() / ".cortex" / "solutions" / f"{solution}.json" + if state_file.exists(): + typer.echo(f"\nRetrieving previous responses from: {state_file}") desc = _get_setup_description(solution, solutions_dir) typer.echo(f"\n{desc}") if typer.confirm("Run setup now?", default=True): diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 5c50930..b01cd64 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -106,6 +106,9 @@ def _select_github_integration(self, integrations: list) -> str: print(f" {marker}{i + 1}. {cfg['alias']} [{type_label}]") print(" (* = default)") + if self._no_prompt: + return integrations[default_idx]["alias"] + while True: choice = input(f"\nSelect integration [{default_idx + 1}]: ").strip() if not choice: From 5ae71bf83f9c2c119c430b4433c17fd3b709f9cd Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 10:50:32 -0700 Subject: [PATCH 65/83] chore: fix runResponseTemplate newlines, add deploys link, app-url variable --- .../_templates/github-actions-deploy.yaml | 38 +++++++++++-------- .../solutions/github-actions-deploy/setup.py | 2 + 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index 3dab554..ced3602 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -17,53 +17,61 @@ variables: type: STRING defaultValue: "" description: GitHub repository name (derived from entity git config). +- slug: app-url + type: STRING + defaultValue: "PLACEHOLDER_APP_URL" + description: Cortex app URL (set during installation). runResponseTemplate: | # GitHub Actions Deploy — Complete **Repo:** {{variables.github-owner}}/{{variables.repo-name}} + **SHA:** `{{actions.trigger-deploy.outputs.result.output.sha}}` + **Run:** [View on GitHub]({{actions.trigger-deploy.outputs.result.output.run_url}}) + **Deploys:** [View deploy history]({{variables.app-url}}/admin/service/{{context.entity.cid}}/deploys) + --- ## How this workflow works - This Cortex workflow triggered a deploy in GitHub Actions and waited for it to finish. - Here's what happened end-to-end: + This Cortex workflow triggered a deploy in GitHub Actions and waited for it to finish. Here's what happened end-to-end: **1. Cortex read the linked repo** - The workflow fetched this entity's catalog config and extracted `x-cortex-git.github.repository` - to determine which GitHub repo to deploy — no manual input required. + + The workflow fetched this entity's catalog config and extracted `x-cortex-git.github.repository` to determine which GitHub repo to deploy — no manual input required. **2. Cortex triggered GitHub Actions** - It called the GitHub API to dispatch `cortex-deploy.yml` via `workflow_dispatch`, passing a - one-time callback URL as an input (`cortex_callback_url`). + + It called the GitHub API to dispatch `cortex-deploy.yml` via `workflow_dispatch`, passing a one-time callback URL as an input (`cortex_callback_url`). **3. GitHub Actions ran the deploy** + `cortex-deploy.yml` runs two jobs: + - **build** — your deploy steps (replace the `sleep` placeholder with your real build/deploy) - - **notify-cortex** — runs after `build`, always, even on failure - The `notify-cortex` job uses two local composite actions bundled in the repo: + - **notify-cortex** — runs after `build`, always, even on failure. It uses two local composite actions bundled in the repo: - - **`.github/actions/cortex-register-deploy`** — POSTs a deploy event to the Cortex API, - recording the SHA, actor, branch, and run URL against this entity. This feeds the - Deploy Health scorecard. + - **`.github/actions/cortex-register-deploy`** — POSTs a deploy event to the Cortex API, recording the SHA, actor, branch, and run URL against this entity. This feeds the Deploy Health scorecard. - - **`.github/actions/cortex-async-callback`** — POSTs the final status (SUCCESS/FAILURE/CANCELLED) - back to the callback URL, which is how Cortex knew this workflow run was done. + - **`.github/actions/cortex-async-callback`** — POSTs the final status (SUCCESS/FAILURE/CANCELLED) back to the callback URL, which is how Cortex knew this workflow run was done. **4. Cortex received the callback** - When GitHub Actions posted to the callback URL, Cortex marked this workflow run complete - and surfaced the result here. + + When GitHub Actions posted to the callback URL, Cortex marked this workflow run complete and surfaced the result here. --- ## Adapting this to your own repos 1. Copy `.github/actions/` into your repo + 2. Add a `notify-cortex` job to your existing workflow that calls both actions + 3. Set `CORTEX_API_KEY` and `CORTEX_BASE_URL` as repository secrets + 4. Link the repo to your Cortex entity via `x-cortex-git.github.repository` actions: - name: Get entity details diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index b01cd64..92c74d7 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -424,6 +424,8 @@ def _import_cortex_workflow(self) -> str: yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( "PLACEHOLDER_INTEGRATION_ALIAS", alias + ).replace( + "PLACEHOLDER_APP_URL", app_url ).replace( "https://api.getcortexapp.com", base_url ) From 827457e772946cbea8a17152d121db3ff5fca2f5 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 10:54:23 -0700 Subject: [PATCH 66/83] chore: link directly to workflow run after deploy (not just runs list) Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 92c74d7..3cdcd93 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -225,19 +225,23 @@ def post_steps(self) -> None: result = self._trigger_via_cortex_workflow() status = result.get("status", "").upper() run_id = result.get("_run_id", "") + workflow_cid = result.get("_workflow_cid", "") + run_url = ( + f"{app_url}/admin/workflows/{workflow_cid}/runs/{run_id}" + if workflow_cid and run_id + else None + ) if status == "COMPLETED": gh_actions_url = f"https://github.com/{owner}/{repo}/actions" print(f" Deploy complete \u2713") - if run_id: - print(f" Run ID: {run_id}") - print(f" {_hyperlink(workflows_url, 'View workflow runs in Cortex')}") + if run_url: + print(f" {_hyperlink(run_url, 'View this workflow run')}") print(f" {_hyperlink(gh_actions_url, 'View GitHub Actions runs')}") self.mark_done("first_deploy") else: print(f" Workflow ended with status: {status}", file=sys.stderr) - if run_id: - print(f" Run ID: {run_id}", file=sys.stderr) - print(f" {_hyperlink(workflows_url, 'View workflow runs in Cortex')}", file=sys.stderr) + if run_url: + print(f" {_hyperlink(run_url, 'View this workflow run')}", file=sys.stderr) except Exception as e: print(f" Trigger failed: {e}", file=sys.stderr) print(f" Re-trigger via: cortex solutions post-install -s {self.solution_tag}", file=sys.stderr) From 5c463c62af118ac93158669d818d8e7365bfc505 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 10:57:39 -0700 Subject: [PATCH 67/83] chore: fix Justfile indentation syntax error in axon-configure recipe Co-Authored-By: Claude Sonnet 4.6 --- internal/Justfile | 155 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/internal/Justfile b/internal/Justfile index 1a5854e..75f2717 100644 --- a/internal/Justfile +++ b/internal/Justfile @@ -323,6 +323,161 @@ axon-setup: echo "Done. Running containers:" docker compose -f axon/compose.yaml ps +# Configure an axon relay integration end-to-end (prompts for credentials, configures Cortex, starts relay) +# Usage: just axon-configure jira +axon-configure integration: + #!/usr/bin/env bash + set -euo pipefail + + if [ ! -f .env ]; then + cp .env.example .env 2>/dev/null || touch .env + fi + + # Prompt helper: reads a value, optionally silent, with a default + prompt_var() { + local NAME="$1" LABEL="$2" HELP="$3" DEFAULT="${4:-}" SILENT="${5:-}" + source .env 2>/dev/null || true + local CURRENT="${!NAME:-}" + if [ -n "$CURRENT" ]; then + echo "$NAME already set, skipping." + return + fi + echo "" + echo "$NAME ($LABEL)" + [ -n "$HELP" ] && echo " $HELP" + [ -n "$DEFAULT" ] && echo " Press Enter to use default: $DEFAULT" + while true; do + printf ": " + if [ "${SILENT:-}" = "silent" ]; then + read -rs VALUE; echo "" + else + read -r VALUE + fi + VALUE="${VALUE:-$DEFAULT}" + if [ -n "$VALUE" ]; then + # Update existing line or append + if grep -q "^${NAME}=" .env 2>/dev/null; then + sed -i '' "s|^${NAME}=.*|${NAME}=\"${VALUE}\"|" .env + else + echo "${NAME}=\"${VALUE}\"" >> .env + fi + export "$NAME=$VALUE" + break + fi + echo " Value cannot be empty. Try again." + done + } + + INTEGRATION="{{integration}}" + + case "$INTEGRATION" in + jira) + echo "" + echo "========================================" + echo " Jira Axon Relay Setup" + echo "========================================" + echo "" + echo "This recipe will:" + echo " 1. Collect your Jira credentials and save them to .env" + echo " 2. Configure the Jira integration in your Cortex workspace via the API" + echo " 3. Start the Jira relay container" + echo "" + echo "--- Don't have a Jira account yet? ---" + echo "" + echo " Atlassian offers a free Jira Cloud tier (up to 10 users):" + echo " 1. Sign up at: https://www.atlassian.com/software/jira/free" + echo " 2. Choose a subdomain (e.g. 'acme' → acme.atlassian.net)" + echo " 3. Create a Jira Software project" + echo "" + echo " Then create an API token:" + echo " 1. Go to: https://id.atlassian.com/manage-profile/security/api-tokens" + echo " 2. Click 'Create API token', name it (e.g. 'cortex-axon')" + echo " 3. Copy the token value — it won't be shown again" + echo "" + printf "Press Enter when ready..." + read -r + echo "" + + ./scripts/ensure-env.sh \ + "CORTEX_API_KEY|Cortex API key|Get from Cortex Settings > API Keys." + source .env + + prompt_var "JIRA_SUBDOMAIN" \ + "Jira Cloud subdomain" \ + "The part before .atlassian.net in your Jira URL. Example: for https://acme.atlassian.net, enter: acme" + + prompt_var "JIRA_USERNAME" \ + "Jira email" \ + "Your Atlassian account email address." + + prompt_var "JIRA_TOKEN" \ + "Jira API token" \ + "From https://id.atlassian.com/manage-profile/security/api-tokens" \ + "" "silent" + + source .env + + # Derive JIRA_API from subdomain and save it + JIRA_API="https://${JIRA_SUBDOMAIN}.atlassian.net" + if grep -q "^JIRA_API=" .env 2>/dev/null; then + sed -i '' "s|^JIRA_API=.*|JIRA_API=\"${JIRA_API}\"|" .env + else + echo "JIRA_API=\"${JIRA_API}\"" >> .env + fi + export JIRA_API + + # Compute and save JIRA_AUTH_TOKEN (base64 of email:token, used by accept.json) + JIRA_AUTH_TOKEN=$(printf '%s:%s' "${JIRA_USERNAME}" "${JIRA_TOKEN}" | base64) + if grep -q "^JIRA_AUTH_TOKEN=" .env 2>/dev/null; then + sed -i '' "s|^JIRA_AUTH_TOKEN=.*|JIRA_AUTH_TOKEN=\"${JIRA_AUTH_TOKEN}\"|" .env + else + echo "JIRA_AUTH_TOKEN=\"${JIRA_AUTH_TOKEN}\"" >> .env + fi + + # Integration alias (must match -a flag in compose.yaml) + echo "" + echo "Integration alias in Cortex (default: jira-relay):" + echo " This must match the -a flag in compose.yaml. Change only if you have a reason." + printf ": " + read -r JIRA_ALIAS + JIRA_ALIAS="${JIRA_ALIAS:-jira-relay}" + + # Configure the Jira integration in Cortex + echo "" + echo "Configuring Jira integration in Cortex (alias: $JIRA_ALIAS)..." + JIRA_CONFIG=$(printf '{"type":"CLOUD_BASIC","alias":"%s","subdomain":"%s","email":"%s","apiToken":"%s","baseUrl":"ATLASSIAN","isDefault":true}' \ + "$JIRA_ALIAS" "$JIRA_SUBDOMAIN" "$JIRA_USERNAME" "$JIRA_TOKEN") + + if poetry run cortex integrations jira add -f- <<< "$JIRA_CONFIG"; then + echo "Cortex integration configured successfully." + else + echo "" + echo "Warning: Cortex API call failed (integration may already exist or credentials may be wrong)." + echo "You can configure it manually at: Settings > Integrations > Jira" + echo "" + printf "Continue and start the relay anyway? [y/N]: " + read -r CONTINUE + [[ "$CONTINUE" =~ ^[Yy]$ ]] || exit 1 + fi + + # Start the relay container + echo "" + echo "Starting Jira relay container..." + docker compose -f axon/compose.yaml up -d jira + echo "" + echo "Done!" + echo " Relay alias: $JIRA_ALIAS" + echo " Jira URL: $JIRA_API" + echo "" + echo "Run 'just axon-status' to check relay status." + ;; + *) + echo "Unknown integration: $INTEGRATION" + echo "Available: jira" + exit 1 + ;; + esac + # Show status of axon relay containers axon-status: docker compose -f axon/compose.yaml ps -a From 831dfe9d5899a1f8e66c2da352f4ed68292df975 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:00:55 -0700 Subject: [PATCH 68/83] chore: print workflow run link immediately after POST, before polling Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 3cdcd93..418dd4d 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -222,7 +222,10 @@ def post_steps(self) -> None: # Use Cortex async workflow — waits for GitHub Actions callback print(f" Running: POST /api/v1/workflows/{workflow_tag}/runs") try: - result = self._trigger_via_cortex_workflow() + def _on_run_started(run_url: str) -> None: + print(f" {_hyperlink(run_url, 'View this workflow run')}") + + result = self._trigger_via_cortex_workflow(on_run_started=_on_run_started) status = result.get("status", "").upper() run_id = result.get("_run_id", "") workflow_cid = result.get("_workflow_cid", "") @@ -450,7 +453,7 @@ def _import_cortex_workflow(self) -> str: action = "Created" if resp.status_code == 201 else "Updated" return f"{action} workflow 'github-actions-deploy': {_hyperlink(workflows_url, 'View workflows')}" - def _trigger_via_cortex_workflow(self) -> dict: + def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" import time @@ -491,6 +494,10 @@ def _trigger_via_cortex_workflow(self) -> dict: raise RuntimeError("No run ID returned from workflow start") workflow_cid = run_data.get("workflow", {}).get("cid", "") + if on_run_started and workflow_cid: + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + on_run_started(f"{app_url}/admin/workflows/{workflow_cid}/runs/{run_id}") + terminal = {"COMPLETED", "FAILED", "CANCELLED"} start = time.time() dots = 0 From 27f33b78c609f4e2ac2061cf79f8c0a4e6c5a263 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:01:44 -0700 Subject: [PATCH 69/83] chore: update Deploys link label in runResponseTemplate Co-Authored-By: Claude Sonnet 4.6 --- .../github-actions-deploy/_templates/github-actions-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index ced3602..4e26fb0 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -30,7 +30,7 @@ runResponseTemplate: | **Run:** [View on GitHub]({{actions.trigger-deploy.outputs.result.output.run_url}}) - **Deploys:** [View deploy history]({{variables.app-url}}/admin/service/{{context.entity.cid}}/deploys) + **Cortex Deploys:** [see deploys for {{context.entity.tag}}]({{variables.app-url}}/admin/service/{{context.entity.cid}}/deploys) --- From 765c2c773fa39c16ede2075cb1af1825696709a4 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:04:15 -0700 Subject: [PATCH 70/83] =?UTF-8?q?chore:=20fix=20SHA=20path=20in=20runRespo?= =?UTF-8?q?nseTemplate=20=E2=80=94=20result.sha=20not=20result.output.sha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/github-actions-deploy.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index 4e26fb0..7b2eb8e 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -26,9 +26,9 @@ runResponseTemplate: | **Repo:** {{variables.github-owner}}/{{variables.repo-name}} - **SHA:** `{{actions.trigger-deploy.outputs.result.output.sha}}` + **SHA:** `{{actions.trigger-deploy.outputs.result.sha}}` - **Run:** [View on GitHub]({{actions.trigger-deploy.outputs.result.output.run_url}}) + **Run:** [View on GitHub]({{actions.trigger-deploy.outputs.result.run_url}}) **Cortex Deploys:** [see deploys for {{context.entity.tag}}]({{variables.app-url}}/admin/service/{{context.entity.cid}}/deploys) From 851280abf7b161444af56de72530fbf36165c5b4 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:06:06 -0700 Subject: [PATCH 71/83] chore: add newline before POST line in workflow trigger output Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 418dd4d..548b84d 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -220,7 +220,7 @@ def post_steps(self) -> None: if self.confirm("\nTrigger a workflow run now?", default=True): if self._answers.get("github_integration_alias"): # Use Cortex async workflow — waits for GitHub Actions callback - print(f" Running: POST /api/v1/workflows/{workflow_tag}/runs") + print(f"\n Running: POST /api/v1/workflows/{workflow_tag}/runs") try: def _on_run_started(run_url: str) -> None: print(f" {_hyperlink(run_url, 'View this workflow run')}") From d164ac20955871127cfac7b0a36002839b88c868 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:06:48 -0700 Subject: [PATCH 72/83] =?UTF-8?q?chore:=20fix=20workflow=20run=20URL=20?= =?UTF-8?q?=E2=80=94=20use=20numeric=20id=20not=20cid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 548b84d..c594e72 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -492,7 +492,7 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: run_id = run_data.get("id") if not run_id: raise RuntimeError("No run ID returned from workflow start") - workflow_cid = run_data.get("workflow", {}).get("cid", "") + workflow_cid = run_data.get("workflow", {}).get("id", "") if on_run_started and workflow_cid: app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url From ad7e43283b2737498d18918e043d81cb852b08aa Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:08:29 -0700 Subject: [PATCH 73/83] chore: fetch workflow numeric id for run URL instead of relying on run response cid Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index c594e72..253fb21 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -228,10 +228,10 @@ def _on_run_started(run_url: str) -> None: result = self._trigger_via_cortex_workflow(on_run_started=_on_run_started) status = result.get("status", "").upper() run_id = result.get("_run_id", "") - workflow_cid = result.get("_workflow_cid", "") + workflow_numeric_id = result.get("_workflow_numeric_id", "") run_url = ( - f"{app_url}/admin/workflows/{workflow_cid}/runs/{run_id}" - if workflow_cid and run_id + f"{app_url}/admin/workflows/{workflow_numeric_id}/runs/{run_id}" + if workflow_numeric_id and run_id else None ) if status == "COMPLETED": @@ -465,7 +465,14 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: } workflow_tag = "github-actions-deploy" - # Entity-scoped runs require entityId (not entityTag) — look it up first + # Fetch workflow numeric id for the run URL, and entity numeric id for scope + wf_resp = requests.get( + f"{base_url}/api/v1/workflows/{workflow_tag}", + headers=cortex_headers, + timeout=10, + ) + workflow_numeric_id = wf_resp.json().get("id", "") if wf_resp.status_code == 200 else "" + entity_resp = requests.get( f"{base_url}/api/v1/catalog/github-actions-demo", headers=cortex_headers, @@ -492,11 +499,10 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: run_id = run_data.get("id") if not run_id: raise RuntimeError("No run ID returned from workflow start") - workflow_cid = run_data.get("workflow", {}).get("id", "") - if on_run_started and workflow_cid: + if on_run_started and workflow_numeric_id and run_id: app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url - on_run_started(f"{app_url}/admin/workflows/{workflow_cid}/runs/{run_id}") + on_run_started(f"{app_url}/admin/workflows/{workflow_numeric_id}/runs/{run_id}") terminal = {"COMPLETED", "FAILED", "CANCELLED"} start = time.time() @@ -515,7 +521,7 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: print() # newline after dots result = r.json() result["_run_id"] = run_id - result["_workflow_cid"] = workflow_cid + result["_workflow_numeric_id"] = workflow_numeric_id return result raise TimeoutError("Timed out waiting for workflow to complete (5 min)") From 1443f57a6f3b2fcf5288b6508792e130033cf57d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:09:30 -0700 Subject: [PATCH 74/83] chore: fix deploys URL to use action output cid; remove SHA from runResponseTemplate Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/github-actions-deploy.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index 7b2eb8e..2fd7628 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -26,11 +26,9 @@ runResponseTemplate: | **Repo:** {{variables.github-owner}}/{{variables.repo-name}} - **SHA:** `{{actions.trigger-deploy.outputs.result.sha}}` - **Run:** [View on GitHub]({{actions.trigger-deploy.outputs.result.run_url}}) - **Cortex Deploys:** [see deploys for {{context.entity.tag}}]({{variables.app-url}}/admin/service/{{context.entity.cid}}/deploys) + **Cortex Deploys:** [see deploys for {{context.entity.tag}}]({{variables.app-url}}/admin/service/{{actions.get-entity-details.outputs.body.cid}}/deploys) --- From b75f5a34b88c768aebbdf7633ed9eadc8fab54d6 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:09:57 -0700 Subject: [PATCH 75/83] chore: link .github/actions/ to seeded repo in runResponseTemplate adapt section Co-Authored-By: Claude Sonnet 4.6 --- .../github-actions-deploy/_templates/github-actions-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index 2fd7628..64b2ff9 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -64,7 +64,7 @@ runResponseTemplate: | ## Adapting this to your own repos - 1. Copy `.github/actions/` into your repo + 1. Copy [`.github/actions/`](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/tree/main/.github/actions) into your repo 2. Add a `notify-cortex` job to your existing workflow that calls both actions From 9d771dc8c100bb71df5576d6ef5a899d366992f6 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:13:51 -0700 Subject: [PATCH 76/83] chore: link directly to specific workflow after import using numeric id Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 253fb21..68fc188 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -451,7 +451,9 @@ def _import_cortex_workflow(self) -> str: f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" ) action = "Created" if resp.status_code == 201 else "Updated" - return f"{action} workflow 'github-actions-deploy': {_hyperlink(workflows_url, 'View workflows')}" + workflow_id = resp.json().get("id", "") + wf_url = f"{app_url}/admin/workflows/{workflow_id}" if workflow_id else workflows_url + return f"{action} workflow 'github-actions-deploy': {_hyperlink(wf_url, 'View workflow')}" def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" From 4a1b16a35782777110691419ddf6eb637817a1ee Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:15:33 -0700 Subject: [PATCH 77/83] =?UTF-8?q?chore:=20revert=20workflow=20run=20URL=20?= =?UTF-8?q?to=20runs=20list=20=E2=80=94=20numeric=20id=20not=20available?= =?UTF-8?q?=20in=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 25 ++++--------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 68fc188..5f2291d 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -228,12 +228,7 @@ def _on_run_started(run_url: str) -> None: result = self._trigger_via_cortex_workflow(on_run_started=_on_run_started) status = result.get("status", "").upper() run_id = result.get("_run_id", "") - workflow_numeric_id = result.get("_workflow_numeric_id", "") - run_url = ( - f"{app_url}/admin/workflows/{workflow_numeric_id}/runs/{run_id}" - if workflow_numeric_id and run_id - else None - ) + run_url = workflows_url if run_id else None if status == "COMPLETED": gh_actions_url = f"https://github.com/{owner}/{repo}/actions" print(f" Deploy complete \u2713") @@ -451,9 +446,7 @@ def _import_cortex_workflow(self) -> str: f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" ) action = "Created" if resp.status_code == 201 else "Updated" - workflow_id = resp.json().get("id", "") - wf_url = f"{app_url}/admin/workflows/{workflow_id}" if workflow_id else workflows_url - return f"{action} workflow 'github-actions-deploy': {_hyperlink(wf_url, 'View workflow')}" + return f"{action} workflow 'github-actions-deploy': {_hyperlink(workflows_url, 'View workflows')}" def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" @@ -467,14 +460,7 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: } workflow_tag = "github-actions-deploy" - # Fetch workflow numeric id for the run URL, and entity numeric id for scope - wf_resp = requests.get( - f"{base_url}/api/v1/workflows/{workflow_tag}", - headers=cortex_headers, - timeout=10, - ) - workflow_numeric_id = wf_resp.json().get("id", "") if wf_resp.status_code == 200 else "" - + # Entity-scoped runs require entityId (not entityTag) — look it up first entity_resp = requests.get( f"{base_url}/api/v1/catalog/github-actions-demo", headers=cortex_headers, @@ -502,9 +488,9 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: if not run_id: raise RuntimeError("No run ID returned from workflow start") - if on_run_started and workflow_numeric_id and run_id: + if on_run_started and run_id: app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url - on_run_started(f"{app_url}/admin/workflows/{workflow_numeric_id}/runs/{run_id}") + on_run_started(f"{app_url}/admin/workflows?activeTab=runs") terminal = {"COMPLETED", "FAILED", "CANCELLED"} start = time.time() @@ -523,7 +509,6 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: print() # newline after dots result = r.json() result["_run_id"] = run_id - result["_workflow_numeric_id"] = workflow_numeric_id return result raise TimeoutError("Timed out waiting for workflow to complete (5 min)") From 5151e2a906a0833824b53cbb7eb8ae6d66aba812 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:16:56 -0700 Subject: [PATCH 78/83] chore: fix waiting message and add workflow runs link after POST Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 5f2291d..9564868 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -223,7 +223,7 @@ def post_steps(self) -> None: print(f"\n Running: POST /api/v1/workflows/{workflow_tag}/runs") try: def _on_run_started(run_url: str) -> None: - print(f" {_hyperlink(run_url, 'View this workflow run')}") + print(f" {_hyperlink(run_url, 'View workflow runs')}") result = self._trigger_via_cortex_workflow(on_run_started=_on_run_started) status = result.get("status", "").upper() @@ -233,7 +233,7 @@ def _on_run_started(run_url: str) -> None: gh_actions_url = f"https://github.com/{owner}/{repo}/actions" print(f" Deploy complete \u2713") if run_url: - print(f" {_hyperlink(run_url, 'View this workflow run')}") + print(f" {_hyperlink(run_url, 'View workflow runs')}") print(f" {_hyperlink(gh_actions_url, 'View GitHub Actions runs')}") self.mark_done("first_deploy") else: @@ -495,6 +495,7 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: terminal = {"COMPLETED", "FAILED", "CANCELLED"} start = time.time() dots = 0 + print(f" Waiting for Cortex workflow", end="", flush=True) while time.time() - start < 300: time.sleep(5) r = requests.get( @@ -504,7 +505,7 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: r.raise_for_status() status = r.json().get("status", "").upper() dots += 1 - print(f"\r Waiting for GitHub Actions{'.' * (dots % 4)} ", end="", flush=True) + print(f"\r Waiting for Cortex workflow{'.' * (dots % 4)} ", end="", flush=True) if status in terminal: print() # newline after dots result = r.json() From 15fd46b319aba900012e489daa009671a8d3a3ad Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:20:40 -0700 Subject: [PATCH 79/83] chore: hyperlink Cortex workflow in waiting line; remove redundant run link Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/github-actions-deploy/setup.py | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 9564868..6e560e0 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -222,24 +222,15 @@ def post_steps(self) -> None: # Use Cortex async workflow — waits for GitHub Actions callback print(f"\n Running: POST /api/v1/workflows/{workflow_tag}/runs") try: - def _on_run_started(run_url: str) -> None: - print(f" {_hyperlink(run_url, 'View workflow runs')}") - - result = self._trigger_via_cortex_workflow(on_run_started=_on_run_started) + result = self._trigger_via_cortex_workflow() status = result.get("status", "").upper() - run_id = result.get("_run_id", "") - run_url = workflows_url if run_id else None if status == "COMPLETED": gh_actions_url = f"https://github.com/{owner}/{repo}/actions" print(f" Deploy complete \u2713") - if run_url: - print(f" {_hyperlink(run_url, 'View workflow runs')}") print(f" {_hyperlink(gh_actions_url, 'View GitHub Actions runs')}") self.mark_done("first_deploy") else: print(f" Workflow ended with status: {status}", file=sys.stderr) - if run_url: - print(f" {_hyperlink(run_url, 'View this workflow run')}", file=sys.stderr) except Exception as e: print(f" Trigger failed: {e}", file=sys.stderr) print(f" Re-trigger via: cortex solutions post-install -s {self.solution_tag}", file=sys.stderr) @@ -448,7 +439,7 @@ def _import_cortex_workflow(self) -> str: action = "Created" if resp.status_code == 201 else "Updated" return f"{action} workflow 'github-actions-deploy': {_hyperlink(workflows_url, 'View workflows')}" - def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: + def _trigger_via_cortex_workflow(self) -> dict: """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" import time @@ -488,14 +479,12 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: if not run_id: raise RuntimeError("No run ID returned from workflow start") - if on_run_started and run_id: - app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url - on_run_started(f"{app_url}/admin/workflows?activeTab=runs") + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + workflows_url = f"{app_url}/admin/workflows?activeTab=runs" terminal = {"COMPLETED", "FAILED", "CANCELLED"} start = time.time() - dots = 0 - print(f" Waiting for Cortex workflow", end="", flush=True) + print(f" Waiting for {_hyperlink(workflows_url, 'Cortex workflow')}", end="", flush=True) while time.time() - start < 300: time.sleep(5) r = requests.get( @@ -504,8 +493,7 @@ def _trigger_via_cortex_workflow(self, on_run_started=None) -> dict: ) r.raise_for_status() status = r.json().get("status", "").upper() - dots += 1 - print(f"\r Waiting for Cortex workflow{'.' * (dots % 4)} ", end="", flush=True) + print(".", end="", flush=True) if status in terminal: print() # newline after dots result = r.json() From 8a66f8ff11ea829b0c80ca873b0f5a278f2970f6 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:23:16 -0700 Subject: [PATCH 80/83] chore: clarify setup description wording Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 6e560e0..731ce8e 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -6,7 +6,7 @@ SETUP_DESCRIPTION = ( "This solution includes a post-install setup script that will create a GitHub " - "repository, seed it with the Cortex deploy workflow, and configure the required secrets." + "repository, seed it with a GitHub workflow that will add a deploy to a Cortex entity, and configure the required secrets." ) import base64 import os From 8ba40ff425ac62abe416eb6112226db1c6654142 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 11:23:53 -0700 Subject: [PATCH 81/83] chore: clarify API key and base URL confirmation prompts Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 731ce8e..97bef43 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -165,7 +165,7 @@ def collect_prompts(self) -> None: # 3. Cortex credentials from CLI session if self._session_api_key: - if self.confirm("Use current Cortex API key?", default=True): + if self.confirm("Use Cortex API key used by this CLI session?", default=True): self._answers["cortex_api_key"] = self._session_api_key else: self.prompt("cortex_api_key", "Cortex API key", secret=True) @@ -173,7 +173,7 @@ def collect_prompts(self) -> None: self.prompt("cortex_api_key", "Cortex API key", env_var="CORTEX_API_KEY", secret=True) if self._session_base_url: - if self.confirm(f"Use current Cortex base URL [{self._session_base_url}]?", default=True): + if self.confirm(f"Use Cortex base URL used by this CLI session [{self._session_base_url}]?", default=True): self._answers["cortex_base_url"] = self._session_base_url else: self.prompt("cortex_base_url", "Cortex base URL", default=self._session_base_url) From 80e1e4e8e3ce4803e0219e0964f16642d994a333 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 12:07:59 -0700 Subject: [PATCH 82/83] =?UTF-8?q?chore:=20fix=20deploys=20URL=20=E2=80=94?= =?UTF-8?q?=20entity=20body=20uses=20id=20not=20cid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../github-actions-deploy/_templates/github-actions-deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index 64b2ff9..b106609 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -28,7 +28,7 @@ runResponseTemplate: | **Run:** [View on GitHub]({{actions.trigger-deploy.outputs.result.run_url}}) - **Cortex Deploys:** [see deploys for {{context.entity.tag}}]({{variables.app-url}}/admin/service/{{actions.get-entity-details.outputs.body.cid}}/deploys) + **Cortex Deploys:** [see deploys for {{context.entity.tag}}]({{variables.app-url}}/admin/service/{{actions.get-entity-details.outputs.body.id}}/deploys) --- From afd8365f5c047d65ccd28d307348cacee1066022 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 12:39:26 -0700 Subject: [PATCH 83/83] chore: rename notify-cortex job to cortex-callback in workflow and docs Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/github-actions-deploy/README.md | 2 +- .../github-actions-deploy/_templates/cortex-deploy.yml | 2 +- .../_templates/github-actions-deploy.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cortexapps_cli/solutions/github-actions-deploy/README.md b/cortexapps_cli/solutions/github-actions-deploy/README.md index 0b58953..75407b4 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/README.md +++ b/cortexapps_cli/solutions/github-actions-deploy/README.md @@ -43,7 +43,7 @@ Trigger deploys from Cortex, track them as they run in GitHub Actions, and surfa │ job: build │ │ └── run your deploy steps │ │ │ - │ job: notify-cortex │ + │ job: cortex-callback │ │ ├── POST /deploys │◄── registers deploy event │ │ (entity: github-actions- │ on the Cortex entity │ │ demo) │ diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml index bb0dd89..9db5d6c 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -24,7 +24,7 @@ jobs: done echo "Build complete!" - notify-cortex: + cortex-callback: needs: build runs-on: ubuntu-latest if: always() diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml index b106609..39a2070 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -50,7 +50,7 @@ runResponseTemplate: | - **build** — your deploy steps (replace the `sleep` placeholder with your real build/deploy) - - **notify-cortex** — runs after `build`, always, even on failure. It uses two local composite actions bundled in the repo: + - **cortex-callback** — runs after `build`, always, even on failure. It uses two local composite actions bundled in the repo: - **`.github/actions/cortex-register-deploy`** — POSTs a deploy event to the Cortex API, recording the SHA, actor, branch, and run URL against this entity. This feeds the Deploy Health scorecard. @@ -66,7 +66,7 @@ runResponseTemplate: | 1. Copy [`.github/actions/`](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/tree/main/.github/actions) into your repo - 2. Add a `notify-cortex` job to your existing workflow that calls both actions + 2. Add a `cortex-callback` job to your existing workflow that calls both actions 3. Set `CORTEX_API_KEY` and `CORTEX_BASE_URL` as repository secrets