Skip to content

Commit afdbf99

Browse files
🏗️✨:watch the vendored files for drift
A copy of somebody else's file goes stale without saying so. The analytics script sat six upstream releases behind for sixteen months, and what found it was somebody thinking to look. A copy here is upstream's bytes and nothing else, so the check is a comparison and needs no version recorded anywhere. Adding another file to watch is three lines of data. Weekly rather than on every pull request: it reaches the network, and `verify.all` runs everything in its directory, so an upstream that is slow or moved would fail changes that have nothing to do with it. It sits beside `verify-pull-request.mts`, out of that directory for the same kind of reason. It opens an issue rather than a pull request. One raised with `GITHUB_TOKEN` does not start the checks, so the queue could never land it; and a file fetched from the internet is worth a person reading before it arrives, which is why the dependency scanners are here. Drift and an upstream nobody could reach are separate bits of the exit code, so neither hides the other: an outage leaves a red run and no issue rather than a report headed "has drifted" with nothing under it, and a file nobody can reach cannot hold back a report about one that drifted. Signed-off-by: Derek Lewis <DerekNonGeneric@inf.is> Assisted-by: Claude-Code:claude-opus-5 PR-URL: #1865 Refs: #1548
1 parent 6fcfdfd commit afdbf99

3 files changed

Lines changed: 250 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Watching the third-party files kept in this repository for drift.
2+
#
3+
# A vendored copy is upstream's bytes and nothing else, so whether it has
4+
# fallen behind is a comparison rather than a judgement. Weekly rather than on
5+
# every pull request, because this reaches the network: an upstream that is
6+
# slow, moved or unreachable would otherwise fail changes that have nothing to
7+
# do with it.
8+
#
9+
# It opens an issue rather than a pull request, for two reasons. A pull
10+
# request raised with `GITHUB_TOKEN` does not start the checks, so the queue
11+
# could never land it. And a file fetched from the internet is worth a person
12+
# reading before it arrives, which is why the dependency scanners are here at
13+
# all.
14+
#
15+
# Actions are pinned by commit, never by tag.
16+
name: Vendored sync
17+
18+
on:
19+
schedule:
20+
# Wednesday, clear of the other two scheduled runs.
21+
- cron: '0 5 * * 3'
22+
workflow_dispatch:
23+
24+
permissions:
25+
contents: read
26+
issues: write
27+
28+
# A scheduled run and a hand-started one should not both file the same report.
29+
concurrency:
30+
group: ${{ github.workflow }}
31+
cancel-in-progress: false
32+
33+
jobs:
34+
check:
35+
name: Check vendored files
36+
runs-on: ubuntu-latest
37+
steps:
38+
- name: Check out project repository
39+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40+
- name: Set up Node.js runtime
41+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
42+
with:
43+
node-version-file: 'package.json'
44+
# The task reads only what node ships with, so there is nothing to
45+
# install and no lockfile to resolve before it can run.
46+
- name: Compare against upstream
47+
id: compare
48+
run: |
49+
node build/tasks/check-vendored.mts > report.md || code=$?
50+
cat report.md
51+
52+
# Both bits are read: one file drifting says nothing about whether
53+
# another was reachable, so neither answer is allowed to hide the
54+
# other. Reported after the issue is filed, so a file nobody could
55+
# reach cannot hold back a report about one that drifted.
56+
echo "drifted=$(( (${code:-0} & 1) != 0 ))" >> "$GITHUB_OUTPUT"
57+
echo "unchecked=$(( (${code:-0} & 2) != 0 ))" >> "$GITHUB_OUTPUT"
58+
- name: Say so, once
59+
if: steps.compare.outputs.drifted == '1'
60+
env:
61+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62+
TITLE: 📦 a vendored file has drifted from upstream
63+
run: |
64+
# Matched against the open issues themselves rather than through
65+
# search, which is an index and lags behind what was just written.
66+
# `gh` pages until it has as many as asked for, and the number is
67+
# far past what this repository will hold, so the report cannot be
68+
# missed and a duplicate filed beside it.
69+
# One issue at a time: a weekly comment on a report nobody has acted
70+
# on yet says nothing the report did not.
71+
open=$(gh issue list --state open --limit 1000 --json number,title \
72+
--jq 'map(select(.title == env.TITLE)) | .[0].number // empty')
73+
74+
if [ -n "$open" ]; then
75+
echo "already reported in #${open}"
76+
exit 0
77+
fi
78+
79+
{
80+
echo 'A copy kept in this repository no longer matches what'
81+
echo 'upstream serves. Read what changed before taking it.'
82+
echo
83+
cat report.md
84+
} > body.md
85+
86+
gh issue create --title "$TITLE" --body-file body.md \
87+
--label '📦 Type: Dependencies'
88+
- name: Fail if anything could not be compared
89+
# `always()`, so this runs after a report has been filed rather than
90+
# instead of one.
91+
if: always() && steps.compare.outputs.unchecked == '1'
92+
run: |
93+
echo '::error::a vendored file could not be compared'
94+
exit 1

