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
29 changes: 29 additions & 0 deletions .github/workflows/access.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,26 @@ jobs:
echo "high=false" >> "$GITHUB_OUTPUT"
echo "### Severity: ordinary, no guardrail file changed" > severity.md
fi

# A replacement destroys and recreates a resource under one address,
# and on IAM that changes an ARN underneath whatever trusts it. The
# delta lists them, and this names them where a reviewer cannot miss
# them, because "approve the change" means the change including the
# part where something stops existing (decision 63).
replacements=$(jq -r '.replacements[]? | " " + .address + " forced by " + .forced_by' delta.json)
if [ -n "$replacements" ]; then
echo "replacement=true" >> "$GITHUB_OUTPUT"
{
echo
echo "### Replacement: this plan destroys and recreates"
echo
echo "Each of these loses its ARN. Whatever trusts or names the old one breaks at apply."
echo
printf '%s\n' "$replacements"
} >> severity.md
else
echo "replacement=false" >> "$GITHUB_OUTPUT"
fi
cat severity.md

- name: The job summary
Expand Down Expand Up @@ -280,6 +300,15 @@ jobs:
gh pr edit "${{ github.event.pull_request.number }}" --add-label guardrail-change ||
echo "could not add the label, the summary carries the severity"

- name: Label a replacement
if: steps.severity.outputs.replacement == 'true' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
gh pr edit "${{ github.event.pull_request.number }}" --add-label replacement ||
echo "could not add the label, the summary carries the replacement"

# The comment, same posture. Posting one needs pull-requests: write,
# which a fork PR does not get, so the delta always lands in the job
# summary and the comment is the convenience on top.
Expand Down
3 changes: 3 additions & 0 deletions access/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ terraform.tfstate
terraform.tfstate.backup
.terraform.tfstate.lock.info
*.tfplan
tfplan*
plan*.json
old-digest
generated.tf

# The copy access/scripts/double-refusal works on. It is created and destroyed
Expand Down
49 changes: 49 additions & 0 deletions access/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -989,3 +989,52 @@ deleted from the repo.
The adopted files import resources that exist only where a hand made them,
so they live on the laptop that adopted them and the lesson's compare
excludes them. The reference tree carries the mechanism and no adoption.

## Lesson 14, approve the change, not the diff

The reviewer approves a plan, the apply job refuses anything else, and the
things the plan cannot say are named.

```
access/
scripts/
plan-digest the digest an approval binds to, over what changes
render-delta the access delta, with replacements on their own
.github/workflows/
access.yml the pr job saves the plan and its digest, the apply job compares
```

### The digest

`plan-digest` hashes a normalised reading of the plan, the resource changes
sorted by address with the timestamp and the prior state dropped, and on
an update in place only the attributes that differ from before. That last
reduction is why two jobs that each build the same base in their own
container compute one digest for one change, which lesson 14 proves with
two containers and which the first in-place update the pipeline merged
did not have (decision 63). `--normalise` prints what is hashed.

### Two refusals

The apply job recomputes the digest over its own plan and refuses when it
differs from the one the pr job recorded, which is the estate having moved
between approval and apply. Terraform refuses a saved plan whose state
moved on its own, `Saved plan is stale`, which is the state having moved.
They are different facts and the lesson shows both.

### Replacements

A replacement is a destroy and a create under one address, and on IAM it
changes an ARN underneath whatever trusts it. `render-delta` lists them on
their own with the attribute that forced each, the pr job writes them into
the summary and labels the pull request `replacement`, and a reviewer who
reads "site-publisher-v2 gains read" is also told that site-publisher is
about to stop existing.

### The gate, honestly

`environment: prod` on the apply job is where a maintainer adds required
reviewers in the repository settings. Today the environment exists and has
none, so the merge is the approval on this repository, and the page says so.
The digest proves the plan did not move and not who produced it, which is
property XIV half closed.
19 changes: 19 additions & 0 deletions access/scripts/render-delta
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,18 @@ delta="$(jq --argjson files "$files_json" '
| { address: .address, type: .type, verb: verb(.) }
] | sort_by(.address),

# A replacement is a destroy and a create under one address, and on IAM
# it changes an ARN underneath whatever trusts it. It is listed on its
# own, with the attribute that forced it, because a reviewer who reads
# "site-publisher-v2 gains read" has not been told that site-publisher
# is about to stop existing (decision 63).
replacements: [
$changed[]
| select(verb(.) == "replace")
| { address: .address, type: .type,
forced_by: ((.change.replace_paths // []) | map(map(tostring) | join(".")) | join(", ")) }
] | sort_by(.address),

files: ($files | map(select(. != "")))
}
' "$plan_json")" || {
Expand Down Expand Up @@ -168,6 +180,13 @@ printf '%s' "$delta" | jq -r '
(.other | map(" " + sym(.verb) + " " + .address) | join("\n")),
"")
end ),
( if (.replacements | length) == 0 then empty
else (" Replacements. These are destroyed and created again under the same",
" address, and on IAM that changes an ARN underneath whatever trusts it.",
" A replacement waits for a person, and the PR job says so.",
(.replacements | map(" ! " + .address + " forced by " + .forced_by) | join("\n")),
"")
end ),
( if (.files | length) == 0 then empty
else (" Files changed",
(.files | map(" " + .) | join("\n")),
Expand Down
Loading
Loading