Skip to content

Guard repository destroys instead of blocking them outright - #95

Merged
NWarila merged 1 commit into
mainfrom
feat/never-destroy-repositories
Jul 20, 2026
Merged

Guard repository destroys instead of blocking them outright#95
NWarila merged 1 commit into
mainfrom
feat/never-destroy-repositories

Conversation

@NWarila

@NWarila NWarila commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

A Terraform delete of github_repository is not necessarily a deletion.

archive_on_destroy defaults to true, and the provider's delete path sets archived = true and calls Edit rather than Delete — confirmed in the provider source:

archiveOnDestroy := d.Get("archive_on_destroy").(bool)
if archiveOnDestroy {
    if d.Get("archived").(bool) { return nil }   // already archived → no-op
    d.Set("archived", true)
    client.Repositories.Edit(ctx, owner, repoName, repoReq)   // ARCHIVE
}

So removing a definition archives the repository. The archive-then-delete-in-UI retirement path already existed.

Why prevent_destroy is not used

It would block the archive too — replacing a working retirement path with a hard plan error. It also can't help decide the interesting cases: it's a lifecycle meta-argument, must be a literal, and cannot read the before-state.

What the guard does

The plan shows delete for both an archive and a real deletion, so the plan alone can't distinguish them. The guard reads the before-state and splits the cases:

Case Why
refused archive_on_destroy = false the only genuinely irreversible path. Nothing sets it today
refused repository not already archived archiving as a side effect of a definition going missing — a bug signature, not a retirement
allowed already-archived repo leaving config provider no-ops, state forgets it. Blocking this would make retirement impossible
reported non-repository destroys rulesets and environments churn legitimately, but a destroy shouldn't scroll past unread

The case that matters in practice

On the-hero-wars-guys, a dotfile-blind glob (fixed in #94) dropped a definitions file and the plan proposed to archive .githubarchived=false, public, the org profile repo. Archiving it makes it read-only and stops its Actions org-wide. Reversible, but an outage during a ramp.

That's the second rule, and it costs nothing: it only insists an archive be deliberate rather than a consequence of a file going missing.

Correction to this PR's earlier revision

The first version added prevent_destroy and described the incident as a repository deletion with no safety net. Both were wrong — archive_on_destroy was already the default, so it was an archive, and it was already a safety net.

Testing

tools/test_destroy_guard.py extracts the guard's Python from the workflow and executes it, so the tested code is the shipped code rather than a copy that can drift. 12 tests, including the real incident plan shape.

Two edge cases decide which way ambiguity falls, deliberately in opposite directions:

  • absent archive_on_destroy → provider default applies → allow (assuming deletion would block ordinary retirements)
  • null before-state → archived state unknown → refuse

Gate: 12/12 guard tests, 93/93 terraform tests, fmt and validate clean.

@NWarila NWarila changed the title Never destroy repositories: prevent_destroy plus a pipeline guard Guard repository destroys instead of blocking them outright Jul 20, 2026
A Terraform 'delete' of github_repository is not necessarily a deletion.
archive_on_destroy defaults to TRUE, and the provider's delete path sets
archived=true and calls Edit rather than Delete - confirmed in the provider
source. So removing a definition ARCHIVES the repository, and the archive-then-
delete-in-UI retirement path already existed.

prevent_destroy is therefore NOT added: it would block the archive too,
replacing a working retirement path with a hard plan error. It also cannot help
decide the interesting cases, being a lifecycle meta-argument that must be a
literal and cannot read the before-state.

The plan shows 'delete' for both an archive and a real deletion, so the plan
alone cannot tell them apart. The guard reads the before-state and splits them:

  REFUSED  archive_on_destroy = false       the only irreversible path. Nothing
                                            sets it today.
  REFUSED  repository not already archived  archiving as a SIDE EFFECT of a
                                            definition going missing - a bug
                                            signature, not a retirement.
  ALLOWED  already-archived repo leaving    provider no-ops, state forgets it.
                                            The last step of a deliberate
                                            retirement; blocking it would make
                                            retirement impossible.
  REPORTED non-repository destroys          rulesets and environments churn
                                            legitimately, but a destroy should
                                            never scroll past unread.

The premature-archive rule is the one that matters. On the-hero-wars-guys a
dotfile-blind glob dropped a definitions file and the plan proposed to archive
.github - archived=false, public, the org profile repo. Archiving it would have
made it read-only and stopped its Actions org-wide.

tools/test_destroy_guard.py extracts the guard's python from the workflow and
runs it, so the tested code is the shipped code rather than a copy that can
drift. 12 tests including the real incident plan shape, plus two edge cases that
decide ambiguity in opposite directions: an absent archive_on_destroy falls to
the provider default (allow), a null before-state is unknown (refuse).

Gate: 12/12 destroy-guard, assemble-guard passing, 93/93 terraform, fmt clean.
@NWarila
NWarila force-pushed the feat/never-destroy-repositories branch from ac96824 to d1e816a Compare July 20, 2026 18:41
@NWarila
NWarila merged commit d4ab1e9 into main Jul 20, 2026
13 checks passed
@NWarila
NWarila deleted the feat/never-destroy-repositories branch July 20, 2026 18:44
NWarila added a commit to nwarila-platform/github-terraform-runner that referenced this pull request Jul 20, 2026
Picks up the two fixes made while working on the-hero-wars-guys. This
runner is currently **two commits behind** and has **no destroy guard**
— while holding org-admin credentials for 21 repositories.

| PR | Fix |
|---|---|
| nwarila-platform/github-terraform-framework#94 | assembly guard used
`compgen -G`, which skips dotfiles — a definitions directory containing
only dotfiles read as empty and its repositories silently stopped being
declared |
| nwarila-platform/github-terraform-framework#95 | destroy guard:
refuses genuine deletion (`archive_on_destroy=false`) and refuses
archiving a repository that was not deliberately archived first, while
still allowing a deliberate retirement to complete |

## Was this runner exposed to #94?

Not in practice — `terraform/public/` holds 19 non-dotfile YAMLs, so the
guard passed.

It was closer than that sounds. `terraform/private/` contains **only
`.gitkeep`**, so it reads as empty under the old guard, and it is safe
only because the S3 fetch populates that directory *before* assembly
runs. A change to that step ordering, or moving the last public
definition, would have exposed it.

## Acceptance

- [ ] Real-state plan on merge is unchanged — expected `No changes`,
since this is CI-only
- [ ] Destroy guard reports "No repository destroys in this plan"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant