@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.4.24",
3
+ "version": "0.4.25",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -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]
@@ -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
- // The check of each Atlas job, by its `when`. A job whose check is not built yet reads `none` and
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.24",
3
+ "version": "0.4.25",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",