@biffo/cli 0.217.7 → 0.217.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI runner for the destructive-plan guard (issue #387).
4
+ *
5
+ * Reads a `terraform show -json` plan and refuses it when it would destroy
6
+ * stateful infrastructure without an `Infra-Destroy:` trailer authorising it.
7
+ *
8
+ * Runs in the **Plan** job, before Apply, so a refusal costs nothing: no
9
+ * infrastructure has been touched when it fires. Apply runs with
10
+ * `--auto-approve`, so this is the only point at which a human decision can
11
+ * still be required.
12
+ *
13
+ * No dependencies, so it runs on bare node without a pnpm install — see
14
+ * destructive-plan.mjs for why.
15
+ *
16
+ * Usage: node check-destructive-plan.mjs <plan.json>
17
+ */
18
+
19
+ import { execFileSync } from 'node:child_process'
20
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs'
21
+ import { DESTROY_TRAILER, checkDestructivePlan } from './destructive-plan.mjs'
22
+
23
+ const BOLD = ''
24
+ const DIM = ''
25
+ const RED = ''
26
+ const OFF = ''
27
+
28
+ /**
29
+ * Commit messages this deploy is carrying.
30
+ *
31
+ * A push build has no PR body, so the commit message is the only place an
32
+ * author can speak to CI. `github.event.before` bounds it to what this push
33
+ * actually added; on a first push or a force-push it is all-zeroes, and then
34
+ * the tip commit alone is the honest answer rather than the entire history —
35
+ * an `Infra-Destroy:` trailer from six months ago must not authorise today.
36
+ */
37
+ function pushCommitMessages() {
38
+ const before = process.env.GITHUB_EVENT_BEFORE
39
+ const head = process.env.GITHUB_SHA || 'HEAD'
40
+ const args =
41
+ before && !/^0+$/.test(before)
42
+ ? ['log', '--format=%B', `${before}..${head}`]
43
+ : ['log', '--format=%B', '-1']
44
+ try {
45
+ return execFileSync('git', args, { encoding: 'utf8' })
46
+ } catch {
47
+ // A shallow clone may not contain `before`. Fall back to the tip rather
48
+ // than treating an unreadable range as "no trailer", which would block a
49
+ // legitimately authorised deploy.
50
+ try {
51
+ return execFileSync('git', ['log', '--format=%B', '-1'], { encoding: 'utf8' })
52
+ } catch {
53
+ return ''
54
+ }
55
+ }
56
+ }
57
+
58
+ function main() {
59
+ const planPath = process.argv[2]
60
+ if (!planPath) {
61
+ console.error('Usage: check-destructive-plan.mjs <plan.json>')
62
+ process.exit(2)
63
+ }
64
+ if (!existsSync(planPath)) {
65
+ // Fail closed. A missing plan means the guard cannot see what is about to
66
+ // be applied, which is not the same as there being nothing to see.
67
+ console.error(`::error::Plan file ${planPath} not found — refusing to apply unexamined.`)
68
+ process.exit(2)
69
+ }
70
+
71
+ let plan
72
+ try {
73
+ plan = JSON.parse(readFileSync(planPath, 'utf8'))
74
+ } catch (err) {
75
+ console.error(`::error::Could not parse ${planPath}: ${err.message}`)
76
+ process.exit(2)
77
+ }
78
+
79
+ const result = checkDestructivePlan(plan, pushCommitMessages())
80
+
81
+ if (result.destructive.length === 0) {
82
+ console.log('✓ destructive-plan guard: no stateful resource is destroyed by this plan.')
83
+ return
84
+ }
85
+
86
+ const lines = result.destructive.map(
87
+ (c) => ` ${c.address} ${DIM}(${c.replacement ? 'replaced' : 'destroyed'})${OFF}`,
88
+ )
89
+
90
+ if (!result.blocked) {
91
+ console.log(
92
+ `⚠ destructive-plan guard: allowed by ${DESTROY_TRAILER}: ${result.acknowledgedReason}\n` +
93
+ lines.join('\n'),
94
+ )
95
+ summarise(result.destructive, result.acknowledgedReason)
96
+ return
97
+ }
98
+
99
+ console.error(`
100
+ ${RED}${BOLD}✗ This plan destroys stateful infrastructure.${OFF}
101
+
102
+ ${lines.join('\n')}
103
+
104
+ ${BOLD}Why this is blocked${OFF}
105
+ These resources hold data no re-apply can recreate — rows, users, objects.
106
+ Everything else Terraform rebuilds from this repo; these it cannot, because
107
+ nothing in this repo contains the data. Apply runs with --auto-approve, so
108
+ without this check the destroy would happen with nobody having seen the plan.
109
+
110
+ A ${BOLD}replacement${OFF} destroys the original just as surely as a delete, and most
111
+ incidents of this kind are replacements: an attribute that forces one changed
112
+ somewhere far from the resource itself.
113
+
114
+ ${BOLD}If it is deliberate${OFF}
115
+ Say so in the commit message and this passes:
116
+
117
+ ${DIM}${DESTROY_TRAILER}: <what is destroyed, and why that is acceptable>${OFF}
118
+
119
+ Take a backup first if the data matters — in dev and staging there may be no
120
+ final snapshot to fall back on.
121
+ `)
122
+ process.exit(1)
123
+ }
124
+
125
+ /** Record an authorised destroy in the job summary, so it is visible without
126
+ * reading the log — the log being exactly what nobody reads. */
127
+ function summarise(destructive, reason) {
128
+ const path = process.env.GITHUB_STEP_SUMMARY
129
+ if (!path) return
130
+ const rows = destructive.map(
131
+ (c) => `- \`${c.address}\` (${c.replacement ? 'replaced' : 'destroyed'})`,
132
+ )
133
+ appendFileSync(
134
+ path,
135
+ `### ⚠️ Destructive plan authorised\n\n${destructive.length} stateful resource(s) will be destroyed:\n\n` +
136
+ `${rows.join('\n')}\n\n**${DESTROY_TRAILER}:** ${reason || ''}\n`,
137
+ )
138
+ }
139
+
140
+ main()
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Decide whether a Terraform plan destroys stateful infrastructure (issue #387).
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `deploy-infra.yml` runs `terraform apply -auto-approve`. Nobody reads the
7
+ * plan. A user-pool replacement or a database rebuild reads as an ordinary
8
+ * `-/+ resource` line among dozens, and the deploy reports success either way.
9
+ *
10
+ * The exposure is not theoretical. Three attributes on `aws_db_instance` —
11
+ * `identifier`, `db_name`, `username` — are derived from `project_name` and
12
+ * force **replacement**, and in dev and staging `deletion_protection` is false.
13
+ * So renaming the project, a plausible and innocuous-looking edit, plans a
14
+ * destroy of the database, and CI applies it with nobody having seen the plan.
15
+ *
16
+ * A Cognito pool replacement of exactly this shape landed unattended during the
17
+ * 0.50.0 upgrade. It was intended and documented; nothing about the mechanism
18
+ * required it to be.
19
+ *
20
+ * ## Why plain .mjs rather than TypeScript
21
+ *
22
+ * This runs in the **Plan** job, which sets up Terraform and AWS credentials
23
+ * and nothing else — no pnpm, no node_modules. Adding a dependency install to
24
+ * three jobs to run one guard is a poor trade, so the guard has no
25
+ * dependencies and runs on bare node.
26
+ *
27
+ * It is still tested: `cli/src/lib/destructive-plan.test.ts` imports this file
28
+ * directly, the same arrangement `packaged-root-assets.mjs` already uses. The
29
+ * logic has one home, not two.
30
+ */
31
+
32
+ /**
33
+ * Resource types whose destruction loses data that no re-apply can recreate.
34
+ *
35
+ * Deliberately a small, explicit list rather than a heuristic. A guard that
36
+ * fires on everything gets its acknowledgement trailer added by reflex, and
37
+ * then it protects nothing — so it fires only where a human really should stop.
38
+ *
39
+ * Everything absent from this list is configuration: Terraform rebuilds a
40
+ * Lambda, a security group or an IAM role from this repo. Nothing in this repo
41
+ * contains the rows.
42
+ */
43
+ export const STATEFUL_RESOURCE_TYPES = [
44
+ // The data itself.
45
+ 'aws_db_instance',
46
+ 'aws_rds_cluster',
47
+ 'aws_rds_cluster_instance',
48
+ 'aws_dynamodb_table',
49
+ 'aws_efs_file_system',
50
+ 'aws_elasticache_cluster',
51
+ 'aws_elasticache_replication_group',
52
+ // Identities. Replacing a pool deletes every user and every `sub` with it,
53
+ // orphaning anything keyed on cognito_sub (ADR-0012).
54
+ 'aws_cognito_user_pool',
55
+ // Objects. A bucket destroy takes its contents; for state and media buckets
56
+ // that is the entire point of the bucket.
57
+ 'aws_s3_bucket',
58
+ // Delegation. A hosted zone's NS assignment is not in this repo — AWS
59
+ // assigns it at creation and the registrar is told about it out of band.
60
+ // Replacing the zone issues new nameservers, so recovery is a registrar
61
+ // change plus propagation, not a re-apply. The zone's records ARE
62
+ // Terraform-managed and would come back; the delegation is what would not.
63
+ //
64
+ // aws_acm_certificate was considered and rejected: a replacement re-issues
65
+ // and DNS-validates automatically while the zone underneath it stands, so
66
+ // it passes the same test that keeps this list small — Terraform rebuilds
67
+ // it from this repo, unattended.
68
+ 'aws_route53_zone',
69
+ ]
70
+
71
+ /**
72
+ * Trailer, on its own line in a commit message, authorising this plan's
73
+ * destructive changes. Mirrors `Core-Divergence:` — deliberate, and recorded in
74
+ * history rather than argued about afterwards.
75
+ */
76
+ export const DESTROY_TRAILER = 'Infra-Destroy'
77
+
78
+ /**
79
+ * Extract the reason from an `Infra-Destroy:` trailer, or null.
80
+ *
81
+ * Anchored per line, and comment lines are dropped, for the same reason
82
+ * `Core-Divergence:` is: the words appearing inside prose are not an
83
+ * authorisation to delete a database.
84
+ */
85
+ export function parseDestroyTrailer(commitMessage) {
86
+ const body = String(commitMessage || '')
87
+ .split('\n')
88
+ .filter((line) => !line.trimStart().startsWith('#'))
89
+ .join('\n')
90
+ const match = /^Infra-Destroy:[ \t]*(\S.*?)[ \t]*$/m.exec(body)
91
+ return match ? match[1] : null
92
+ }
93
+
94
+ /**
95
+ * Classify a `terraform show -json` plan.
96
+ *
97
+ * `replace` counts as destruction: Terraform encodes it as
98
+ * `["delete","create"]` (or the reverse under create_before_destroy), and a
99
+ * replacement destroys the original exactly as surely as a bare delete. Most
100
+ * incidents in this class ARE replacements — nobody runs `terraform destroy` by
101
+ * accident; they change an attribute that forces one, far from the resource.
102
+ */
103
+ export function checkDestructivePlan(
104
+ plan,
105
+ commitMessage = '',
106
+ statefulTypes = STATEFUL_RESOURCE_TYPES,
107
+ ) {
108
+ const types = new Set(statefulTypes)
109
+ const destructive = []
110
+
111
+ for (const change of (plan && plan.resource_changes) || []) {
112
+ const actions = (change.change && change.change.actions) || []
113
+ if (!actions.includes('delete')) continue
114
+ if (!types.has(change.type)) continue
115
+ destructive.push({
116
+ address: change.address,
117
+ type: change.type,
118
+ replacement: actions.includes('create'),
119
+ })
120
+ }
121
+
122
+ const acknowledgedReason = parseDestroyTrailer(commitMessage)
123
+ return {
124
+ destructive,
125
+ acknowledgedReason,
126
+ blocked: destructive.length > 0 && acknowledgedReason === null,
127
+ }
128
+ }
@@ -85,8 +85,12 @@ jobs:
85
85
  EOF
86
86
  - run: terraform init -input=false -backend-config=backend.hcl
87
87
  timeout-minutes: 10
88
- - run: terraform apply -input=false -auto-approve
89
- timeout-minutes: 45
88
+ # Plan to a FILE so the guard below reads the plan that is actually
89
+ # applied. Variables belong here rather than on the apply: a saved plan
90
+ # has them baked in, and `terraform apply tfplan` takes no -var at all.
91
+ - name: Plan
92
+ run: terraform plan -input=false -no-color -out=tfplan
93
+ timeout-minutes: 20
90
94
  env:
91
95
  TF_VAR_project_name: ${{ vars.PROJECT_NAME }}
92
96
  TF_VAR_environment: ${{ needs.resolve-environment.outputs.environment }}
@@ -100,6 +104,25 @@ jobs:
100
104
  # variables.tf. Re-run this workflow (workflow_dispatch) once
101
105
  # that PR merges and this var is set, to add the bucket policy.
102
106
  TF_VAR_parent_cloudfront_distribution_arn: ${{ vars.PARENT_CLOUDFRONT_DISTRIBUTION_ARN || '' }}
107
+ # Refuse a plan that destroys stateful infrastructure without an
108
+ # `Infra-Destroy:` trailer authorising it (#387, reach fixed by #1123).
109
+ # Apply is --auto-approve and nobody reads the plan, so this is the only
110
+ # point at which a human decision can still be required — and it costs
111
+ # nothing when it fires, because no infrastructure has been touched yet.
112
+ #
113
+ # Runs on bare `node`: the script has no dependencies precisely so it can
114
+ # execute in a Terraform job that installs no JS toolchain. $GITHUB_WORKSPACE
115
+ # because this job's default working-directory is `infra/`.
116
+ - name: Destructive-plan guard
117
+ run: |
118
+ terraform show -json tfplan > plan.json
119
+ node "$GITHUB_WORKSPACE/scripts/check-destructive-plan.mjs" plan.json
120
+ env:
121
+ GITHUB_EVENT_BEFORE: ${{ github.event.before }}
122
+ # Applies the plan just checked, not a fresh one — a re-plan here could
123
+ # differ from what the guard saw.
124
+ - run: terraform apply -input=false -auto-approve tfplan
125
+ timeout-minutes: 45
103
126
  - name: Export outputs to GitHub Actions variables
104
127
  env:
105
128
  GH_TOKEN: ${{ secrets.SIBLING_GITHUB_TOKEN }}
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CI runner for the destructive-plan guard (issue #387).
4
+ *
5
+ * Reads a `terraform show -json` plan and refuses it when it would destroy
6
+ * stateful infrastructure without an `Infra-Destroy:` trailer authorising it.
7
+ *
8
+ * Runs in the **Plan** job, before Apply, so a refusal costs nothing: no
9
+ * infrastructure has been touched when it fires. Apply runs with
10
+ * `--auto-approve`, so this is the only point at which a human decision can
11
+ * still be required.
12
+ *
13
+ * No dependencies, so it runs on bare node without a pnpm install — see
14
+ * destructive-plan.mjs for why.
15
+ *
16
+ * Usage: node check-destructive-plan.mjs <plan.json>
17
+ */
18
+
19
+ import { execFileSync } from 'node:child_process'
20
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs'
21
+ import { DESTROY_TRAILER, checkDestructivePlan } from './destructive-plan.mjs'
22
+
23
+ const BOLD = ''
24
+ const DIM = ''
25
+ const RED = ''
26
+ const OFF = ''
27
+
28
+ /**
29
+ * Commit messages this deploy is carrying.
30
+ *
31
+ * A push build has no PR body, so the commit message is the only place an
32
+ * author can speak to CI. `github.event.before` bounds it to what this push
33
+ * actually added; on a first push or a force-push it is all-zeroes, and then
34
+ * the tip commit alone is the honest answer rather than the entire history —
35
+ * an `Infra-Destroy:` trailer from six months ago must not authorise today.
36
+ */
37
+ function pushCommitMessages() {
38
+ const before = process.env.GITHUB_EVENT_BEFORE
39
+ const head = process.env.GITHUB_SHA || 'HEAD'
40
+ const args =
41
+ before && !/^0+$/.test(before)
42
+ ? ['log', '--format=%B', `${before}..${head}`]
43
+ : ['log', '--format=%B', '-1']
44
+ try {
45
+ return execFileSync('git', args, { encoding: 'utf8' })
46
+ } catch {
47
+ // A shallow clone may not contain `before`. Fall back to the tip rather
48
+ // than treating an unreadable range as "no trailer", which would block a
49
+ // legitimately authorised deploy.
50
+ try {
51
+ return execFileSync('git', ['log', '--format=%B', '-1'], { encoding: 'utf8' })
52
+ } catch {
53
+ return ''
54
+ }
55
+ }
56
+ }
57
+
58
+ function main() {
59
+ const planPath = process.argv[2]
60
+ if (!planPath) {
61
+ console.error('Usage: check-destructive-plan.mjs <plan.json>')
62
+ process.exit(2)
63
+ }
64
+ if (!existsSync(planPath)) {
65
+ // Fail closed. A missing plan means the guard cannot see what is about to
66
+ // be applied, which is not the same as there being nothing to see.
67
+ console.error(`::error::Plan file ${planPath} not found — refusing to apply unexamined.`)
68
+ process.exit(2)
69
+ }
70
+
71
+ let plan
72
+ try {
73
+ plan = JSON.parse(readFileSync(planPath, 'utf8'))
74
+ } catch (err) {
75
+ console.error(`::error::Could not parse ${planPath}: ${err.message}`)
76
+ process.exit(2)
77
+ }
78
+
79
+ const result = checkDestructivePlan(plan, pushCommitMessages())
80
+
81
+ if (result.destructive.length === 0) {
82
+ console.log('✓ destructive-plan guard: no stateful resource is destroyed by this plan.')
83
+ return
84
+ }
85
+
86
+ const lines = result.destructive.map(
87
+ (c) => ` ${c.address} ${DIM}(${c.replacement ? 'replaced' : 'destroyed'})${OFF}`,
88
+ )
89
+
90
+ if (!result.blocked) {
91
+ console.log(
92
+ `⚠ destructive-plan guard: allowed by ${DESTROY_TRAILER}: ${result.acknowledgedReason}\n` +
93
+ lines.join('\n'),
94
+ )
95
+ summarise(result.destructive, result.acknowledgedReason)
96
+ return
97
+ }
98
+
99
+ console.error(`
100
+ ${RED}${BOLD}✗ This plan destroys stateful infrastructure.${OFF}
101
+
102
+ ${lines.join('\n')}
103
+
104
+ ${BOLD}Why this is blocked${OFF}
105
+ These resources hold data no re-apply can recreate — rows, users, objects.
106
+ Everything else Terraform rebuilds from this repo; these it cannot, because
107
+ nothing in this repo contains the data. Apply runs with --auto-approve, so
108
+ without this check the destroy would happen with nobody having seen the plan.
109
+
110
+ A ${BOLD}replacement${OFF} destroys the original just as surely as a delete, and most
111
+ incidents of this kind are replacements: an attribute that forces one changed
112
+ somewhere far from the resource itself.
113
+
114
+ ${BOLD}If it is deliberate${OFF}
115
+ Say so in the commit message and this passes:
116
+
117
+ ${DIM}${DESTROY_TRAILER}: <what is destroyed, and why that is acceptable>${OFF}
118
+
119
+ Take a backup first if the data matters — in dev and staging there may be no
120
+ final snapshot to fall back on.
121
+ `)
122
+ process.exit(1)
123
+ }
124
+
125
+ /** Record an authorised destroy in the job summary, so it is visible without
126
+ * reading the log — the log being exactly what nobody reads. */
127
+ function summarise(destructive, reason) {
128
+ const path = process.env.GITHUB_STEP_SUMMARY
129
+ if (!path) return
130
+ const rows = destructive.map(
131
+ (c) => `- \`${c.address}\` (${c.replacement ? 'replaced' : 'destroyed'})`,
132
+ )
133
+ appendFileSync(
134
+ path,
135
+ `### ⚠️ Destructive plan authorised\n\n${destructive.length} stateful resource(s) will be destroyed:\n\n` +
136
+ `${rows.join('\n')}\n\n**${DESTROY_TRAILER}:** ${reason || ''}\n`,
137
+ )
138
+ }
139
+
140
+ main()
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Decide whether a Terraform plan destroys stateful infrastructure (issue #387).
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * `deploy-infra.yml` runs `terraform apply -auto-approve`. Nobody reads the
7
+ * plan. A user-pool replacement or a database rebuild reads as an ordinary
8
+ * `-/+ resource` line among dozens, and the deploy reports success either way.
9
+ *
10
+ * The exposure is not theoretical. Three attributes on `aws_db_instance` —
11
+ * `identifier`, `db_name`, `username` — are derived from `project_name` and
12
+ * force **replacement**, and in dev and staging `deletion_protection` is false.
13
+ * So renaming the project, a plausible and innocuous-looking edit, plans a
14
+ * destroy of the database, and CI applies it with nobody having seen the plan.
15
+ *
16
+ * A Cognito pool replacement of exactly this shape landed unattended during the
17
+ * 0.50.0 upgrade. It was intended and documented; nothing about the mechanism
18
+ * required it to be.
19
+ *
20
+ * ## Why plain .mjs rather than TypeScript
21
+ *
22
+ * This runs in the **Plan** job, which sets up Terraform and AWS credentials
23
+ * and nothing else — no pnpm, no node_modules. Adding a dependency install to
24
+ * three jobs to run one guard is a poor trade, so the guard has no
25
+ * dependencies and runs on bare node.
26
+ *
27
+ * It is still tested: `cli/src/lib/destructive-plan.test.ts` imports this file
28
+ * directly, the same arrangement `packaged-root-assets.mjs` already uses. The
29
+ * logic has one home, not two.
30
+ */
31
+
32
+ /**
33
+ * Resource types whose destruction loses data that no re-apply can recreate.
34
+ *
35
+ * Deliberately a small, explicit list rather than a heuristic. A guard that
36
+ * fires on everything gets its acknowledgement trailer added by reflex, and
37
+ * then it protects nothing — so it fires only where a human really should stop.
38
+ *
39
+ * Everything absent from this list is configuration: Terraform rebuilds a
40
+ * Lambda, a security group or an IAM role from this repo. Nothing in this repo
41
+ * contains the rows.
42
+ */
43
+ export const STATEFUL_RESOURCE_TYPES = [
44
+ // The data itself.
45
+ 'aws_db_instance',
46
+ 'aws_rds_cluster',
47
+ 'aws_rds_cluster_instance',
48
+ 'aws_dynamodb_table',
49
+ 'aws_efs_file_system',
50
+ 'aws_elasticache_cluster',
51
+ 'aws_elasticache_replication_group',
52
+ // Identities. Replacing a pool deletes every user and every `sub` with it,
53
+ // orphaning anything keyed on cognito_sub (ADR-0012).
54
+ 'aws_cognito_user_pool',
55
+ // Objects. A bucket destroy takes its contents; for state and media buckets
56
+ // that is the entire point of the bucket.
57
+ 'aws_s3_bucket',
58
+ // Delegation. A hosted zone's NS assignment is not in this repo — AWS
59
+ // assigns it at creation and the registrar is told about it out of band.
60
+ // Replacing the zone issues new nameservers, so recovery is a registrar
61
+ // change plus propagation, not a re-apply. The zone's records ARE
62
+ // Terraform-managed and would come back; the delegation is what would not.
63
+ //
64
+ // aws_acm_certificate was considered and rejected: a replacement re-issues
65
+ // and DNS-validates automatically while the zone underneath it stands, so
66
+ // it passes the same test that keeps this list small — Terraform rebuilds
67
+ // it from this repo, unattended.
68
+ 'aws_route53_zone',
69
+ ]
70
+
71
+ /**
72
+ * Trailer, on its own line in a commit message, authorising this plan's
73
+ * destructive changes. Mirrors `Core-Divergence:` — deliberate, and recorded in
74
+ * history rather than argued about afterwards.
75
+ */
76
+ export const DESTROY_TRAILER = 'Infra-Destroy'
77
+
78
+ /**
79
+ * Extract the reason from an `Infra-Destroy:` trailer, or null.
80
+ *
81
+ * Anchored per line, and comment lines are dropped, for the same reason
82
+ * `Core-Divergence:` is: the words appearing inside prose are not an
83
+ * authorisation to delete a database.
84
+ */
85
+ export function parseDestroyTrailer(commitMessage) {
86
+ const body = String(commitMessage || '')
87
+ .split('\n')
88
+ .filter((line) => !line.trimStart().startsWith('#'))
89
+ .join('\n')
90
+ const match = /^Infra-Destroy:[ \t]*(\S.*?)[ \t]*$/m.exec(body)
91
+ return match ? match[1] : null
92
+ }
93
+
94
+ /**
95
+ * Classify a `terraform show -json` plan.
96
+ *
97
+ * `replace` counts as destruction: Terraform encodes it as
98
+ * `["delete","create"]` (or the reverse under create_before_destroy), and a
99
+ * replacement destroys the original exactly as surely as a bare delete. Most
100
+ * incidents in this class ARE replacements — nobody runs `terraform destroy` by
101
+ * accident; they change an attribute that forces one, far from the resource.
102
+ */
103
+ export function checkDestructivePlan(
104
+ plan,
105
+ commitMessage = '',
106
+ statefulTypes = STATEFUL_RESOURCE_TYPES,
107
+ ) {
108
+ const types = new Set(statefulTypes)
109
+ const destructive = []
110
+
111
+ for (const change of (plan && plan.resource_changes) || []) {
112
+ const actions = (change.change && change.change.actions) || []
113
+ if (!actions.includes('delete')) continue
114
+ if (!types.has(change.type)) continue
115
+ destructive.push({
116
+ address: change.address,
117
+ type: change.type,
118
+ replacement: actions.includes('create'),
119
+ })
120
+ }
121
+
122
+ const acknowledgedReason = parseDestroyTrailer(commitMessage)
123
+ return {
124
+ destructive,
125
+ acknowledgedReason,
126
+ blocked: destructive.length > 0 && acknowledgedReason === null,
127
+ }
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.217.7",
3
+ "version": "0.217.8",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",