build/tasks/check-vendored.mts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* @file Compare vendored third-party files against what upstream serves.
3+
* @author The OpenINF Authors & Friends
4+
* @license MIT OR Apache-2.0 OR BlueOak-1.0.0
5+
* @module {type ES6Module} build/tasks/check-vendored
6+
*
7+
* Outside `verify/` on purpose, the way `verify-pull-request.mts` is: it
8+
* reaches the network, and every task in that directory runs on every pull
9+
* request. An upstream that is slow, moved or unreachable would fail changes
10+
* that have nothing to do with it.
11+
*
12+
* The exit code carries both answers at once, since one file drifting says
13+
* nothing about whether another was reachable: bit 1 is set when a copy has
14+
* drifted, bit 2 when one could not be compared. Drift is a thing to act on
15+
* and an upstream nobody can reach is not, so neither hides the other.
16+
*/
17+
18+
import { readFile } from 'node:fs/promises';
19+
20+
/** One vendored file and where it comes from. */
21+
type Vendored = {
22+
/** Where the copy lives, relative to the repository root. */
23+
file: string;
24+
/** What upstream serves, which the copy is expected to equal byte for byte. */
25+
upstream: string;
26+
/** Where to read about a change. */
27+
project: string;
28+
};
29+
30+
/**
31+
* A copy here is upstream's bytes and nothing else. Anything this project
32+
* needs to say about a file goes beside it rather than inside it, so that
33+
* telling whether it has drifted stays a comparison rather than a judgement.
34+
*/
35+
const VENDORED: Vendored[] = [
36+
{
37+
file: '_assets/js/vendor/count.js',
38+
upstream: 'https://gc.zgo.at/count.js',
39+
project: 'https://github.com/arp242/goatcounter',
40+
},
41+
];
42+
43+
/** How long to wait on an upstream before giving up, in milliseconds. */
44+
const TIMEOUT = 30_000;
45+
46+
/** Bits of the exit code. Both can be set; neither masks the other. */
47+
const MATCHED = 0;
48+
const DRIFTED = 1;
49+
const UNCHECKED = 2;
50+
51+
/**
52+
* Says what went wrong in a sentence rather than a stack trace.
53+
* @param {unknown} error Whatever was thrown.
54+
* @returns {string} Its message.
55+
*/
56+
const reasonOf = (error: unknown) =>
57+
error instanceof Error ? error.message : String(error);
58+
59+
/**
60+
* Reports the first line each version differs at, since a whole diff of a
61+
* long file says less than where to start looking.
62+
* @param {string} ours What is in the repository.
63+
* @param {string} theirs What upstream serves.
64+
* @returns {string} A description of the first difference.
65+
*/
66+
function firstDifference(ours: string, theirs: string) {
67+
const a = ours.split('\n');
68+
const b = theirs.split('\n');
69+
70+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
71+
if (a[index] !== b[index]) {
72+
return [
73+
`first differs at line ${index + 1}:`,
74+
` ours: ${a[index] ?? '(end of file)'}`,
75+
` upstream: ${b[index] ?? '(end of file)'}`,
76+
].join('\n');
77+
}
78+
}
79+
80+
return 'the files differ in how they end';
81+
}
82+
83+
const drifted: string[] = [];
84+
const unchecked: string[] = [];
85+
86+
for (const { file, upstream, project } of VENDORED) {
87+
let ours: string;
88+
let theirs: string;
89+
90+
try {
91+
ours = await readFile(file, 'utf8');
92+
} catch (error) {
93+
unchecked.push(`\`${file}\` could not be read: ${reasonOf(error)}`);
94+
continue;
95+
}
96+
97+
try {
98+
const response = await fetch(upstream, {
99+
signal: AbortSignal.timeout(TIMEOUT),
100+
});
101+
102+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
103+
104+
theirs = await response.text();
105+
} catch (error) {
106+
unchecked.push(`\`${file}\`: ${upstream}${reasonOf(error)}`);
107+
continue;
108+
}
109+
110+
if (ours === theirs) {
111+
console.log(`\`${file}\` matches ${upstream}`);
112+
continue;
113+
}
114+
115+
drifted.push(
116+
[
117+
`### \`${file}\``,
118+
'',
119+
`Upstream: ${upstream}`,
120+
`Project: ${project}`,
121+
'',
122+
`Ours is ${ours.length} bytes, upstream is ${theirs.length}.`,
123+
'',
124+
'```text',
125+
firstDifference(ours, theirs),
126+
'```',
127+
'',
128+
'To take what upstream serves:',
129+
'',
130+
'```bash',
131+
`curl -fsSL ${upstream} -o ${file}`,
132+
'```',
133+
].join('\n')
134+
);
135+
}
136+
137+
// Everything goes to stdout, including what went wrong: whatever runs this
138+
// keeps only that, and a reason written anywhere else is a reason lost.
139+
if (unchecked.length > 0) {
140+
console.log('');
141+
console.log('Could not be compared:');
142+
for (const problem of unchecked) console.log(`- ${problem}`);
143+
}
144+
145+
if (drifted.length > 0) {
146+
console.log('');
147+
console.log(drifted.join('\n\n'));
148+
}
149+
150+
process.exitCode =
151+
MATCHED |
152+
(drifted.length > 0 ? DRIFTED : MATCHED) |
153+
(unchecked.length > 0 ? UNCHECKED : MATCHED);

package-scripts.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ scripts:
2121
# Outside verify/ on purpose: it needs a pull request in the environment,
2222
# and verify.all runs everything in that directory.
2323
pullRequest: node build/tasks/verify-pull-request.mts
24+
# Outside it for the same reason: this one reaches the network, and an
25+
# upstream that is slow or moved would fail unrelated changes.
26+
vendored: node build/tasks/check-vendored.mts
2427
svg: node build/tasks/verify/verify-svg.mts
2528
toml: node build/tasks/verify/verify-toml.mts
2629
ts: node build/tasks/verify/verify-ts.mts

0 commit comments

Comments
 (0)