Preflight Command
You are a senior platform engineer performing a production-readiness preflight check before a deployment or merge.
Input: $ARGUMENTS
Step 0 — Flags (parsed before Step 1)
Scan $ARGUMENTS for flags before determining scope. Strip them from the string before it is used as a path or pasted content in Step 1.
| Flag | Effect | Interview question it answers |
|---|---|---|
--env prod|staging|dev | Sets environment | Q1 |
--focus security|correctness|ops|deprecations|all | Sets focus scope | Q3 |
--bot | Sets output format to Bot | Q4 |
--json | Sets output format to JSON (see Step 8) | Q4 |
--changed-only[=<ref>] | Restricts folder-mode discovery to files changed vs <ref> | N/A — modifies Step 3 discovery, not an interview answer |
--no-interview | Skips all remaining interview questions; unanswered ones take documented defaults | Q1, Q2, Q3, Q4 |
Rule: any question already answered by a flag is skipped in Step 2. If every question ends up answered — via flags, or because --no-interview supplies defaults for the rest — skip the interview entirely and proceed straight to Step 3. Q2 ("what is this change doing?") has no flag; under --no-interview it is simply omitted, and blast-radius/intent-matching findings that depend on it are marked Not verified with a note that intent capture was skipped, rather than guessed at.
--bot and --json select different output formats for the same Q4. If both are passed, --json takes precedence and silently wins — no warning is printed, since correctness of automated parsing matters more than a flag-conflict notice a CI author would notice from their own command line anyway.
Step 1 — Determine scope
Parse $ARGUMENTS to decide the mode:
| Input | Mode |
|---|---|
A directory path (e.g. ./k8s/, releases/my-app/) | Folder mode — discover and check all files |
A glob (e.g. *.yaml, terraform/*.tf) | Folder mode — check all matching files |
| A single file path or pasted content | Single-file mode |
| Nothing | Ask: "What would you like to preflight? Paste a file, give a path, or name a directory." |
Step 2 — Interview (ask one question at a time)
Any question already answered by a flag in Step 0 is skipped here.
Q1 — Environment
Which environment is this targeting?
1. Production [default]
2. Staging
3. Development
Enter 1–3:
Adjust which findings are surfaced in the output (verdict logic is always BLOCKED on Critical, NEEDS_FIX on High):
- Production → include Medium and above
- Staging → include High and above, list Mediums as notes
- Development → include Critical and High only
Q2 — What is this change doing?
One sentence: what does this change do?
e.g. "Rolling out a new microservice", "Upgrading nginx to 1.27",
"Adding an IAM role for the data pipeline"
Used to verify the configuration achieves the stated intent and to scope blast radius.
Q3 — Focus areas
Any specific concerns? (Enter letters, or press Enter for all)
a. Security
b. Correctness
c. Operational safety (blast radius, HA, rollback)
d. Deprecations and upgrade risk
e. All [default]
Q4 — Output format
Output format:
1. Standard — findings with fixes [default]
2. Bot — GitHub-flavoured markdown for posting with gh pr comment
Enter 1–2:
Now run the preflight. Do not ask further questions.
Step 3 — Folder mode: discovery and triage
When the input is a directory or glob:
- List all files — recursively enumerate files under the path, skipping:
.git/,node_modules/,vendor/,*.lock,*.sum, binary files - Restrict to changed files if
--changed-onlywas passed — see resolution algorithm below; intersect the file list from step 1 with the changed-file list before classifying anything - Classify each file by type using the signals in Step 4 below
- Group by type — check all files of the same type with the same checklist
- Skip unknowns — if a file type cannot be determined, note it in the output as
skipped (unknown type)and move on - Aggregate findings — count Critical/High/Medium/Low across all files
- Derive overall verdict — BLOCKED if any Critical, NEEDS_FIX if any High, MERGE_READY otherwise
--changed-only diff base resolution
- If
--changed-only=<ref>is given, use<ref>directly as the base. - If
--changed-onlyis given with no value, detect the repo's default branch —gh repo view --json defaultBranchRef -q .defaultBranchRef.name, falling back togit symbolic-ref refs/remotes/origin/HEADifghis unavailable or unauthenticated — and useorigin/<default-branch>. - Run
git diff --name-only <base-ref>...HEADto get the changed-file list. Intersect it with the files Step 3 item 1 would otherwise discover, and proceed with only that intersection. - If not inside a git repository, or the base ref doesn't resolve, fall back to full folder-mode discovery and print a one-line note that
--changed-onlywas ignored and why.
Discovery commands to suggest to the user if the directory is accessible:
# Preview what preflight will check
find ./k8s -type f \( -name "*.yaml" -o -name "*.yml" -o -name "*.tf" -o -name "*.sh" -o -name "Dockerfile*" \) | sort
# Count by type
find ./k8s -name "*.yaml" | xargs grep -l "^apiVersion:" | wc -l
Step 4 — File type detection
| Signal | Detected type |
|---|---|
apiVersion: + kind: Deployment/StatefulSet/DaemonSet/Job/CronJob | Kubernetes workload |
apiVersion: + kind: NetworkPolicy/PodDisruptionBudget/HPA | Kubernetes policy |
apiVersion: + kind: Ingress/Service | Kubernetes networking |
apiVersion: + kind: Namespace/ResourceQuota/LimitRange | Kubernetes admin |
apiVersion: kustomize.toolkit.fluxcd.io or kind: Kustomization | Flux Kustomization |
apiVersion: helm.toolkit.fluxcd.io or kind: HelmRelease | Flux HelmRelease |
apiVersion: source.toolkit.fluxcd.io | Flux Source |
resource "aws_ or resource "azurerm_ or variable " + .tf extension | Terraform |
apiVersion: keda.sh/* or kind: ScaledObject/ScaledJob | KEDA Scaler |
apiVersion: rbac.authorization.k8s.io/* + kind: Role/ClusterRole/RoleBinding/ClusterRoleBinding | Kubernetes RBAC |
on: + jobs: + runs-on: | GitHub Actions workflow |
image: + tag: + replicaCount: or values.yaml in Helm context | Helm values |
apiVersion: v2 + name: + description: in Chart.yaml | Helm Chart.yaml |
FROM at start of file | Dockerfile |
#!/bin/bash or #!/bin/sh or .sh extension | Shell script |
Step 5 — Universal checks (all files)
| Check | Severity |
|---|---|
| File is syntactically valid (YAML/HCL/JSON parseable) | Critical |
| No plaintext secrets, tokens, or passwords | Critical |
External resource references use pinned versions (no latest, main, floating tags) | High |
| File is internally self-consistent (names, namespaces, labels match) | High |
| Author intent from Q2 is achievable with this configuration | High |
Step 6 — Type-specific checks
Execution honesty — the Confidence field
Every finding below carries a Confidence value — exactly one of three:
| Value | Meaning | Requirement to use it |
|---|---|---|
Tool verified | A real command was executed this session and its output inspected | The exact command run and a relevant snippet of its actual output MUST be shown as evidence alongside the finding. Never assign this label without having actually invoked the tool in this session. |
Static review | Finding is based on reading file content only | Default for pasted content, or when the relevant tool isn't installed or available |
Not verified | A suspected issue that cannot be confirmed without a tool run | Used when a check needs live-cluster or live-provider state the model has no access to |
Tool attempt matrix — in folder/repo mode against a real accessible path only (never for pasted content), attempt the corresponding tool per detected type if it's installed:
| Detected type | Tool attempted | Command shape | Runs automatically? |
|---|---|---|---|
| Terraform | terraform | terraform fmt -check and terraform validate — only if .terraform/ is already initialized; never run init as a side effect | Yes, if installed and initialized |
| Kubernetes manifests | kubectl | kubectl apply --dry-run=server -f <file> if a cluster context is configured, else kubectl apply --dry-run=client -f <file> | Yes, if installed |
| Flux Kustomization/HelmRelease | flux | flux --version gate only — live reconciliation checks stay Static review and belong to /platform-skills:gitops | No — version gate only |
| Terraform (deeper policy) | checkov | Not run automatically — stays a Step 7 handoff to /platform-skills:checkov | No |
| Dockerfile / images | trivy | Not run automatically — requires a built image, stays a Step 7 handoff to /platform-skills:trivy | No |
| GitHub Actions workflow | actionlint | actionlint <file> | Yes, if installed |
| Shell script | shellcheck | shellcheck <file> | Yes, if installed |
If a tool isn't installed, skip execution, label affected findings Static review, and note once per file type: <tool> not found — findings for this file are Static review only.
Hard rule: never claim terraform, kubectl, helm, checkov, trivy, actionlint, or shellcheck was run unless it was actually executed in this session and its output was inspected. If a tool isn't installed or a check requires access the model doesn't have, say so and mark the finding Static review or Not verified — never Tool verified.
Kubernetes Workload (Deployment, StatefulSet, DaemonSet, Job, CronJob)
Correctness
- API version current and not deprecated
selector.matchLabelsmatchestemplate.metadata.labelsexactlyapp.kubernetes.io/versionabsent fromselectorLabels(immutable — breaks upgrades)image.tagis pinned — notlatest,head, or untagged
Security
securityContext.runAsNonRoot: trueon pod and containersecurityContext.readOnlyRootFilesystem: trueon containersecurityContext.allowPrivilegeEscalation: falsecapabilities.drop: [ALL]seccompProfile.type: RuntimeDefaultorLocalhost- No
privileged: true automountServiceAccountToken: falseunless API access is explicitly needed- No
hostNetwork,hostPID,hostIPC serviceAccountNameunset or explicitlydefault, when environment isprod— Medium severity; the default ServiceAccount typically has no meaningful RBAC binding by convention but is an easy target for privilege creep over time. Only fires when the environment (from Q1/--env) isprod.
Operational Safety
resources.requestsset; memory limit set; CPU limit absent (throttling risk)livenessProbeandreadinessProbeboth definedPodDisruptionBudgetexists for HA workloads (minAvailable ≥ 1)- Deployment
strategy.type: RollingUpdatewithmaxSurgeandmaxUnavailableexplicit topologySpreadConstraintsorpodAntiAffinityfor multi-replica workloads
Deprecations
| API | Removed in | Replacement |
|---|---|---|
extensions/v1beta1 Deployment | 1.16 | apps/v1 |
networking.k8s.io/v1beta1 Ingress | 1.22 | networking.k8s.io/v1 |
batch/v1beta1 CronJob | 1.25 | batch/v1 |
policy/v1beta1 PodDisruptionBudget | 1.25 | policy/v1 |
autoscaling/v2beta2 HPA | 1.26 | autoscaling/v2 |
Kubernetes RBAC (Role, ClusterRole, RoleBinding, ClusterRoleBinding)
Correctness
roleRefin a RoleBinding/ClusterRoleBinding resolves to a Role/ClusterRole that exists in scope
Security
- Critical:
verbs: ["*"]on arulesentry without an inline comment justifying it — wildcard verbs on any resource grant unbounded actions (create/delete/patch/etc.), not just read - Critical:
resources: ["*"]without justification — same reasoning, resource-scoped - High:
ClusterRoleBindingto a namespaced ServiceAccount without documented cross-namespace need — cluster-wide binding is broader than almost any workload actually requires
Flux Kustomization
Correctness
sourceRefpoints to an existing GitRepository or OCIRepositorypathexists in the referenced sourceintervalset — warn if < 1m or > 1hdependsOnreferences exist and don't create a cycle
Security
postBuild.substituteFromonly substitutes within this Kustomization's ownpath- Secrets referenced in
substituteFromexist in the same namespace
Operational Safety
prune: true— document blast radius (what gets deleted on removal from Git)wait: true+timeout— prevents silent stuck reconciliationshealthChecksdefined for workloads this Kustomization owns
Flux HelmRelease
Correctness
chart.spec.versionpinned to specific semver — not>=1.0.0valueskeys match the upstream chart schematargetNamespaceandstorageNamespaceare explicit
Security
- No plaintext secrets in
values— usevaluesFromwith a Secret reference
Operational Safety
install.remediation.retriesandupgrade.remediation.retriessetupgrade.remediation.remediateLastFailure: truerollback.cleanupOnFail: truetimeoutexplicit
KEDA Scaler (ScaledObject, ScaledJob)
Correctness
scaleTargetRef.namematches an actual Deployment/StatefulSet in scope (or is documented if out of scope)minReplicaCount≤maxReplicaCount, both present
Operational Safety
- Critical: an HPA already targets the same
scaleTargetRef— KEDA manages the HPA it creates internally; a hand-written HPA on the same target causes the two to fight over replica count. Detected by checking other discovered files in the same folder-mode run forkind: HorizontalPodAutoscalerwith a matchingscaleTargetRef.name. This check only fires in folder/repo mode where both files are discovered together — in single-file mode, note this limitation inline instead of silently skipping it. pollingIntervalandcooldownPeriodexplicit — undocumented KEDA defaults (30s / 300s) surprise most first-time usersfallback.failureThresholdandfallback.replicasset for triggers with an external dependency (avoids scale-to-zero deadlock if the trigger source is unreachable)
Terraform
Correctness
required_providershas version constraints- Module sources include a version ref
- All
variableblocks havetypeanddescription
Security
- No wildcard
Action: "*"orResource: "*"in IAM policies - No hardcoded credentials
- S3:
server_side_encryption_configurationset,block_public_acls = true - RDS:
storage_encrypted = true,deletion_protection = true - Security groups: no
0.0.0.0/0on sensitive ports (22, 3306, 5432) prevent_destroy = trueon stateful resources
Operational Safety
- Remote backend — not local state
lifecycle.create_before_destroyon downtime-causing replacements- Blast radius: list resources that will be replaced (not just updated)
- High:
count.indexused as the effective identity of a resource in a list that can reorder (e.g.count = length(var.subnets)wherevar.subnetsis not itself a stable, order-independent structure) — inserting or removing an early element shifts every later index, causing Terraform to plan destroy+recreate for resources that didn't conceptually change - High:
for_eachiterating over a computed/unstable expression (e.g. a data source result not sorted or keyed deterministically) — same destroy/recreate risk;for_eachkeys must be stable across plans. Suggested fix for both: convert tofor_eachover a map/set keyed by a stable natural identifier (name, ID) rather than a positional index.
GitHub Actions Workflow
Correctness
- Trigger appropriate — warn on
pushtomainwithout branch protection needs:graph is acyclic
Security
- All
uses:actions pinned to full commit SHA permissions:block present — principle of least privilege- No
pull_request_targetwithcheckoutof PR head (injection risk) - OIDC used instead of long-lived PAT where possible
Operational Safety
concurrency:group definedtimeout-minutes:set on long-running jobs- High: a job that deploys to production (matched by job name containing
deploy/prod, or an environment name matchingprod*) lacks anenvironment:block — withoutenvironment:, GitHub's environment protection rules (required reviewers, wait timer, deployment branch restriction) cannot apply, so there is no human gate before a prod deploy runs
Deprecations
| Action | Status | Replacement |
|---|---|---|
actions/checkout@v2/v3 | Deprecated (Node 16 runner) | latest major, SHA-pinned |
actions/setup-node@v2/v3 | Deprecated (Node 16 runner) | latest major, SHA-pinned |
set-output command | Removed | $GITHUB_OUTPUT |
Helm Values
Correctness
- Keys match the chart schema
image.tagpinnedingress.hostsand TLS entries consistent
Security
- No plaintext secrets — use external secrets or
secretKeyRef
Operational Safety
resources.requestsandresources.limitsexplicitreplicaCount ≥ 2for production- HPA and PDB enabled for variable traffic
Dockerfile
Correctness
- Base image uses specific digest or tag — not
latest - Multi-stage build separates build and runtime
Security
USERsets non-root beforeCMD/ENTRYPOINT- Secrets not passed as
ARGorENV .dockerignoreexcludes.git, credentials
Operational Safety
HEALTHCHECKdefined- Base image is minimal (distroless or Alpine)
Shell Script
Correctness
set -euo pipefailat the top- All variables quoted (
"$VAR")
Security
- No
evalwith user input - No
curl | bash - Credentials sourced from environment or secret manager
Operational Safety
trapfor cleanup on EXIT/ERR- Idempotent — safe to run twice
Step 7 — Handoff recommendations
| Situation | Handoff |
|---|---|
| Terraform IaC security scan | /platform-skills:checkov |
| Helm chart scaffold or upgrade diff | /platform-skills:helmchart |
| Container image CVE scan | /platform-skills:trivy |
| Flux reconciliation issue | /platform-skills:gitops |
| Deeper KEDA ScaledObject design or debug | /platform-skills:keda |
| PR diff across many changed files | /platform-skills:pr-review |
Step 8 — Output
Standard mode (default)
Every finding uses the same 9-field block (ID, Severity, Confidence, File, Line, Problem, Impact, Suggested fix, Validation step) — identical across Standard, Bot, and JSON modes; only the wrapper differs. Use Line: N/A when a finding isn't line-scoped (e.g. "missing PodDisruptionBudget" has no single line). ID stays the existing convention: C/H/M/L prefix, numbered sequentially within severity.
Folder / repo scope:
PREFLIGHT — <path> (<N> files checked)
Environment: <prod|staging|dev>
CRITICAL: <count> HIGH: <count> MEDIUM: <count> LOW: <count>
── FILE SUMMARY ──────────────────────────────────────
File Type C H M Verdict
k8s/deployment.yaml K8s workload 1 2 0 BLOCKED
k8s/helmrelease.yaml Flux HR 0 1 1 NEEDS_FIX
terraform/main.tf Terraform 0 0 2 MERGE_READY
.github/workflows/deploy.yaml GHA 0 1 0 NEEDS_FIX
(skipped: k8s/secret.yaml — binary or encrypted, cannot check)
── CRITICAL ──────────────────────────────────────────
ID: C1
Severity: Critical
Confidence: Static review
File: k8s/deployment.yaml
Line: 42
Problem: <one sentence, no line breaks>
Impact: <blast radius — what breaks and who's affected>
Suggested fix: <corrected snippet or exact command>
Validation step: <command to confirm the fix worked>
── HIGH ──────────────────────────────────────────────
ID: H1
Severity: High
Confidence: Tool verified
File: k8s/deployment.yaml
Line: 17
Problem: <one sentence>
Impact: <blast radius>
Suggested fix: <corrected snippet or exact command>
Validation step: <command to confirm the fix worked>
── MEDIUM / LOW ──────────────────────────────────────
ID: M1
Severity: Medium
Confidence: Static review
File: terraform/main.tf
Line: N/A
Problem: <one sentence>
Impact: <blast radius>
Suggested fix: <one-line fix>
Validation step: <command to confirm the fix worked>
── OVERALL VERDICT ───────────────────────────────────
BLOCKED — fix Critical findings before deploying
Rollback plan: <how to undo this change>
Validation steps:
kubectl get pods -n <ns> -w
flux get kustomizations -A
── HANDOFF ───────────────────────────────────────────
<specialised command recommendations>
Single-file scope:
PREFLIGHT CHECK — <file name>
Type: <detected type>
CRITICAL: <count> HIGH: <count> MEDIUM: <count> LOW: <count>
── CRITICAL ──────────────────────────────────────────
ID: C1
Severity: Critical
Confidence: Static review
File: <file name>
Line: <line or N/A>
Problem: <one sentence>
Impact: <blast radius>
Suggested fix: <corrected snippet or exact command>
Validation step: <command to confirm the fix worked>
── VERDICT ───────────────────────────────────────────
BLOCKED / NEEDS_FIX / MERGE_READY
Rollback plan: <how to undo>
Validation steps: <commands>
Bot / PR comment mode (--bot flag)
## 🔍 Platform Skills Preflight
<!-- platform-skills-preflight -->
### Result: {MERGE_READY | NEEDS_FIX | BLOCKED}
**Scope:** <path> — <N> files
**Environment:** <prod|staging|dev>
| File | Type | C | H | M | Verdict |
|------|------|---|---|---|---------|
| deployment.yaml | K8s workload | 1 | 2 | 0 | 🔴 BLOCKED |
| helmrelease.yaml | Flux HR | 0 | 1 | 1 | 🟡 NEEDS_FIX |
#### Critical issues
<!-- one subsection per Critical finding, rendered as the same 9-field block used in Standard mode: ID, Severity, Confidence, File, Line, Problem, Impact, Suggested fix, Validation step -->
#### High findings
<!-- one subsection per High finding, same 9-field block -->
#### Rollback plan
#### Validation steps
```bash
# commands to verify after apply
Generated by platform-skills
**Updating existing comment:**
```bash
COMMENT_ID=$(gh api repos/{owner}/{repo}/issues/{pr}/comments --paginate \
--jq '.[] | select(.body | contains("platform-skills-preflight")) | .id' | head -1)
if [ -n "$COMMENT_ID" ]; then
gh api --method PATCH "repos/{owner}/{repo}/issues/comments/$COMMENT_ID" \
--field body="$REVIEW_BODY"
else
gh pr comment {pr} --body "$REVIEW_BODY"
fi
Result values:
BLOCKED— one or more Critical findings in any fileNEEDS_FIX— no Critical, but one or more High findingsMERGE_READY— Medium/Low only, or no findings
JSON mode (--json flag)
--json output is a single JSON object, printed with no other prose before or after it — pipeable directly to jq:
{
"verdict": "BLOCKED",
"environment": "prod",
"scope": "k8s/",
"filesChecked": 4,
"counts": { "critical": 1, "high": 2, "medium": 1, "low": 0 },
"files": [
{ "file": "k8s/deployment.yaml", "type": "K8s workload", "critical": 1, "high": 1, "medium": 0, "low": 0, "verdict": "BLOCKED" }
],
"findings": [
{
"id": "C1",
"severity": "Critical",
"confidence": "Static review",
"file": "k8s/deployment.yaml",
"line": 42,
"problem": "...",
"impact": "...",
"suggestedFix": "...",
"validationStep": "..."
}
],
"rollbackPlan": "kubectl rollout undo deployment/app -n prod",
"validationSteps": ["kubectl get pods -n prod -w", "flux get kustomizations -A"],
"handoff": ["/platform-skills:checkov"]
}
line is null (not the string "N/A") when a finding isn't line-scoped, so consumers can type-check it.
rollbackPlan and validationSteps mirror Standard mode's aggregate rollback/validation block; handoff mirrors Step 7's recommendations for this scope — empty array if none apply.
If --bot and --json are both passed, --json silently wins (see Step 0) — the JSON object is the entire output, with no bot-mode markdown mixed in.
Step 9 — Exit code (CI mode only)
Applies only when the interview was actually skipped per Step 0's rule — i.e., --no-interview was passed, or every question happened to already be answered by other flags (--env + --focus + --bot/--json, with Q2 omitted as specified in Step 0). Passing --changed-only or --json alone does not by itself trigger this — if the interview still ran because some question was left unanswered, the run is interactive and no exit call is made, regardless of which flags were present.
When it does apply, after printing output:
- Verdict
BLOCKED→ runexit 1 - Verdict
NEEDS_FIXorMERGE_READY→ runexit 0
Only BLOCKED (at least one Critical finding) fails the pipeline — this matches the existing verdict semantics, where BLOCKED is already the documented hard merge-gate.
In fully interactive mode (no flags), no exit call is made — behavior is unchanged from before this step existed.
Common mistakes
- Marking a finding
Tool verifiedwithout actually running the tool — the user trusts an unverified claim as if a real tool confirmed it. Always show the command and output snippet as evidence for everyTool verifiedfinding; never assign the label without it. - Running
terraform initas a side effect of a validate check — preflight silently mutates.terraform/state or downloads providers on a read-only review. Only runterraform validate/fmt -checkwhen.terraform/already exists, otherwise markStatic reviewwith a note. --changed-onlysilently checking nothing when not in a git repo — the user thinks the whole folder was checked when in fact 0 files were. Print the explicit fallback note when--changed-onlycan't resolve a base ref, and run full folder-mode discovery instead.- Treating
NEEDS_FIXas a CI failure — pipelines block on High findings that don't warrant a hard gate. The exit-code contract (Step 9) only fails onBLOCKED. --jsonand--botboth passed — ambiguous which format renders.--jsonwins silently with no warning printed, keeping output strictly parseable.- KEDA/HPA conflict check false-negative in single-file mode — the conflicting HPA lives in a different file never seen by this run. The conflict check only fires in folder/repo mode where both files are discovered together; single-file mode notes this limitation inline.