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
7 changes: 2 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,11 @@
- Frozen exit codes (see README), mapped from the error category; `X-DevHelm-Client` on every request and an `Idempotency-Key` on every POST. Keys are random per invocation and reused only for retries of that request, so a second identical command is a new request (as before), never a replay.

### Changed
- `deploy` never changes `enabled` (pause is an overlay); `enabled` in devhelm.yml is now an error naming `monitors pause|resume`.
- `--prune` deletes only monitors this project owns. `state pull` pulls only this project's monitors.
- `deploy --prune` also deletes monitors this project owns (`ownerProject == project`) that are absent from the config, in addition to the resources in `.devhelm/state.json`. `--prune-org-cli` and `--prune-all` keep their wider scopes.
- `state pull` pulls only this project's monitors when devhelm.yml sets `project:`; without `project:` it pulls every monitor, as before.
- `monitors test` exits 14 when a probe fails.
- The owner the CLI writes is `GIT`.

### Removed
- `deploy --prune-org-cli`, `--prune-all`.

### Deprecated (removed 2027-01-31)
- `monitors results` (use `monitors history`).
- The bare JSON output of pre-1.12 commands (set `DEVHELM_JSON_ENVELOPE=1` for the wrapper).
3 changes: 2 additions & 1 deletion skills/devhelm-synthetics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,8 @@ monitors:
```

`code` is required for code types; `config.url` is not allowed on them;
`enabled:` is refused (use `devhelm monitors pause|resume`). `include` is
`enabled:` is optional: omit it and deploy leaves the pause state alone
(`devhelm monitors pause|resume`). `include` is
only for files nothing imports (fixtures read at runtime).

---
Expand Down
30 changes: 23 additions & 7 deletions src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
apply, buildStateV2, changesetToJson, formatPlan, processMovedBlocks, readState, resourceAddress, writeState,
type ApplyResult, type Change,
} from '../../lib/yaml/index.js'
import {removedPruneFlag} from '../plan.js'

const MAX_LOCK_TTL_MINUTES = 60

Expand All @@ -30,6 +29,8 @@ export default class Deploy extends DevhelmCommand {
'<%= config.bin %> deploy',
'<%= config.bin %> deploy --yes --lock-timeout 5m',
'<%= config.bin %> deploy --env production --prune --yes',
'<%= config.bin %> deploy --prune-org-cli --yes',
'<%= config.bin %> deploy --prune-all --yes',
'<%= config.bin %> deploy --adopt "Checkout" --yes',
'<%= config.bin %> deploy --acknowledge-rollback "Checkout" --yes',
'<%= config.bin %> deploy --dry-run',
Expand All @@ -42,9 +43,19 @@ export default class Deploy extends DevhelmCommand {
file: fileFlag,
env: Flags.string({description: 'Only monitors in this environment (slug)'}),
'no-test': Flags.boolean({description: 'Publish code without a validation run (PUT with code; untested)', default: false}),
prune: Flags.boolean({description: 'Delete monitors this project owns that are absent from the config', default: false}),
'prune-org-cli': Flags.boolean({hidden: true, default: false}),
'prune-all': Flags.boolean({hidden: true, default: false}),
prune: Flags.boolean({description: 'Delete resources tracked in .devhelm/state.json and monitors this project owns that are absent from the config', default: false}),
'prune-org-cli': Flags.boolean({
description:
'Also delete CLI-managed resources elsewhere in the org that are absent from YAML. ' +
'Use with caution in shared workspaces; widens --prune to the legacy org-wide CLI scope.',
default: false,
}),
'prune-all': Flags.boolean({
description:
'Delete ALL resources not in YAML, including dashboard- and Terraform-managed ones. ' +
'The most destructive option; intended for single-tenant workspaces or scripted teardowns.',
default: false,
}),
adopt: Flags.string({description: 'Take over an existing monitor of that name (transfer to Git, this project); repeatable', multiple: true}),
'acknowledge-rollback': Flags.string({description: 'Re-publish over an emergency rollback of this monitor, for this run only; repeatable', multiple: true}),
'lock-timeout': Flags.string({description: 'How long to wait for a held deploy lock (e.g. 5m; 0 = fail at once)', default: '0'}),
Expand All @@ -59,16 +70,21 @@ export default class Deploy extends DevhelmCommand {

async run() {
const {flags} = await this.parse(Deploy)
if (flags['prune-org-cli']) removedPruneFlag('--prune-org-cli')
if (flags['prune-all']) removedPruneFlag('--prune-all')
const timeoutMs = parseDurationMs(flags.timeout, 'timeout')
const lockTimeoutMs = parseDurationMs(flags['lock-timeout'], 'lock-timeout')
const deadline = Date.now() + timeoutMs
const client = this.client()
const files = configFiles(undefined, flags.file)
if (!this.structured) this.progress('Fetching current state from API...')

const p = await runPipeline(client, {files, prune: flags.prune, env: flags.env, adopt: flags.adopt, details: true, applyMoved: false})
const p = await runPipeline(client, {
files,
// The wider scopes imply --prune.
prune: flags.prune || flags['prune-org-cli'] || flags['prune-all'],
pruneOrgCli: flags['prune-org-cli'] || flags['prune-all'],
pruneAll: flags['prune-all'],
env: flags.env,
adopt: flags.adopt, details: true, applyMoved: false})
await this.loadMeta({org: p.config.org, workspace: p.config.workspace})
for (const w of p.warnings) this.addWarning(w)
const changes = totalChanges(p.changeset) + p.bind.adopt.length
Expand Down
2 changes: 1 addition & 1 deletion src/commands/monitors/pause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {resolveMonitor} from '../../lib/monitors/resolve.js'

export default class MonitorsPause extends DevhelmCommand {
static legacyOutput = true
static description = 'Pause a monitor (an overlay: deploy never clears it)'
static description = 'Pause a monitor (deploy keeps it paused unless devhelm.yml sets `enabled`)'
static examples = [
'<%= config.bin %> monitors pause Checkout --env production --reason "provider maintenance"',
'<%= config.bin %> monitors pause 7f3c… --until 2h',
Expand Down
17 changes: 7 additions & 10 deletions src/commands/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export default class Plan extends DevhelmCommand {
'<%= config.bin %> plan',
'<%= config.bin %> plan --env production',
'<%= config.bin %> plan --prune',
'<%= config.bin %> plan --prune-all',
'<%= config.bin %> plan --detailed-exitcode',
'<%= config.bin %> plan -o json',
]
Expand All @@ -25,18 +26,20 @@ export default class Plan extends DevhelmCommand {
output: projectOutputFlag,
file: fileFlag,
env: Flags.string({description: 'Only monitors in this environment (slug)'}),
prune: Flags.boolean({description: 'Include deletions of monitors this project owns that are absent from the config', default: false}),
'prune-all': Flags.boolean({hidden: true, description: 'Removed: deleted resources other projects own', default: false}),
prune: Flags.boolean({description: 'Include deletions of resources tracked in .devhelm/state.json and monitors this project owns that are absent from the config', default: false}),
'prune-all': Flags.boolean({
description: 'Include deletions of ALL resources not in config, including those not managed by the CLI',
default: false,
}),
'detailed-exitcode': Flags.boolean({description: 'Exit 10 when the plan has changes (for CI)', default: false}),
}

async run() {
const {flags} = await this.parse(Plan)
if (flags['prune-all']) removedPruneFlag('--prune-all')
const client = this.client()
const files = configFiles(undefined, flags.file)
if (!this.structured) this.progress('Fetching current state from API...')
const p = await runPipeline(client, {files, prune: flags.prune, env: flags.env, details: true, applyMoved: false})
const p = await runPipeline(client, {files, prune: flags.prune || flags['prune-all'], pruneAll: flags['prune-all'], env: flags.env, details: true, applyMoved: false})
await this.loadMeta({org: p.config.org, workspace: p.config.workspace})
for (const w of p.warnings) this.addWarning(w)
for (const u of p.bind.unowned) this.addWarning(`${u.name} exists (${u.owner}) but this project does not own it; deploy needs --adopt "${u.name}"`)
Expand Down Expand Up @@ -75,9 +78,3 @@ export default class Plan extends DevhelmCommand {
})
}
}

export function removedPruneFlag(flag: string): never {
throw new (class extends Error {
oclif = {exit: EXIT_CODES.VALIDATION}
})(`${flag} was removed: its deletion scope crossed teams. --prune deletes only monitors this project owns (ownerProject == project).`)
}
19 changes: 14 additions & 5 deletions src/commands/state/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,18 @@ import {isGitOwner} from '../../lib/yaml/project-context.js'

type Schemas = components['schemas']

/**
* Whether `state pull` records this monitor. With a `project:` key only
* the monitors this project owns (ownerProject == project); without one,
* every monitor (the 1.11 behaviour).
*/
export function pullsMonitor(raw: Schemas['MonitorDto'], project: string | undefined): boolean {
if (!project) return true
return isGitOwner(raw.owner?.type ?? raw.managedBy) && raw.owner?.project === project
}

export default class StatePull extends Command {
static description = 'Reconstruct the state file from the API: monitors this project owns, plus the other resources'
static description = 'Reconstruct the state file from the API (monitors are scoped to `project:` when the config sets it)'

static examples = [
'<%= config.bin %> state pull',
Expand Down Expand Up @@ -59,15 +69,14 @@ export default class StatePull extends Command {
children?: Record<string, ChildStateEntry>
}> = []

// Monitors: only the ones this project owns (ownerProject == project).
// Pulling the whole org would turn `deploy --prune` into prune-all.
// Monitors: with `project:`, only the ones this project owns, so
// `deploy --prune` stays within the project. Without it, all of them.
const project = existsSync(flags.file) ? loadProject([flags.file]).config?.project : undefined
if (!project) this.warn(`No "project:" in ${flags.file}; no monitors are pulled (they are scoped to a project)`)
for (const handler of handlers) {
for (const entry of refs.allEntries(handler.refType)) {
if (handler.resourceType === 'monitor') {
const raw = entry.raw as Schemas['MonitorDto']
if (!project || !isGitOwner(raw.owner?.type ?? raw.managedBy) || raw.owner?.project !== project) continue
if (!pullsMonitor(raw, project)) continue
}
let children: Record<string, ChildStateEntry> | undefined
if (handler.resourceType === 'statusPage') {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/monitors/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function yamlEntryFor(m: MonitorDto, opts: {codePath?: string | {entrypoi
/** Fields set on the monitor that the YAML entry does not carry. */
export function fidelityReport(m: MonitorDto): string[] {
const out: string[] = []
if (m.pausedAt || m.enabled === false) out.push('paused: an overlay, set with `devhelm monitors pause|resume`')
if (m.pausedAt || m.enabled === false) out.push('paused: not exported; set with `devhelm monitors pause|resume` or `enabled: false`')
if (m.muted) out.push('muted: an overlay, set with `devhelm monitors mute|unmute`')
if (m.quarantineUntil) out.push('quarantined: an overlay, set with `devhelm monitors quarantine|release-quarantine`')
if (m.auth) out.push('auth: secret-backed auth is not exported; re-declare it with a secret reference')
Expand Down
8 changes: 6 additions & 2 deletions src/lib/project/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {formatIssue, loadProject, prepareCodeMonitors, type LoadedProject} from
export interface PipelineOptions {
files: string[]
prune: boolean
/** `--prune-org-cli`: also delete CLI/Git-owned resources outside this config. */
pruneOrgCli?: boolean
/** `--prune-all`: also delete resources other tools own. */
pruneAll?: boolean
adopt?: string[]
env?: string
/** Fetch changed file names and drift (plan output). */
Expand Down Expand Up @@ -86,7 +90,7 @@ export async function runPipeline(client: ApiClient, opts: PipelineOptions): Pro
if (planRefs.errors.length > 0) throw issuesError('Validation failed', planRefs.errors.map((e) => `${e.path}: ${e.message}`), 'CONFIG_INVALID')

const children = await prefetchChildSnapshots(config, refs, client)
let changeset = await diff(config, refs, {prune: opts.prune}, state, children)
let changeset = await diff(config, refs, {prune: opts.prune, pruneOrgCli: opts.pruneOrgCli, pruneAll: opts.pruneAll}, state, children)
if (opts.env) {
changeset = {
...changeset,
Expand Down Expand Up @@ -135,7 +139,7 @@ export function codePlanLines(plans: CodeMonitorPlan[]): string[] {
}
if (p.action === 'update' && p.settingsChanged) what.push('settings')
if (p.owner) what.push(`owner ${p.owner}`)
if (p.overlays.length > 0) what.push(`${p.overlays.join(', ')} (not changed by deploy)`)
if (p.overlays.length > 0) what.push(`${p.overlays.join(', ')} (deploy leaves these unless devhelm.yml sets enabled)`)
out.push(` ${sym} ${p.name} ${what.join(' · ')}`)
if (p.drift) {
out.push(` ! live code ${p.drift.liveDigest.slice(0, 19)}… differs from the last Git deploy ${p.drift.gitDigest.slice(0, 19)}…${p.republishesRolledBack ? ` (rolled back); deploy refuses unless --acknowledge-rollback "${p.name}"` : ''}`)
Expand Down
16 changes: 9 additions & 7 deletions src/lib/yaml/differ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,6 @@ function diffSection(

const address = resourceAddress(handler.resourceType as ResourceType, entry.refKey)
const scope = classifyDeleteScope(handler, entry, stateAddresses, address, refs)
if (!scope) continue

// Decide whether to include this delete based on the requested
// prune flag(s). State-scoped deletes are the safe default and
Expand Down Expand Up @@ -248,19 +247,22 @@ function classifyDeleteScope(
stateAddresses: ReadonlySet<string>,
address: string,
refs: ResolvedRefs,
): PruneScope | undefined {
): PruneScope {
if (stateAddresses.has(address)) return 'state'
// Monitors are pruned only when this project owns them: Git-owned with
// `ownerProject == project` (doc 42 §6.3). Nothing else is ever a prune
// candidate — `--prune-org-cli` / `--prune-all` were removed because
// their deletion scope crossed teams.
// Monitors: this project's own (Git-owned, `ownerProject == project`,
// doc 42 §6.3) are covered by plain `--prune`. Other CLI/Git-owned
// monitors need `--prune-org-cli`; monitors other tools own (dashboard,
// Terraform, MCP, API) need `--prune-all`.
if (handler.resourceType === 'monitor') {
const project = refs.project.project
const raw = entry.raw as {owner?: {type?: string; project?: string | null} | null; managedBy?: string} | undefined
const type = raw?.owner?.type ?? raw?.managedBy ?? entry.managedBy ?? undefined
if (project && isGitOwner(type) && raw?.owner?.project === project) return 'state'
return isGitOwner(type) ? 'org-cli' : 'org-all'
}
return undefined
// Other resource types carry no owner on the API: `--prune-org-cli`
// deletes anything absent from YAML, `--prune-all` likewise.
return 'org-cli'
}

function memberKey(memberType: string, nameOrSlug: string): string {
Expand Down
6 changes: 4 additions & 2 deletions src/lib/yaml/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,11 +707,11 @@ const resourceGroupHandler = defineHandler<YamlResourceGroup, Schemas['ResourceG

// Derived from UpdateMonitorRequest minus control-only fields (clearAuth,
// clearEnvironmentId, managedBy, status, acknowledgeRollback, expectedUpdatedAt,
// ownerEnforced, ownerProject, enabled — pause is an overlay, F14). `code`
// ownerEnforced, ownerProject). `code`
// is replaced by the closure digest the live revision runs. auth and
// incidentPolicy need | null because monitors can lack them.
type MonitorSnapshotBase = Required<Omit<Schemas['UpdateMonitorRequest'],
'clearEnvironmentId' | 'clearAuth' | 'managedBy' | 'status' | 'enabled'
'clearEnvironmentId' | 'clearAuth' | 'managedBy' | 'status'
| 'ownerEnforced' | 'ownerProject' | 'code'
| 'acknowledgeRollback' | 'expectedUpdatedAt'>>
type MonitorSnapshot = Omit<MonitorSnapshotBase, 'auth' | 'incidentPolicy'> & {
Expand Down Expand Up @@ -745,6 +745,7 @@ function monitorDesiredSnapshot(yaml: YamlMonitor, api: Schemas['MonitorDto'], r
name: yaml.name,
config: desiredMonitorConfig(yaml, api),
frequencySeconds: yaml.frequencySeconds ?? api.frequencySeconds ?? null,
enabled: yaml.enabled ?? api.enabled ?? null,
regions: yaml.regions !== undefined
? sortedIds(yaml.regions)
: (api.regions ? sortedIds(api.regions) : null),
Expand Down Expand Up @@ -792,6 +793,7 @@ function monitorCurrentSnapshot(api: Schemas['MonitorDto']): MonitorSnapshot {
name: api.name ?? null,
config: stripNullish(api.config) as MonitorSnapshot['config'],
frequencySeconds: api.frequencySeconds ?? null,
enabled: api.enabled ?? null,
regions: api.regions ? sortedIds(api.regions) : null,
environmentId: api.environment?.id ?? null,
assertions: apiAssertionsToSnapshot(api.assertions),
Expand Down
3 changes: 2 additions & 1 deletion src/lib/yaml/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,11 @@ function applyDefaults(config: DevhelmConfig): DevhelmConfig {

function applyMonitorDefaults(monitor: YamlMonitor, defaults: YamlMonitorDefaults, runtimeId: string | undefined): YamlMonitor {
const out: YamlMonitor = {...monitor}
const fill = <K extends 'frequencySeconds' | 'regions' | 'alertChannels' | 'incidentPolicy'>(key: K) => {
const fill = <K extends 'frequencySeconds' | 'enabled' | 'regions' | 'alertChannels' | 'incidentPolicy'>(key: K) => {
if (out[key] === undefined && defaults[key] !== undefined) out[key] = defaults[key] as YamlMonitor[K]
}
fill('frequencySeconds')
fill('enabled')
fill('regions')
fill('alertChannels')
fill('incidentPolicy')
Expand Down
3 changes: 3 additions & 0 deletions src/lib/yaml/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@ export interface YamlMonitor {
key?: string
session?: YamlSession
frequencySeconds?: number
/** Omitted = deploy leaves the current pause state alone. */
enabled?: boolean
regions?: string[]
runParallel?: boolean
fastRetryMaxAttempts?: number
Expand Down Expand Up @@ -546,6 +548,7 @@ export interface YamlStatusPage {

export interface YamlMonitorDefaults {
frequencySeconds?: number
enabled?: boolean
regions?: string[]
alertChannels?: string[]
incidentPolicy?: YamlIncidentPolicy
Expand Down
Loading
Loading