Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/ci-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ If the platform's file path variable is missing (e.g. `GITHUB_OUTPUT` unset), th
| `diff_has_destructive` | `true` when the diff includes deletions |
| `plan_ids` | Comma-separated external plan ids when `change plan` fans out across environments |
| `plan_count` | Number of plans created by environment fan-out (`1` when `--environment` is set) |
| `pr_touched_apps` | Comma-separated app names in the plan that overlap PR/changed paths (labeling only) |
| `also_still_drifted_apps` | Comma-separated plan apps that are still drifted but not touched by this PR |
| `pr_preview_summary` | Human line: `this PR touches: …; also still drifted: …` |

`pr_*` keys are emitted only when changed paths are available (`--changed-paths`,
`--changed-paths-file`, `DESLICER_CHANGED_PATHS`, or a GitHub `pull_request` git
range) **and** a dry-run diff with change items exists. They never filter which
apps the plan packs or executes — full desired-vs-observed reconcile remains the
execute semantics.

GitHub Actions also receives a **job step summary** (markdown table, plus a PR
preview section when labels are present) when `GITHUB_STEP_SUMMARY` is set.

### `change status`

Expand All @@ -44,8 +56,6 @@ If the platform's file path variable is missing (e.g. `GITHUB_OUTPUT` unset), th
| `fully_completed_items` | Items applied on every target host |
| `diff_*` | Same keys as verify when a persisted dry-run diff exists |

GitHub Actions also receives a **job step summary** (markdown table) when `GITHUB_STEP_SUMMARY` is set.

### `change deploy` (queued, with `--no-wait`)

