forked from ChelseaKR/disclosed
-
Notifications
You must be signed in to change notification settings - Fork 0
288 lines (268 loc) · 15.6 KB
/
Copy pathsnapshot.yml
File metadata and controls
288 lines (268 loc) · 15.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
name: daily-snapshot
# The whole point of the project is the comparison between runs. A single snapshot cannot tell a
# field that was never collected from a field that was collected until last Tuesday, so the
# history has to accrue somewhere, and it accrues in git: a snapshot is per-field counts, a few
# hundred bytes, and the record of what stopped being published belongs in a diff that anyone can
# read rather than in a bucket they have to trust.
#
# The full graded report is deliberately NOT committed. It is several megabytes per run and every
# byte of it is reproducible from the capture plus the rules. The raw capture is kept as a
# workflow artifact for ninety days, and the provenance of the walk that produced it -- every
# page's status, size and digest, the call count, the rate-limit headroom -- is committed beside
# the snapshot, so a drift finding can be traced to the bytes it was computed from.
#
# How the commit reaches master took eighteen runs to get right, and every route that looks
# simpler is closed. master is protected twice over: a ruleset that requires `verify`, and
# classic branch protection that requires five checks from the Actions app -- `verify` and
# `replay` from verify.yml, the three scans from security.yml -- each pinned to a specific app id,
# not just a context name. Ten runs (2026-08-06 to 2026-08-15) committed nothing because they
# asked `git diff` about an untracked file. Five (2026-08-16 to 2026-08-20) were rejected for
# lacking the checks. One (2026-08-21, run 32466145742) ran `make verify` itself and recorded a
# `verify` status on its commit, which satisfied exactly one of the five. ADR 0003's fix -- push to
# an unprotected staging ref, dispatch verify.yml and security.yml there, wait for both, then fast-
# forward -- looked right and passed its own tests, and two more runs that same day (32473991532,
# rerun twice) still rejected the push: `gh api .../commits/<sha>/check-runs` showed all five check
# runs completed and green on the exact SHA being pushed, and GitHub refused it anyway, both times,
# seconds after the checks finished. A check run's check suite is scoped to the branch that
# triggered it; a workflow_dispatch run on snapshot/staging does not satisfy a requirement bound to
# master, no matter how identical the SHA is. Commit statuses carry no such scoping, which is why
# run 32466145742's self-recorded `verify` status worked when the checks that actually ran did not.
# So the job keeps ADR 0003's real, external verification -- dispatch, wait, `gh run watch
# --exit-status` fails the job if the gate fails -- and adds back what ADR 0002 got right about the
# mechanism while fixing what it got wrong about the evidence: after each dispatched workflow is
# watched to completion, its own job results are read back and *transcribed*, one status per job,
# quoting the run that earned it. Nothing is graded twice and nothing is asserted that did not
# happen; the status is a receipt for a check that already ran and already passed, not a promise.
# ADR 0004 records the decision; tests/test_workflows.py pins that the status follows the watch
# rather than substituting for it.
on:
schedule:
- cron: "41 9 * * *" # daily, off the hour to avoid the top-of-hour scheduling crush
workflow_dispatch:
permissions:
contents: read
concurrency:
group: daily-snapshot
cancel-in-progress: false
jobs:
snapshot:
runs-on: ubuntu-latest
permissions:
contents: write # pushes the snapshot commit: to a staging ref, then to master
actions: write # dispatches the gate workflows on the staging ref, and the site rebuild
statuses: write # transcribes each dispatched job's already-earned result (ADR 0004)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # the previous snapshot is the entire point; a shallow clone has none
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install
run: python -m pip install -e .
# Without a key the adapter silently falls back to DEMO_KEY, which allows about three pages
# an hour. The run would fetch ~300 of ~6,300 institutions and then rate-limit. Better to
# refuse at the top with the remedy stated than to fail obscurely 40 pages in.
- name: Require an API key
env:
DATA_GOV_API_KEY: ${{ secrets.DATA_GOV_API_KEY }}
run: |
if [ -z "${DATA_GOV_API_KEY}" ]; then
echo "::error title=Missing DATA_GOV_API_KEY::The repository secret DATA_GOV_API_KEY \
is not set. A full national run is ~63 pages; DEMO_KEY allows about three an hour, so \
this job would rate-limit partway through and a partial fetch would look exactly like a \
nationwide collapse in reporting. Get a free key at https://api.data.gov/signup/ and \
add it under Settings > Secrets and variables > Actions."
exit 1
fi
# The only step that touches the network or the key. Everything after it reads the file.
- name: Walk the API with provenance
id: walk
env:
DATA_GOV_API_KEY: ${{ secrets.DATA_GOV_API_KEY }}
run: |
taken="$(date -u +%F)"
echo "taken=${taken}" >> "$GITHUB_OUTPUT"
mkdir -p data/snapshots/scorecard/provenance
python -m disclosed.cli fetch \
--out /tmp/capture.json \
--provenance-out "data/snapshots/scorecard/provenance/${taken}.json" \
| tee -a "$GITHUB_STEP_SUMMARY"
- name: Keep the raw capture for ninety days
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: scorecard-capture-${{ steps.walk.outputs.taken }}
path: /tmp/capture.json
retention-days: 90
if-no-files-found: error
# Graded from the file with no key in the environment, and the scope it produces has to be
# national: the envelope proves exhaustion from its own counts or it does not, and a
# snapshot of a slice would read as a nationwide collapse in reporting.
- name: Grade the capture without the key
run: |
python -m disclosed.cli grade --source /tmp/capture.json --out report.json
kind="$(python -c "import json; print(json.load(open('report.json'))['scope']['kind'])")"
if [ "${kind}" != "national" ]; then
echo "::error title=Not a census::The capture replays as '${kind}', not national. \
The walk did not confirm exhaustion, so no snapshot is taken from it."
exit 1
fi
# Into data/snapshots/scorecard/, kept apart from data/snapshots/ipeds/. The two are
# different populations with no field in common, and a comparison across them would skip
# every field and report "no change in per-field disclosure", which is the most reassuring
# possible way of saying nothing at all. `drift` refuses such a pair outright; this keeps
# the glob below from ever assembling one.
- name: Reduce to a committable snapshot
id: snap
env:
TAKEN: ${{ steps.walk.outputs.taken }}
run: |
python -m disclosed.cli snapshot \
--report report.json \
--taken "${TAKEN}" \
--out "data/snapshots/scorecard/${TAKEN}.json"
# Drift against the most recent earlier snapshot from this same source. Reported into the
# job summary so a systemic change is visible without cloning anything. The provenance
# sidecars live one directory down, so the glob cannot pick one up as a snapshot.
- name: Report drift since the previous snapshot
env:
TAKEN: ${{ steps.walk.outputs.taken }}
run: |
previous="$(ls data/snapshots/scorecard/*.json 2>/dev/null \
| grep -v "/${TAKEN}.json$" | tail -n 1 || true)"
if [ -z "${previous}" ]; then
echo "First Scorecard snapshot; nothing to compare against yet." \
>> "$GITHUB_STEP_SUMMARY"
exit 0
fi
{
echo "## Disclosure drift: $(basename "${previous}" .json) to ${TAKEN}"
echo '```'
python -m disclosed.cli drift \
"${previous}" "data/snapshots/scorecard/${TAKEN}.json"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Staged BEFORE the comparison, because `git diff` compares the index and the working tree
# and an untracked file is in neither. Every daily snapshot is a new path, so the old shape
# of this step -- `git diff --quiet -- data/snapshots/` ahead of the `git add` -- was asking
# git a question that could only ever be answered "unchanged". It answered that ten days
# running while the run above graded 6,273 institutions each time, and the record this
# project's entire drift argument depends on was written to a runner and thrown away. The
# comparison a new file can fail is the index against HEAD, which is what `--cached` is.
- name: Commit the snapshot
id: commit
env:
TAKEN: ${{ steps.walk.outputs.taken }}
run: |
git add data/snapshots/
if git diff --cached --quiet --exit-code -- data/snapshots/; then
echo "Snapshot for ${TAKEN} is byte-identical to the committed one; nothing to commit."
echo "committed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "data: per-field disclosure snapshot ${TAKEN}"
echo "committed=true" >> "$GITHUB_OUTPUT"
# A check can only be attached to a SHA the remote has, and master will not take the SHA
# until it has the checks. The staging ref breaks the circle: unprotected, it holds the
# commit for the minutes the gates take, and it is deleted afterwards.
- name: Stage the commit where the gates can reach it
if: steps.commit.outputs.committed == 'true'
run: git push --force origin HEAD:refs/heads/snapshot/staging
# The same two workflows a pull request runs, dispatched on the staging ref so they check
# this exact SHA, and waited for. `gh run watch --exit-status` fails this job if either
# fails, so a snapshot that breaks a figure-gating test never reaches master and the day is
# lost loudly rather than committed quietly.
#
# Watching is not enough by itself (ADR 0004): a check run's check suite is scoped to
# snapshot/staging, the branch that triggered it, and does not satisfy a requirement bound
# to master for the same SHA -- proven by two runs (32473991532, watched and rejected twice
# on 2026-08-21) where `gh api .../check-runs` showed all five green on the SHA and the push
# was refused anyway. So once a dispatched workflow's run has been watched to completion --
# never before -- its job results are read back and each one is transcribed to a commit
# status quoting that run. The status is not this job's opinion; it repeats a verdict a
# separate, already-completed workflow run already reached, with a link to the run that
# reached it.
- name: Run the checks master requires, on this exact commit
if: steps.commit.outputs.committed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
sha="$(git rev-parse HEAD)"
for workflow in verify.yml security.yml; do
gh workflow run "${workflow}" --ref snapshot/staging
done
for workflow in verify.yml security.yml; do
run_id=""
for _ in $(seq 1 24); do
run_id="$(gh run list --workflow "${workflow}" --branch snapshot/staging \
--event workflow_dispatch --json databaseId,headSha \
--jq ".[] | select(.headSha == \"${sha}\") | .databaseId" | head -n 1)"
[ -n "${run_id}" ] && break
sleep 5
done
if [ -z "${run_id}" ]; then
echo "::error title=${workflow} never started::No workflow_dispatch run of \
${workflow} appeared for ${sha} within two minutes, so the commit cannot be checked \
and is not pushed."
exit 1
fi
echo "${workflow}: run ${run_id} on ${sha}"
gh run watch "${run_id}" --exit-status
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs" \
--jq '.jobs[] | [.name, .html_url, .conclusion] | @tsv' \
> /tmp/jobs.tsv
while IFS=$'\t' read -r job_name job_url job_conclusion; do
if [ "${job_conclusion}" != "success" ]; then
echo "::error title=${job_name} did not succeed::gh run watch exited zero for \
${workflow} but job '${job_name}' concluded '${job_conclusion}'; refusing to transcribe \
a status that does not match what the run actually reported."
exit 1
fi
gh api --method POST "repos/${GITHUB_REPOSITORY}/statuses/${sha}" \
-f state=success \
-f context="${job_name}" \
-f description="passed in ${workflow} run ${run_id}, watched to completion" \
-f target_url="${job_url}"
echo "status: ${job_name} <- ${run_url}"
done < /tmp/jobs.tsv
done
- name: Push to master
if: steps.commit.outputs.committed == 'true'
run: |
git push origin HEAD:master
git push --delete origin refs/heads/snapshot/staging
# Nothing a GITHUB_TOKEN push does starts another workflow, so without this the published
# site would go on describing the day before. workflow_dispatch is the documented exception
# to that rule, and publish-site needs no secret and reads only committed data.
- name: Rebuild the published site
if: steps.commit.outputs.committed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run publish-site --ref master
# The post-condition, checked rather than assumed. A step that reports success without
# having done anything is the failure this repository exists to name, and the first version
# of the commit step above was exactly that for ten consecutive runs. This asserts the
# effect on the remote, not on the runner: today's snapshot is on origin/master.
- name: Fail if the snapshot never reached the history
env:
TAKEN: ${{ steps.walk.outputs.taken }}
run: |
path="data/snapshots/scorecard/${TAKEN}.json"
if ! git ls-files --error-unmatch -- "${path}" >/dev/null 2>&1; then
echo "::error title=Snapshot not committed::${path} was written by this run but is \
not tracked. The daily series is the only evidence behind every drift claim this \
project makes, so a run that grades the country and commits nothing is a failed run, \
not a quiet one."
exit 1
fi
git fetch --quiet origin master
if ! git ls-tree -r --name-only origin/master -- "${path}" | grep -qx "${path}"; then
echo "::error title=Snapshot not on master::${path} is committed on this runner and \
absent from origin/master. The push did not land, and a snapshot that exists only on \
a runner that is about to be discarded is the same as no snapshot."
exit 1
fi
echo "${path} is on origin/master."