@arjunkhera/atlas 0.3.52 → 0.4.0

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.3.52",
3
+ "version": "0.4.0",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -1,5 +1,5 @@
1
1
  {
2
- "note": "Every kit of the folder tests/ that a release of Atlas shipped, as the kit hash of its manifest (the sha256 of the sorted lines of path and file hash). The kit of this package is always accepted and need not be listed. When a release changes anything in tests/, add the kit hash of the release before it here. atlas tests check reads this list; git history is never read.",
2
+ "note": "Every kit of the folder tests/ that a release of Atlas shipped, as the kit hash of its manifest (the sha256 of the sorted lines of path and file hash). The kit of this package is always accepted and need not be listed. When a release changes anything in tests/, add the kit hash of the release before it here. atlas tests check reads this list; git history is never read. Since 24 October 2026 the release picks the version after the merge, so a new row names no first_package. The row of kit 1e62be9a names none either: it was built as 0.3.28, and npm never held 0.3.28.",
3
3
  "releases": [
4
4
  {
5
5
  "kit_sha256": "ae0f32b8c6694fbffb8bdacd8bd919d27c1c1da6281a46779e521dfea2341565",
@@ -22,8 +22,7 @@
22
22
  "first_package": "0.3.26"
23
23
  },
24
24
  {
25
- "kit_sha256": "1e62be9a3fb632161c593d44ac7ad404ef04186fe2fbcd108645fbe1559bf860",
26
- "first_package": "0.3.28"
25
+ "kit_sha256": "1e62be9a3fb632161c593d44ac7ad404ef04186fe2fbcd108645fbe1559bf860"
27
26
  },
28
27
  {
29
28
  "kit_sha256": "08c868aa39e6c9f471da2571e9a1d5939932a59856cea9c4176e3173f93454db",
@@ -14,6 +14,7 @@ import { tmpdir } from 'node:os';
14
14
  import { join, relative } from 'node:path';
15
15
  import { createInterface } from 'node:readline/promises';
16
16
  import { readConnection } from '../../work/mcp.mjs';
17
+ import { VERSION_FILES, stampVersion } from './releases.mjs';
17
18
 
18
19
  export const CATALOG_NAME = 'atlas';
19
20
  export const PLUGIN_ID = 'atlas@atlas';
@@ -169,38 +170,95 @@ function filesUnder(root, skip = () => false) {
169
170
  return out;
170
171
  }
171
172
 
172
- // Is `sha` a commit of the master branch itself? A run's branch name is not
173
- // enough: a tag named "master" gives a run the same name.
173
+ // Is `sha` in master's history now? A tag named "master" gives a run the
174
+ // branch name too, so the run's branch name is not enough.
174
175
  export function onMaster(gh, repo, sha) {
175
176
  const compare = gh(['api', '--method', 'GET', `repos/${repo}/compare/${sha}...refs/heads/master`]);
176
177
  return ['ahead', 'identical'].includes(compare.status);
177
178
  }
178
179
 
179
- // The master run of release.yml that built this version, or null.
180
- export function findReleaseRun({ gh, repo, version }) {
181
- const runs = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/release.yml/runs?event=push&status=success&per_page=100`]).workflow_runs ?? [];
182
- for (const run of runs) {
183
- if (!/^[0-9a-f]{40}$/.test(run.head_sha ?? '')) continue;
184
- let named;
185
- try {
186
- const file = gh(['api', '--method', 'GET', `repos/${repo}/contents/packages/atlas/package.json?ref=${run.head_sha}`]);
187
- named = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')).version;
188
- } catch { continue; }
189
- if (named !== version) continue;
190
- if (!onMaster(gh, repo, run.head_sha)) continue;
191
- return run;
180
+ // Was `sha` once the tip of master, through a merge or a normal push? A commit
181
+ // that reached master only as the second parent of a merge was never the tip,
182
+ // so the run that built it ran on another branch. A force push does not count:
183
+ // with onMaster it would let in code that left master's history. It reads the
184
+ // newest 100 changes of master, the one page this proxy lets through (its
185
+ // next-page links use a path it refuses); an older release is refused.
186
+ const TIP_KINDS = new Set(['pr_merge', 'push', 'merge_queue_merge']);
187
+ export function wasTip(gh, repo, sha) {
188
+ const rows = gh(['api', '--method', 'GET', `repos/${repo}/activity?ref=refs/heads/master&direction=desc&per_page=100`]);
189
+ return Array.isArray(rows) && rows.some((row) => row.after === sha && TIP_KINDS.has(row.activity_type));
190
+ }
191
+
192
+ // The commit that the tag v<version> names, or null. The release job tags the
193
+ // master commit it built (tracker item 339538d3): the commit no longer names
194
+ // the version, because the release picks it after the merge.
195
+ export function tagCommit(gh, repo, version) {
196
+ // Only "not found" means no tag. Any other failure is GitHub not
197
+ // answering, and the caller must say so, not refuse on a guess.
198
+ const read = (path) => {
199
+ try { return gh(['api', '--method', 'GET', path]).object; } catch (error) {
200
+ if (/HTTP 404|Not Found/i.test(`${error.stderr ?? ''}${error.message ?? ''}`)) return null;
201
+ throw error;
202
+ }
203
+ };
204
+ let object = read(`repos/${repo}/git/ref/tags/v${version}`);
205
+ if (object?.type === 'tag') object = read(`repos/${repo}/git/tags/${object.sha}`);
206
+ return object?.type === 'commit' && /^[0-9a-f]{40}$/.test(object.sha ?? '') ? object.sha : null;
207
+ }
208
+
209
+ // The name of the record that a master release run keeps for each version
210
+ // it publishes. Only a run of the tip of master makes one (release.yml). If
211
+ // GitHub stops listing a name once its file expires (90 days), an upgrade to
212
+ // that release is refused, never let through.
213
+ export const recordName = (version, sha256) => `atlas-release-${version}-${sha256}`;
214
+
215
+ // The release of this version. The tag is only a hint to find the commit: the
216
+ // proof is the record of a successful push run of release.yml on that master
217
+ // commit, which names this version and the sha256 of this exact tarball. The
218
+ // commit must once have been the tip of master. A tag that a person made or
219
+ // moved finds no record, so the upgrade refuses. With no sha256 (a loaded
220
+ // copy has no tarball), any record of this version counts, and the byte
221
+ // compare of every file does the rest.
222
+ export function findRelease({ gh, repo, version, sha256 }) {
223
+ 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` };
227
+ 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
+ const prefix = recordName(version, '');
229
+ const named = (name) => (sha256 ? name === recordName(version, sha256) : name.startsWith(prefix) && /^[0-9a-f]{64}$/.test(name.slice(prefix.length)));
230
+ // A tag named "master" gives a run on another commit the branch name too;
231
+ // the tip check above rules that out, and these fields rule out the rest.
232
+ const ours = (row) => row.head_sha === commit && row.event === 'push' && row.head_branch === 'master' && row.path === '.github/workflows/release.yml';
233
+ for (const run of runs.filter(ours)) {
234
+ const artifacts = gh(['api', '--method', 'GET', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]).artifacts ?? [];
235
+ if (artifacts.some((artifact) => named(artifact.name ?? ''))) return { run, commit };
192
236
  }
193
- return null;
237
+ return { problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
238
+ }
239
+
240
+ // The blob ids that the version files of the tarball must have: the files of
241
+ // the commit with the stamp of the target version. So the stamp may change the
242
+ // version line and nothing else.
243
+ export function stampedBlobs({ gh, repo, commit, version }) {
244
+ const out = new Map();
245
+ for (const path of VERSION_FILES) {
246
+ const file = gh(['api', '--method', 'GET', `repos/${repo}/contents/packages/atlas/${path}?ref=${commit}`]);
247
+ out.set(path, blobSha(Buffer.from(stampVersion(Buffer.from(file.content, 'base64').toString('utf8'), version))));
248
+ }
249
+ return out;
194
250
  }
195
251
 
196
252
  // Every file of the tarball must be the file at that commit, and every file of
197
- // packages/atlas at that commit must be in the tarball.
198
- export function matchTree({ tree, files }) {
253
+ // packages/atlas at that commit must be in the tarball. `stamped` holds the
254
+ // blob ids that replace the commit's own for the version files.
255
+ export function matchTree({ tree, files, stamped = new Map() }) {
199
256
  const wanted = new Map((tree ?? []).filter((row) => row.type === 'blob' && row.path.startsWith('packages/atlas/')).map((row) => [row.path.slice('packages/atlas/'.length), row.sha]));
257
+ for (const [path, sha] of stamped) if (wanted.has(path)) wanted.set(path, sha);
200
258
  const problems = [];
201
259
  for (const [path, bytes] of files) {
202
260
  if (!wanted.has(path)) problems.push(`${path} is in the tarball and not in the commit`);
203
- else if (wanted.get(path) !== blobSha(bytes)) problems.push(`${path} differs from the commit`);
261
+ else if (wanted.get(path) !== blobSha(bytes)) problems.push(`${path} differs from the commit${stamped.has(path) ? ' with the version stamped' : ''}`);
204
262
  }
205
263
  for (const path of wanted.keys()) if (!files.has(path)) problems.push(`${path} is in the commit and not in the tarball`);
206
264
  return problems;
@@ -222,14 +280,19 @@ export function proveCopy({ packageRoot, exec = defaultExec }) {
222
280
  const { version } = packageJson(packageRoot);
223
281
  const repo = repoOf(packageRoot);
224
282
  const gh = (args) => JSON.parse(exec('gh', args));
225
- let run;
226
- try { run = findReleaseRun({ gh, repo, version }); } catch (error) { return { state: 'not checked', reason: `GitHub did not answer: ${firstLines(error)}` }; }
227
- if (!run) return { state: 'unproven', reason: `no successful release run on a commit of master built ${version}. It may have been published from a branch.` };
283
+ let release;
284
+ try { release = findRelease({ gh, repo, version }); } catch (error) { return { state: 'not checked', reason: `GitHub did not answer: ${firstLines(error)}` }; }
285
+ if (release.problem) return { state: 'unproven', reason: `${release.problem}. It may have been published from a branch.` };
286
+ const { run, commit } = release;
228
287
  let tree;
229
- try { tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${run.head_sha}?recursive=1`]).tree; } catch (error) { return { state: 'not checked', reason: `GitHub did not give the file tree: ${firstLines(error)}` }; }
288
+ let stamped;
289
+ try {
290
+ tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${commit}?recursive=1`]).tree;
291
+ stamped = stampedBlobs({ gh, repo, commit, version });
292
+ } catch (error) { return { state: 'not checked', reason: `GitHub did not give the files of the commit: ${firstLines(error)}` }; }
230
293
  const files = filesUnder(packageRoot, (path) => COPY_MARKER.test(path));
231
- const problems = matchTree({ tree, files });
232
- const found = { run: run.id, commit: run.head_sha };
294
+ const problems = matchTree({ tree, files, stamped });
295
+ const found = { run: run.id, commit };
233
296
  return problems.length ? { state: 'differs', ...found, problems: problems.slice(0, 20) } : { state: 'match', ...found, files: files.size };
234
297
  } catch (error) {
235
298
  return { state: 'not checked', reason: `the proof stopped: ${firstLines(error)}` };
@@ -246,9 +309,6 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
246
309
  const integrity = exec('npm', ['view', `${name}@${target}`, 'dist.integrity']).trim();
247
310
 
248
311
  line(`Atlas ${installed} → ${target}`);
249
- const run = findReleaseRun({ gh, repo, version: target });
250
- if (!run) { line(`Refused: no successful release run on a commit of master built ${target}. It may have been published from a branch.`); return 1; }
251
- line(` built by release run ${run.id}, on master commit ${run.head_sha.slice(0, 12)}`);
252
312
 
253
313
  const work = mkdtempSync(join(tmpdir(), 'atlas-upgrade-'));
254
314
  try {
@@ -257,12 +317,17 @@ export async function upgrade({ packageRoot, options = {}, line = console.log, e
257
317
  if (!tarball) throw new Error('npm pack wrote no tarball');
258
318
  const bytes = readFileSync(join(work, tarball));
259
319
  if (sha512Integrity(bytes) !== integrity) { line('Refused: the tarball does not match the integrity npm records.'); return 1; }
320
+ const { run, commit, problem } = findRelease({ gh, repo, version: target, sha256: createHash('sha256').update(bytes).digest('hex') });
321
+ if (problem) { line(`Refused: ${problem}. It may have been published from a branch.`); return 1; }
322
+ line(` tag v${target}, recorded by release run ${run.id}, on master commit ${commit.slice(0, 12)}`);
260
323
  const unpacked = join(work, 'unpacked');
261
324
  mkdirSync(unpacked);
262
325
  exec('tar', ['-xzf', join(work, tarball), '-C', unpacked]);
263
326
  const files = filesUnder(join(unpacked, 'package'));
264
- const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${run.head_sha}?recursive=1`]).tree;
265
- const problems = matchTree({ tree, files });
327
+ const tree = gh(['api', '--method', 'GET', `repos/${repo}/git/trees/${commit}?recursive=1`]).tree;
328
+ let stamped;
329
+ try { stamped = stampedBlobs({ gh, repo, commit, version: target }); } catch (error) { line(`Refused: the version files of that commit cannot take the stamp: ${error.message}`); return 1; }
330
+ const problems = matchTree({ tree, files, stamped });
266
331
  if (problems.length) {
267
332
  line('Refused: the tarball is not the commit that run built.');
268
333
  for (const problem of problems.slice(0, 20)) line(` ${problem}`);
@@ -15,7 +15,9 @@ export function readReleases(packageRoot) {
15
15
  const list = JSON.parse(readFileSync(join(packageRoot, RELEASES_PATH), 'utf8'));
16
16
  if (!Array.isArray(list.releases) || !list.releases.length) throw new Error(`${RELEASES_PATH} lists no release`);
17
17
  for (const row of list.releases) {
18
- if (!Number.isInteger(row.check_version) || !/^[0-9a-f]{64}$/.test(row.sha256 ?? '') || typeof row.first_package !== 'string') {
18
+ // A row from before 24 October 2026 names its first package. A newer row
19
+ // names none, because the release picks the version after the merge.
20
+ if (!Number.isInteger(row.check_version) || !/^[0-9a-f]{64}$/.test(row.sha256 ?? '') || !['string', 'undefined'].includes(typeof row.first_package)) {
19
21
  throw new Error(`${RELEASES_PATH} holds a malformed row: ${JSON.stringify(row)}`);
20
22
  }
21
23
  }
@@ -40,3 +42,18 @@ export function packagedCheck(packageRoot) {
40
42
  const fingerprint = sha256(text);
41
43
  return { text, sha256: fingerprint, releases, ...placeOf(releases, fingerprint) };
42
44
  }
45
+
46
+ // The release picks the version (tracker item 339538d3). The release job
47
+ // writes the number into these two files of its own checkout, and into nothing
48
+ // else, and never commits it. `atlas upgrade` makes the same stamp on the
49
+ // files of the tagged commit, so the stamp can change the version and nothing
50
+ // more.
51
+ export const VERSION_FILES = Object.freeze(['package.json', '.claude-plugin/plugin.json']);
52
+ const VERSION_LINE = /^ {2}"version": "[^"\n]*"(,?)$/gm;
53
+
54
+ export function stampVersion(text, version) {
55
+ if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`"${version}" is not an exact version`);
56
+ const found = [...text.matchAll(VERSION_LINE)].length;
57
+ if (found !== 1) throw new Error(`a version file must hold exactly one top-level "version" line, and this one holds ${found}`);
58
+ return text.replace(VERSION_LINE, (_, comma) => ` "version": "${version}"${comma}`);
59
+ }
@@ -22,6 +22,10 @@ import { kitHashOf } from '../../tests/link-check.mjs';
22
22
 
23
23
  export const STATES = Object.freeze(['current', 'behind', 'unknown', 'missing']);
24
24
 
25
+ // A row after 24 October 2026 names no package, because the release picks
26
+ // the version after the merge.
27
+ const from = (row) => (row.first_package ? `, first in Atlas ${row.first_package}` : '');
28
+
25
29
  export function status({ root, packageRoot }) {
26
30
  const pkg = packagedCheck(packageRoot);
27
31
  const latest = pkg.releases[pkg.releases.length - 1];
@@ -35,10 +39,10 @@ export function status({ root, packageRoot }) {
35
39
  const { row, latest: isLatest } = placeOf(pkg.releases, fingerprint);
36
40
  const copy = { sha256: fingerprint, check_version: row?.check_version ?? checkStamps(text).check_version, first_package: row?.first_package ?? null };
37
41
  if (row && isLatest) {
38
- return { root, path: CHECK_PATH, state: 'current', packaged, copy, why: `the copy is check version ${row.check_version}, the newest one Atlas has released (first in Atlas ${row.first_package}).` };
42
+ return { root, path: CHECK_PATH, state: 'current', packaged, copy, why: `the copy is check version ${row.check_version}, the newest one Atlas has released${from(row)}.` };
39
43
  }
40
44
  if (row) {
41
- return { root, path: CHECK_PATH, state: 'behind', packaged, copy, why: `the copy is check version ${row.check_version}, first released in Atlas ${row.first_package}. The newest is check version ${latest.check_version}, from Atlas ${latest.first_package}. "atlas tooling pr" refreshes it.` };
45
+ return { root, path: CHECK_PATH, state: 'behind', packaged, copy, why: `the copy is check version ${row.check_version}${from(row)}. The newest is check version ${latest.check_version}${from(latest)}. "atlas tooling pr" refreshes it.` };
42
46
  }
43
47
  return { root, path: CHECK_PATH, state: 'unknown', packaged, copy, why: `the copy's fingerprint, sha256 ${fingerprint.slice(0, 12)}…, is in no release of the check. Someone edited it, or it came from an unreleased package. "atlas tooling pr --replace-unknown" shows the difference and replaces it.` };
44
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.52",
3
+ "version": "0.4.0",
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",