| Key | Description |
Expand Down
42 changes: 39 additions & 3 deletions docs/environments.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,50 @@ Monorepos may define many files:
└── dr-failover.yml
```

Select per job:
Keep workflows thin: one matrix row (or job) per stem. Resolve groups by **name** from
each YAML (`inventory_group`) — do not hard-code a single repo-level `TARGET_GROUP_ID`
when stems map different host groups.

```yaml
- run: deslicer change deploy --environment staging
- run: deslicer change deploy --environment production
strategy:
fail-fast: false
matrix:
target: [staging, production]
steps:
- run: deslicer inventory validate --environment ${{ matrix.target }}
- run: deslicer change plan --environment ${{ matrix.target }}
# omit --target-group when the stem YAML has exactly one destination with apps;
# otherwise pass --target-group <inventory_group-name>
```

Select deploy per job:

```yaml
- run: deslicer change deploy --environment staging --plan-id ...
- run: deslicer change deploy --environment production --plan-id ...
if: github.ref == 'refs/heads/main'
```

**Multi-group, same SHA:** Observer migration `206` scopes the idempotent
index by `target_group_id` (plus an unscoped index when the group is null), so
matrix/fan-out across inventory groups on one commit is supported on Observer
builds that include that migration. Older Observer builds still collide on
`(tenant, repo, commit)` alone.

## Lifecycle gates (Path A2)

Suggested thin CI shape (logic stays in the CLI — no reusable workflow core):

| Event | Suggested steps | Notes |
|-------|-----------------|-------|
| `pull_request` | `inventory validate` → `change plan` (draft / pending_approval) | Preview only; approve in portal when ready |
| `push` to default branch / manual full run | `change plan` (full reconcile) → approve → deploy | Same reconcile semantics as PR — packs all drifted mapped apps |
| Nothing mapped | Skip plan | Exit early when the stem has no `source_path` apps |

PR jobs may pass changed paths (or rely on GitHub PR detection) so CI outputs
label **this PR touches** vs **also still drifted** without filtering execute.
See [ci-outputs.md](ci-outputs.md).

## Air-gapped override

When resolve-backend cannot reach deslicer-ai, operators may set `OBSERVER_API_URL` to talk to Observer directly. Environment files still name the logical target for workflows; the override is a break-glass path documented in [installation.md](installation.md).
57 changes: 49 additions & 8 deletions src/commands/change/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::commands::pipeline::{
use crate::errors::CliError;
use crate::observer_client::{ChangePlan, Client, OrchestratedPlan};
use crate::output::{emit_change_plan, emit_change_plan_with_diff, emit_change_plans};
use crate::pr_preview::{labels_for_plan_context, resolve_changed_paths, PrPreviewLabels};
use crate::token_source::TokenSource;
use crate::Ctx;

Expand Down Expand Up @@ -42,6 +43,17 @@ pub struct Args {
/// Optional plan name for the bundle-sourced plan.
#[arg(long)]
pub name: Option<String>,

/// Optional file of changed repo paths (one per line) for PR preview labeling.
/// Clarity only — does not filter which apps the plan packs. When omitted,
/// the CLI uses `DESLICER_CHANGED_PATHS` or a GitHub pull_request git range.
#[arg(long, value_name = "FILE")]
pub changed_paths_file: Option<std::path::PathBuf>,

/// Comma/newline-separated changed paths (same labeling semantics as
/// `--changed-paths-file`).
#[arg(long, value_name = "PATHS")]
pub changed_paths: Option<String>,
}

/// Compile polling: the ephemeral compile-runner takes seconds to a few
Expand Down Expand Up @@ -219,7 +231,7 @@ pub async fn run(ctx: Ctx, args: Args) -> i32 {
// or environment discovery. Handle it before the proxy-mode gate.
if session.is_observer_api_token() {
return match run_direct_git_plan(&session, &client, &args, environment.as_deref()).await {
Ok(plan) => emit_change_plan(&plan),
Ok(plan) => emit_ready_plan(&client, &args, environment.as_deref(), plan).await,
Err(err) => map_cli_error(ctx.log_format, err),
};
}
Expand Down Expand Up @@ -364,13 +376,8 @@ async fn run_git_plans(
}

let emit_code = if plans.len() == 1 {
if let Some(ready) = last_ready.as_ref() {
let diff = client
.get_dry_run_diff(&ready.id)
.await
.ok()
.and_then(|body| crate::diff_summary::diff_counts_from_observer_value(&body));
emit_change_plan_with_diff(ready, diff.as_ref())
if let Some(ready) = last_ready {
emit_ready_plan(client, args, environment, ready).await
} else {
emit_change_plan(&plans[0])
}
Expand All @@ -385,6 +392,40 @@ async fn run_git_plans(
}
}

fn preview_labels_for(
args: &Args,
environment: Option<&str>,
diff_body: Option<&serde_json::Value>,
) -> Option<PrPreviewLabels> {
let changed = resolve_changed_paths(
args.changed_paths_file.as_deref(),
args.changed_paths.as_deref(),
)?;
let env_yaml = environment.and_then(|stem| {
crate::target_group::read_environment_yaml(stem)
.ok()
.flatten()
});
labels_for_plan_context(diff_body, env_yaml.as_deref(), &changed)
}

async fn emit_ready_plan(
client: &Client,
args: &Args,
environment: Option<&str>,
plan: ChangePlan,
) -> i32 {
let diff_body = client.get_dry_run_diff(&plan.id).await.ok();
let counts = diff_body
.as_ref()
.and_then(crate::diff_summary::diff_counts_from_observer_value);
let preview = preview_labels_for(args, environment, diff_body.as_ref());
if let Some(ref labels) = preview {
eprintln!("{}", labels.human_summary());
}
emit_change_plan_with_diff(&plan, counts.as_ref(), preview.as_ref())
}

#[cfg(test)]
mod tests {
use super::environments_for_plan;
Expand Down
4 changes: 2 additions & 2 deletions src/commands/change/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,11 @@ pub async fn run(ctx: Ctx, args: Args) -> i32 {
if let Some(ref counts) = counts {
emit_message(&crate::output::diff_count_pairs(counts));
}
emit_change_plan_with_diff(&plan, counts.as_ref())
emit_change_plan_with_diff(&plan, counts.as_ref(), None)
}
Err(err) => {
eprintln!("dry-run accepted, but the diff could not be fetched: {err}");
emit_change_plan_with_diff(&plan, None)
emit_change_plan_with_diff(&plan, None, None)
}
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub mod observer_client;
pub mod observer_token;
pub mod oidc_exchange;
pub mod output;
pub mod pr_preview;
pub mod reporting;
pub mod resolver;
pub mod session_portal;
Expand Down
83 changes: 74 additions & 9 deletions src/output.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::ci::{detect_platform, CiPlatform};
use crate::diff_summary::DiffCounts;
use crate::observer_client::{ChangePlan, ExecutionQueued, ExecutionSummary, PlanProgress};
use crate::pr_preview::PrPreviewLabels;
use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::{self, Write};
Expand Down Expand Up @@ -113,7 +114,12 @@ pub fn emit_diff_counts(counts: &DiffCounts) -> i32 {
emit_to_sink(&diff_count_pairs(counts))
}

fn plan_summary_markdown(title: &str, plan: &ChangePlan, diff: Option<&DiffCounts>) -> String {
fn plan_summary_markdown(
title: &str,
plan: &ChangePlan,
diff: Option<&DiffCounts>,
preview: Option<&PrPreviewLabels>,
) -> String {
let mut lines = vec![
format!("## {title}"),
String::new(),
Expand All @@ -136,16 +142,47 @@ fn plan_summary_markdown(title: &str, plan: &ChangePlan, diff: Option<&DiffCount
lines.push("| Destructive | yes |".to_string());
}
}
lines.join("\n")
let mut body = lines.join("\n");
if let Some(labels) = preview {
body.push_str("\n\n");
body.push_str(&labels.markdown_section());
}
body
}

pub fn pr_preview_pairs(labels: &PrPreviewLabels) -> Vec<(&'static str, String)> {
vec![
("pr_touched_apps", labels.pr_touched_apps.join(",")),
(
"also_still_drifted_apps",
labels.also_still_drifted_apps.join(","),
),
("pr_preview_summary", labels.human_summary()),
]
}

pub fn emit_change_plan(plan: &ChangePlan) -> i32 {
emit_change_plan_with_diff(plan, None)
emit_change_plan_with_diff(plan, None, None)
}

pub fn emit_change_plan_with_diff(plan: &ChangePlan, diff: Option<&DiffCounts>) -> i32 {
pub fn emit_change_plan_with_diff(
plan: &ChangePlan,
diff: Option<&DiffCounts>,
preview: Option<&PrPreviewLabels>,
) -> i32 {
println!("{}", serde_json::to_string(plan).unwrap_or_default());
let summary = if let Some(counts) = diff {
let summary = if let Some(labels) = preview.filter(|value| !value.is_empty()) {
let base = if let Some(counts) = diff {
counts.human_summary()
} else {
plan.display_summary()
};
if base.is_empty() {
labels.human_summary()
} else {
format!("{base}; {}", labels.human_summary())
}
} else if let Some(counts) = diff {
counts.human_summary()
} else {
plan.display_summary()
Expand All @@ -159,7 +196,11 @@ pub fn emit_change_plan_with_diff(plan: &ChangePlan, diff: Option<&DiffCounts>)
if let Some(counts) = diff {
pairs.extend(diff_count_pairs(counts));
}
let _ = append_github_step_summary(&plan_summary_markdown("Deslicer plan", plan, diff));
if let Some(labels) = preview {
pairs.extend(pr_preview_pairs(labels));
}
let _ =
append_github_step_summary(&plan_summary_markdown("Deslicer plan", plan, diff, preview));
emit_to_sink(&pairs)
}

Expand Down Expand Up @@ -236,8 +277,12 @@ pub fn emit_plan_status(
pairs.extend(diff_count_pairs(counts));
}
if let Some(plan) = plan {
let _ =
append_github_step_summary(&plan_summary_markdown("Deslicer plan status", plan, diff));
let _ = append_github_step_summary(&plan_summary_markdown(
"Deslicer plan status",
plan,
diff,
None,
));
}
emit_to_sink(&pairs)
}
Expand Down Expand Up @@ -338,13 +383,33 @@ mod tests {
name: None,
summary: None,
};
let _ = append_github_step_summary(&plan_summary_markdown("Test", &plan, None));
let _ = append_github_step_summary(&plan_summary_markdown("Test", &plan, None, None));
std::env::remove_var("GITHUB_STEP_SUMMARY");
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("pending_approval"));
assert!(content.contains("ext"));
}

#[test]
fn step_summary_includes_pr_preview_labels() {
let labels = PrPreviewLabels {
pr_touched_apps: vec!["demo_ci_app".into()],
also_still_drifted_apps: vec!["TA-linux".into()],
};
let plan = ChangePlan {
id: "row".into(),
plan_id: Some("ext".into()),
status: "pending_approval".into(),
name: None,
summary: None,
};
let markdown = plan_summary_markdown("Test", &plan, None, Some(&labels));
assert!(markdown.contains("This PR touches"));
assert!(markdown.contains("demo_ci_app"));
assert!(markdown.contains("Also still drifted"));
assert!(markdown.contains("TA-linux"));
}

#[test]
fn plans_summary_lists_each_plan() {
let plans = [
Expand Down
Loading
Loading