@arjunkhera/atlas 0.4.24 → 0.4.25
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.
- package/.claude-plugin/plugin.json +1 -1
- package/door/jobs/npm-release.yaml +24 -0
- package/door/lib/install.mjs +6 -5
- package/door/lib/jobs.mjs +98 -2
- package/package.json +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# An Atlas job (the routines design, records part). A job does nothing until a
|
|
2
|
+
# repo names it in its atlas.yaml, with the npm packages it publishes:
|
|
3
|
+
#
|
|
4
|
+
# jobs:
|
|
5
|
+
# atlas: [npm-release]
|
|
6
|
+
# npm: ['@scope/name']
|
|
7
|
+
#
|
|
8
|
+
# It fits a repo that publishes to npm through a release.yml on master that
|
|
9
|
+
# keeps the Atlas release record, as Atlas does. A service, a schema repo or a
|
|
10
|
+
# repo that publishes nothing does not name it.
|
|
11
|
+
#
|
|
12
|
+
# The check is Atlas code (door/lib/jobs.mjs). Each version that npm got in the
|
|
13
|
+
# last 2 days must have the proof that `atlas upgrade` asks for: a successful
|
|
14
|
+
# push run of release.yml on a master commit that recorded that version with
|
|
15
|
+
# the sha256 of its tarball. A branch can publish before any merge (the gate
|
|
16
|
+
# probes, P7), so a version with no such run is a finding. `problem` is a
|
|
17
|
+
# code: no-tag, not-on-master, never-tip or no-record. Only a person can act
|
|
18
|
+
# on it (deprecate the version on npm), so the job may only report.
|
|
19
|
+
title: Each new npm version was published by a release run on master
|
|
20
|
+
when: daily
|
|
21
|
+
needs: []
|
|
22
|
+
act: atlas:npm-release
|
|
23
|
+
may: report
|
|
24
|
+
fields: [package, version, problem, commit]
|
package/door/lib/install.mjs
CHANGED
|
@@ -218,12 +218,13 @@ export const recordName = (version, sha256) => `atlas-release-${version}-${sha25
|
|
|
218
218
|
// commit must once have been the tip of master. A tag that a person made or
|
|
219
219
|
// moved finds no record, so the upgrade refuses. With no sha256 (a loaded
|
|
220
220
|
// copy has no tarball), any record of this version counts, and the byte
|
|
221
|
-
// compare of every file does the rest.
|
|
221
|
+
// compare of every file does the rest. A refusal carries a `code` too, for
|
|
222
|
+
// the npm-release job of the watcher (jobs.mjs).
|
|
222
223
|
export function findRelease({ gh, repo, version, sha256 }) {
|
|
223
224
|
const commit = tagCommit(gh, repo, version);
|
|
224
|
-
if (!commit) return { problem: `no tag v${version} names a commit` };
|
|
225
|
-
if (!onMaster(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
|
|
226
|
-
if (!wasTip(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
|
|
225
|
+
if (!commit) return { code: 'no-tag', problem: `no tag v${version} names a commit` };
|
|
226
|
+
if (!onMaster(gh, repo, commit)) return { code: 'not-on-master', commit, problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
|
|
227
|
+
if (!wasTip(gh, repo, commit)) return { code: 'never-tip', commit, problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
|
|
227
228
|
const runs = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/release.yml/runs?event=push&status=success&head_sha=${commit}&per_page=100`]).workflow_runs ?? [];
|
|
228
229
|
const prefix = recordName(version, '');
|
|
229
230
|
const named = (name) => (sha256 ? name === recordName(version, sha256) : name.startsWith(prefix) && /^[0-9a-f]{64}$/.test(name.slice(prefix.length)));
|
|
@@ -234,7 +235,7 @@ export function findRelease({ gh, repo, version, sha256 }) {
|
|
|
234
235
|
const artifacts = gh(['api', '--method', 'GET', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]).artifacts ?? [];
|
|
235
236
|
if (artifacts.some((artifact) => named(artifact.name ?? ''))) return { run, commit };
|
|
236
237
|
}
|
|
237
|
-
return { problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
|
|
238
|
+
return { code: 'no-record', commit, problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
// The blob ids that the version files of the tarball must have: the files of
|
package/door/lib/jobs.mjs
CHANGED
|
@@ -24,7 +24,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
|
|
24
24
|
import { dirname, join, resolve } from 'node:path';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { parseYaml } from '../../shape/check.mjs';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
27
28
|
import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
|
|
29
|
+
import { findRelease, sha512Integrity } from './install.mjs';
|
|
28
30
|
import { checkGateFiles, checkGateAudit, checkGateErrors } from './gate-audit.mjs';
|
|
29
31
|
import { commitAudit, ghApi } from './gate.mjs';
|
|
30
32
|
|
|
@@ -107,7 +109,100 @@ export async function checkRedMain({ repo, branch, env }) {
|
|
|
107
109
|
return findings;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
//
|
|
112
|
+
// ---------------------------------------------------------------- the check of npm-release
|
|
113
|
+
|
|
114
|
+
// A branch of the repo can run its own release.yml and publish to npm before any merge (the gate
|
|
115
|
+
// probes, P7). So each version that npm got in the last NPM_DAYS days must have the proof that
|
|
116
|
+
// `atlas upgrade` asks for: a successful push run of release.yml on a master commit that recorded
|
|
117
|
+
// this version with the sha256 of this exact tarball (findRelease, install.mjs). The watcher runs
|
|
118
|
+
// once a day, so two days cover one missed run. A longer window reaches versions from before the
|
|
119
|
+
// release record existed (Atlas 0.4.0), and the tip proof reads only the newest 100 changes of master.
|
|
120
|
+
export const NPM_DAYS = 2;
|
|
121
|
+
export const NPM_MAX = 50;
|
|
122
|
+
const PACKAGE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
123
|
+
|
|
124
|
+
// The npm packages that atlas.yaml names under `jobs: { npm: [...] }`.
|
|
125
|
+
export function npmPackages(root) {
|
|
126
|
+
const facts = parseYaml(readFileSync(join(root, 'atlas.yaml'), 'utf8')) ?? {};
|
|
127
|
+
const named = facts.jobs?.npm;
|
|
128
|
+
if (!Array.isArray(named) || !named.length) throw new Error('atlas.yaml names no package under jobs.npm');
|
|
129
|
+
const bad = named.map(String).find((name) => !PACKAGE.test(name));
|
|
130
|
+
if (bad !== undefined) throw new Error('jobs.npm in atlas.yaml holds a name that is not an npm package name');
|
|
131
|
+
return named.map(String);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// https, or http for this machine only (a test uses a fake registry on 127.0.0.1).
|
|
135
|
+
function registryBase(env) {
|
|
136
|
+
const text = String(env.NPM_REGISTRY_URL || 'https://registry.npmjs.org');
|
|
137
|
+
let url;
|
|
138
|
+
try { url = new URL(text); } catch { throw usage('NPM_REGISTRY_URL is not a URL'); }
|
|
139
|
+
const local = url.hostname === '127.0.0.1' || url.hostname === 'localhost';
|
|
140
|
+
if (!(url.protocol === 'https:' || (url.protocol === 'http:' && local))) throw usage('NPM_REGISTRY_URL must be https (http is allowed only for 127.0.0.1 or localhost)');
|
|
141
|
+
return url;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// findRelease reads GitHub through a sync `gh`. Here each read is a fetch, so the proof runs again
|
|
145
|
+
// after each read it asked for, until it needs none. Every read is a GET, and the same path gives the
|
|
146
|
+
// same answer, so a replay changes nothing.
|
|
147
|
+
export async function replayRelease({ base, key, repo, version, sha256 }) {
|
|
148
|
+
const answers = new Map();
|
|
149
|
+
for (let round = 0; round < 50; round += 1) {
|
|
150
|
+
try {
|
|
151
|
+
return findRelease({
|
|
152
|
+
repo, version, sha256,
|
|
153
|
+
gh: (args) => {
|
|
154
|
+
const path = `/${args.at(-1)}`;
|
|
155
|
+
if (!answers.has(path)) throw Object.assign(new Error('a read is still to come'), { need: path });
|
|
156
|
+
const answer = answers.get(path);
|
|
157
|
+
if (answer instanceof Error) throw answer;
|
|
158
|
+
return answer;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (!error.need) throw error;
|
|
163
|
+
const r = await api(base, key, 'GET', error.need);
|
|
164
|
+
if (r.status === 404) answers.set(error.need, Object.assign(new Error('Not Found'), { stderr: 'HTTP 404' }));
|
|
165
|
+
else if (!r.ok) throw new Error(`GitHub answered ${r.status} when the release of ${version} was read`);
|
|
166
|
+
else answers.set(error.need, r.data);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`the release of ${version} needed more than 50 reads`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function checkNpmRelease({ repo, env, root, now = Date.now(), fetchImpl = fetch }) {
|
|
173
|
+
const base = apiBase(env);
|
|
174
|
+
const key = env.GITHUB_TOKEN || '';
|
|
175
|
+
if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
|
|
176
|
+
const registry = registryBase(env);
|
|
177
|
+
const findings = [];
|
|
178
|
+
for (const name of npmPackages(root)) {
|
|
179
|
+
const response = await fetchImpl(new URL(name.replace('/', '%2F'), `${registry.href.replace(/\/?$/, '/')}`));
|
|
180
|
+
if (!response.ok) throw new Error(`npm answered ${response.status} when ${name} was read`);
|
|
181
|
+
const doc = await response.json();
|
|
182
|
+
// A broken answer must read broken, never none: each version needs a publish time.
|
|
183
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
184
|
+
if (!object(doc?.versions) || !object(doc?.time)) throw new Error(`npm sent no versions and times for ${name}`);
|
|
185
|
+
if (Object.keys(doc.versions).some((version) => !Number.isFinite(Date.parse(doc.time[version])))) throw new Error(`npm sent a version of ${name} with no publish time`);
|
|
186
|
+
const recent = Object.keys(doc.versions).filter((version) => now - Date.parse(doc.time[version]) <= NPM_DAYS * DAY);
|
|
187
|
+
if (recent.length > NPM_MAX) throw new Error(`npm holds ${recent.length} versions of ${name} from the last ${NPM_DAYS} days, more than ${NPM_MAX}`);
|
|
188
|
+
for (const version of recent.sort()) {
|
|
189
|
+
if (!VALUE.test(version)) throw new Error(`npm holds a version of ${name} that is not a plain version`);
|
|
190
|
+
const dist = doc.versions[version].dist ?? {};
|
|
191
|
+
const tarball = new URL(String(dist.tarball ?? ''), registry);
|
|
192
|
+
if (tarball.origin !== registry.origin) throw new Error(`the tarball of ${name}@${version} is not on the registry`);
|
|
193
|
+
const got = await fetchImpl(tarball);
|
|
194
|
+
if (!got.ok) throw new Error(`npm answered ${got.status} when the tarball of ${name}@${version} was read`);
|
|
195
|
+
const bytes = Buffer.from(await got.arrayBuffer());
|
|
196
|
+
if (sha512Integrity(bytes) !== dist.integrity) throw new Error(`the tarball of ${name}@${version} does not match the integrity npm records`);
|
|
197
|
+
const release = await replayRelease({ base, key, repo, version, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
198
|
+
if (!release.problem) continue;
|
|
199
|
+
findings.push({ job: 'npm-release', key: `${name}@${version}`, package: name, version, problem: release.code, ...(release.commit ? { commit: release.commit } : {}) });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return findings;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The check of each Atlas job, by its id, else by its `when`. A job whose check is not built yet reads `none` and
|
|
111
206
|
// says so in the notes; it never reads `found` from nothing.
|
|
112
207
|
// The gate jobs (roadmap R14) read GitHub through `gh`, which the Actions
|
|
113
208
|
// runner holds; their token is the run's own GITHUB_TOKEN.
|
|
@@ -116,6 +211,7 @@ const CHECKS = {
|
|
|
116
211
|
'gate-files': ({ root, env, fetchImpl }) => checkGateFiles({ root, env, fetchImpl }),
|
|
117
212
|
'gate-audit': ({ repo, branch, gh }) => checkGateAudit({ repo, branch, gh, audit: commitAudit }),
|
|
118
213
|
'gate-errors': ({ repo, gh }) => checkGateErrors({ repo, gh }),
|
|
214
|
+
'npm-release': checkNpmRelease,
|
|
119
215
|
};
|
|
120
216
|
|
|
121
217
|
export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs(), gh = ghApi, fetchImpl = fetch }) {
|
|
@@ -128,7 +224,7 @@ export async function jobsCheck({ root, repo, branch = null, env = process.env,
|
|
|
128
224
|
for (const id of named) {
|
|
129
225
|
const job = jobs.get(id);
|
|
130
226
|
if (!job) { notes.push(`${JOB_ID.test(id) ? id : '(a bad id)'}: no Atlas job has this id`); continue; }
|
|
131
|
-
const check = CHECKS[job.when];
|
|
227
|
+
const check = CHECKS[job.id] ?? CHECKS[job.when];
|
|
132
228
|
if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
|
|
133
229
|
try {
|
|
134
230
|
if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
|
package/package.json
CHANGED