@arjunkhera/atlas 0.4.35 → 0.4.37

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.35",
3
+ "version": "0.4.37",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/lib/gate.mjs CHANGED
@@ -252,7 +252,7 @@ export const gateRunTitle = (pr, sha) => `gate #${pr} at ${sha}`;
252
252
  // The audit reads a merged pull request. GitHub then lists no pull request
253
253
  // on its runs, so with `audit` the two pull request pins are left out; the
254
254
  // file, the event, the head and the title still hold.
255
- export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [], audit = false }) {
255
+ export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [], audit = false, baseChanged = () => '' }) {
256
256
  if (!run) return 'no workflow run';
257
257
  if (!homes.get(name)?.has(run.path)) return `from ${run.path ?? 'no file'}, not a base file that defines the check`;
258
258
  const event = name === GATE_CHECK ? 'pull_request_target' : 'pull_request';
@@ -272,6 +272,12 @@ export function refusal(run, { name, pr, sha, baseRef, homes, sides = () => [],
272
272
  const side = sides();
273
273
  if (side === null) return 'no head branch to check for side pull requests';
274
274
  if (side.length) return `the head branch also had #${side[0].number} into ${side[0].base?.ref ?? 'another branch'}`;
275
+ // A pull request can change its base. A run made while it pointed at a side
276
+ // branch used that branch's file, and the lists above no longer show it. So
277
+ // a run made before the last base change does not count (item 490bd791).
278
+ const changed = baseChanged();
279
+ if (changed === null) return `no events of #${pr} to check for a base change`;
280
+ if (changed && !(String(run.created_at ?? '') > changed)) return `made before the base of #${pr} last changed (${changed})`;
275
281
  return null;
276
282
  }
277
283
 
@@ -291,6 +297,77 @@ export function jobRefusal(job, check, run) {
291
297
  return null;
292
298
  }
293
299
 
300
+ // Every event of one pull request, oldest first, or null when GitHub gives
301
+ // none or more pages than `pages`. The newest event sits on the last page.
302
+ function eventsOf(gh, repo, pr, pages = 10) {
303
+ const all = [];
304
+ for (let page = 1; page <= pages; page += 1) {
305
+ const got = gh('GET', `repos/${repo}/issues/${pr}/events?per_page=100&page=${page}`);
306
+ if (!Array.isArray(got)) return null;
307
+ all.push(...got);
308
+ if (got.length < 100) return all;
309
+ }
310
+ return null;
311
+ }
312
+
313
+ // The merge lock (item a638ce35). Two pull requests, each READY on the same
314
+ // main tip, can merge seconds apart and turn the main branch red together
315
+ // (atlas#214 and atlas#215). So a READY takes a lock: the label MERGE_LABEL
316
+ // on its pull request. A lock lives LOCK_MS from its label event, a minute
317
+ // longer than a READY file, or until its pull request closes. A label makes
318
+ // no ref and starts no workflow. Times come from GitHub, not the local clock.
319
+ export const MERGE_LABEL = 'atlas-merging';
320
+ export const LOCK_MS = 11 * 60 * 1000;
321
+
322
+ // The newest event that put the label on or took it off, or null. GitHub
323
+ // lists the events oldest first.
324
+ const labelEvents = (events) => events
325
+ .filter((each) => (each?.event === 'labeled' || each?.event === 'unlabeled') && each.label?.name === MERGE_LABEL);
326
+ const lastLabelEvent = (events) => labelEvents(events).at(-1) ?? null;
327
+
328
+ // `lock.on` says whether the pull request carries the label now.
329
+ export function dropLock(gh, repo, pull, lock) {
330
+ if (!lock.on) return;
331
+ gh('DELETE', `repos/${repo}/issues/${pull.number}/labels/${MERGE_LABEL}`);
332
+ lock.on = false;
333
+ }
334
+
335
+ // Take the lock for `pull`. Returns null when it holds the lock, or why not;
336
+ // the caller then takes the label off. Each caller puts its label on first,
337
+ // then reads the others. Of two live labels, the older one wins, and the
338
+ // lower number breaks a tie. So of two ready calls in the same seconds,
339
+ // exactly one keeps its label.
340
+ export function takeLock(gh, repo, pull, lock) {
341
+ // GitHub can show its events late, and an old label event would make this
342
+ // lock look older. Events only grow, so the label events are counted first:
343
+ // the read after must hold each new one, and the newest must put it on.
344
+ const before = eventsOf(gh, repo, pull.number);
345
+ if (!before) return { why: `GitHub gave no events of #${pull.number}` };
346
+ const made = lock.on ? 2 : 1;
347
+ // A new label event restarts the lock, so an old label comes off first.
348
+ dropLock(gh, repo, pull, lock);
349
+ gh('POST', `repos/${repo}/issues/${pull.number}/labels`, { labels: [MERGE_LABEL] });
350
+ lock.on = true;
351
+ const mine = eventsOf(gh, repo, pull.number);
352
+ const last = mine && lastLabelEvent(mine);
353
+ if (!mine || labelEvents(mine).length < labelEvents(before).length + made || last?.event !== 'labeled') return { why: `GitHub does not show the new label event on #${pull.number} yet` };
354
+ const at = String(last.created_at ?? '');
355
+ const held = gh('GET', `repos/${repo}/issues?labels=${MERGE_LABEL}&state=open&per_page=100`);
356
+ if (!Array.isArray(held)) return { why: 'GitHub gave no list of locked pull requests' };
357
+ for (const other of held) {
358
+ if (!other?.pull_request || Number(other.number) === Number(pull.number)) continue;
359
+ const events = eventsOf(gh, repo, other.number);
360
+ const theirs = events && lastLabelEvent(events);
361
+ if (!theirs) return { why: `GitHub shows no label event on #${other.number}`, pr: other.number };
362
+ // The list can be late too: a label already taken off holds no lock.
363
+ if (theirs.event !== 'labeled') continue;
364
+ const since = String(theirs.created_at ?? '');
365
+ if (Date.parse(at) - Date.parse(since) >= LOCK_MS) continue;
366
+ if (since < at || (since === at && Number(other.number) < Number(pull.number))) return { why: `#${other.number} holds the merge lock since ${since}`, pr: other.number };
367
+ }
368
+ return null;
369
+ }
370
+
294
371
  // Every pull request, open or closed, from the head branch into another
295
372
  // branch. Read once, and only when a gate run needs it.
296
373
  function sidesOf(gh, repo, head, baseRef) {
@@ -304,6 +381,18 @@ function sidesOf(gh, repo, head, baseRef) {
304
381
  };
305
382
  }
306
383
 
384
+ // The time of the last base change of one pull request, '' for none, or
385
+ // null when GitHub gives no events. Read once, when the first run needs it.
386
+ function baseChangedOf(gh, repo, pr) {
387
+ let changedAt;
388
+ return () => {
389
+ if (changedAt !== undefined) return changedAt;
390
+ const events = eventsOf(gh, repo, pr);
391
+ if (!events) return (changedAt = null);
392
+ return (changedAt = events.filter((each) => each?.event === 'base_ref_changed').map((each) => String(each.created_at ?? '')).sort().pop() ?? '');
393
+ };
394
+ }
395
+
307
396
  // The workflow run of each check suite, read once.
308
397
  function suiteRuns(gh, repo) {
309
398
  const suites = new Map();
@@ -337,13 +426,14 @@ export function gateCopyRefusals(gh, repo, pull) {
337
426
  export function pinnedRuns(gh, repo, { pr, sha, baseRef, head, checks, homesRef = baseRef, audit = false }) {
338
427
  const homes = homesAt(gh, repo, homesRef, checks);
339
428
  const sides = sidesOf(gh, repo, head, baseRef);
429
+ const baseChanged = audit ? () => '' : baseChangedOf(gh, repo, pr);
340
430
  const runOf = suiteRuns(gh, repo);
341
431
  const kept = [];
342
432
  const refused = [];
343
433
  for (const check of checkRuns(gh, repo, sha)) {
344
434
  if (!checks.includes(check.name) || check.app?.id !== ACTIONS_APP_ID) continue;
345
435
  const run = runOf(check.check_suite?.id);
346
- const why = refusal(run, { name: check.name, pr, sha, baseRef, homes, sides, audit })
436
+ const why = refusal(run, { name: check.name, pr, sha, baseRef, homes, sides, audit, baseChanged })
347
437
  ?? jobRefusal(check.id === undefined ? null : gh('GET', `repos/${repo}/actions/jobs/${check.id}`), check, run);
348
438
  if (why) refused.push({ name: check.name, why }); else kept.push(check);
349
439
  }
@@ -374,6 +464,20 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
374
464
  const pull = gh('GET', `repos/${repo}/pulls/${pr}`);
375
465
  if (answer && typeof pull?.head?.sha === 'string') answer.sha = pull.head.sha;
376
466
  if (pull?.state !== 'open') { line(`atlas gate ready: pull request #${pr} is ${pull?.merged ? 'merged' : pull?.state ?? 'not found'}. NOT READY.`); return 1; }
467
+ // Any answer but READY, an error too, takes the merge label off.
468
+ const lock = { on: (pull.labels ?? []).some((each) => each?.name === MERGE_LABEL) };
469
+ let code;
470
+ try {
471
+ code = readyOpen({ repo, pr, pull, lock }, line, gh);
472
+ return code;
473
+ } finally {
474
+ if (code !== 0) {
475
+ try { dropLock(gh, repo, pull, lock); } catch (error) { if (code !== undefined) throw error; }
476
+ }
477
+ }
478
+ }
479
+
480
+ function readyOpen({ repo, pr, pull, lock }, line, gh) {
377
481
  const sha = pull.head.sha;
378
482
  let checks;
379
483
  try { checks = checksAt(gh, repo, pull.base.ref); } catch (error) {
@@ -395,7 +499,19 @@ export function readyCommand({ repo, pr, answer }, line, gh = ghApi) {
395
499
  if (!(behind >= 0)) throw new GateError(`GitHub gave no compare of ${pull.base.ref} and ${sha.slice(0, 7)}.`);
396
500
  line(` ${behind === 0 ? '✓' : '✗'} ${(behind === 0 ? 'current' : 'behind').padEnd(8)} ${behind === 0 ? `the head holds every commit of ${pull.base.ref}` : `${pull.base.ref} has ${behind} commit(s) that the head does not hold`}`);
397
501
  const green = rows.every((row) => row.state === 'green');
398
- if (green && behind === 0) { line('READY: every required check is green, on top of the current main branch. Merge with a merge commit, at this head.'); return 0; }
502
+ if (green && behind === 0) {
503
+ const held = takeLock(gh, repo, pull, lock);
504
+ if (held) {
505
+ line(` ✗ locked ${held.why}`);
506
+ line(held.pr
507
+ ? `NOT READY: do not merge. Wait for #${held.pr} to merge or for its lock to end (${LOCK_MS / 60000} min). Then merge ${pull.base.ref} in if it moved, and run ready again.`
508
+ : 'NOT READY: do not merge. The merge lock could not be read; run ready again.');
509
+ return 1;
510
+ }
511
+ line(` ✓ lock this pull request holds the merge lock (label ${MERGE_LABEL}) for ${LOCK_MS / 60000} min`);
512
+ line('READY: every required check is green, on top of the current main branch. Merge with a merge commit, at this head.');
513
+ return 0;
514
+ }
399
515
  line(behind > 0
400
516
  ? `NOT READY: do not merge. Merge ${pull.base.ref} in, wait for green, then run ready again.`
401
517
  : 'NOT READY: do not merge. Wait for a pending check; fix a red or missing one.');
@@ -23,7 +23,7 @@ export const TOOLING_LABEL = 'atlas-helper';
23
23
 
24
24
  // The only files the tooling writer may write: the check, its workflow, and
25
25
  // the pull request template when the repo has none.
26
- export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/workflows/atlas-upkeep.yml', '.github/pull_request_template.md']);
26
+ export const TOOLING_PATHS = Object.freeze([CHECK_PATH, WORKFLOW_PATH, '.github/workflows/atlas-jobs.yml', '.github/workflows/atlas-upkeep.yml', '.github/workflows/atlas-procedures.yml', '.github/pull_request_template.md']);
27
27
  // The only files the tooling writer may remove: the workflow of check versions
28
28
  // 1 to 5, which the gate workflow replaces (roadmap R7).
29
29
  export const TOOLING_REMOVALS = Object.freeze([OLD_WORKFLOW_PATH]);
@@ -47,8 +47,14 @@ export const PACKAGE_JOBS_SOURCE = 'door/templates/atlas-jobs.yml';
47
47
  // master and has no pin, so the writer keeps it and Atlas runs one upkeep job.
48
48
  export const UPKEEP_PATH = '.github/workflows/atlas-upkeep.yml';
49
49
  export const PACKAGE_UPKEEP_SOURCE = 'door/templates/atlas-upkeep.yml';
50
+ // The tagged procedure steps on each pull request that changes a skill
51
+ // (roadmap R40). The same pin rule holds. The Atlas repo runs these steps in
52
+ // its own package gates job, so the writer never writes this file there.
53
+ export const PROCEDURES_PATH = '.github/workflows/atlas-procedures.yml';
54
+ export const PACKAGE_PROCEDURES_SOURCE = 'door/templates/atlas-procedures.yml';
50
55
  // The workflows the writer pins, in the order of its rows.
51
- const PINNED = Object.freeze([[JOBS_PATH, PACKAGE_JOBS_SOURCE], [UPKEEP_PATH, PACKAGE_UPKEEP_SOURCE]]);
56
+ const PINNED = Object.freeze([[JOBS_PATH, PACKAGE_JOBS_SOURCE], [UPKEEP_PATH, PACKAGE_UPKEEP_SOURCE], [PROCEDURES_PATH, PACKAGE_PROCEDURES_SOURCE]]);
57
+ const pinnedFor = (root, packageRoot) => (isPackageRepo(root, packageRoot) ? PINNED.filter(([path]) => path !== PROCEDURES_PATH) : PINNED);
52
58
  const VERSION_SLOT = '__ATLAS_VERSION__';
53
59
  function packagedPinned(packageRoot, source) {
54
60
  const full = join(packageRoot, source);
@@ -56,6 +62,7 @@ function packagedPinned(packageRoot, source) {
56
62
  }
57
63
  export const packagedJobsWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_JOBS_SOURCE);
58
64
  export const packagedUpkeepWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_UPKEEP_SOURCE);
65
+ export const packagedProceduresWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_PROCEDURES_SOURCE);
59
66
  const pinOf = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
60
67
  const older = (a, b) => { for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i]; return false; };
61
68
  // written, refreshed, unchanged or kept: what the writer does with one pinned workflow.
@@ -69,7 +76,7 @@ function pinnedState(root, packageRoot, path, source) {
69
76
  const have = pinOf(was);
70
77
  return have && older(have, pinOf(wanted)) ? 'refreshed' : 'kept';
71
78
  }
72
- const pinnedNeedWrite = (root, packageRoot) => PINNED.some(([path, source]) => ['written', 'refreshed'].includes(pinnedState(root, packageRoot, path, source)));
79
+ const pinnedNeedWrite = (root, packageRoot) => pinnedFor(root, packageRoot).some(([path, source]) => ['written', 'refreshed'].includes(pinnedState(root, packageRoot, path, source)));
73
80
 
74
81
  // The pull request template (roadmap R8). The writer puts it into a repo that
75
82
  // has none, and never replaces a template the repo keeps.
@@ -154,7 +161,7 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
154
161
  if (!dryRun) rmSync(join(root, OLD_WORKFLOW_PATH));
155
162
  rows.push({ path: OLD_WORKFLOW_PATH, state: 'removed', sha256: null, was: sha256(was) });
156
163
  }
157
- for (const [path, source] of workflowOnly ? [] : PINNED) {
164
+ for (const [path, source] of workflowOnly ? [] : pinnedFor(root, packageRoot)) {
158
165
  const state = pinnedState(root, packageRoot, path, source);
159
166
  if (state === null) continue;
160
167
  const contents = packagedPinned(packageRoot, source);
@@ -190,7 +197,7 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
190
197
  if (rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
191
198
  throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to open.', { current: true });
192
199
  }
193
- const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [UPKEEP_PATH]: packagedUpkeepWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
200
+ const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [UPKEEP_PATH]: packagedUpkeepWorkflow(packageRoot), [PROCEDURES_PATH]: packagedProceduresWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
194
201
  return rows
195
202
  .filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
196
203
  .map((row) => (row.state === 'removed' ? {
@@ -206,8 +213,8 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
206
213
  ? `copied byte for byte from ${PACKAGE_CHECK_SOURCE}, check version ${CHECK_VERSION}, in Atlas ${version}`
207
214
  : row.path === PR_TEMPLATE_PATH
208
215
  ? `copied from ${PACKAGE_TEMPLATE_SOURCE}, Atlas ${version}; the repo had no pull request template`
209
- : row.path === JOBS_PATH || row.path === UPKEEP_PATH
210
- ? `written from ${row.path === JOBS_PATH ? PACKAGE_JOBS_SOURCE : PACKAGE_UPKEEP_SOURCE}, with its pin set to Atlas ${version}`
216
+ : PINNED.some(([path]) => path === row.path)
217
+ ? `written from ${PINNED.find(([path]) => path === row.path)[1]}, with its pin set to Atlas ${version}`
211
218
  : `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
212
219
  },
213
220
  }));
@@ -0,0 +1,53 @@
1
+ name: atlas-procedures
2
+ # The Atlas procedure steps (roadmap R40). `atlas tooling` wrote this file from
3
+ # the Atlas package; do not edit it by hand.
4
+ #
5
+ # A procedure is a skill that atlas.yaml names under `procedures:`. A step a
6
+ # clean machine can run sits in a fenced block tagged `atlas-run`. This job
7
+ # runs those blocks for each procedure that a pull request changes: the
8
+ # procedure folder, or a path its `sources:` names. A block tagged
9
+ # `needs=secret/<name>`, `needs=device/<name>` or `needs=network/<name>` is
10
+ # listed "not run here".
11
+ #
12
+ # The path filter starts the job only when a skill or atlas.yaml changes, so
13
+ # most pull requests start nothing. A change to a path that a procedure names
14
+ # in `sources:` alone does not start it; the upkeep check lists that procedure
15
+ # instead. The steps run the code of the pull request, so the job holds a
16
+ # token that can only read and no repo secret, also for a pull request from a
17
+ # fork. npx runs outside the checkout, so an .npmrc in the pull request cannot
18
+ # pick the registry of Atlas itself.
19
+ on:
20
+ pull_request:
21
+ paths:
22
+ - ".claude/skills/**"
23
+ - "atlas.yaml"
24
+
25
+ permissions:
26
+ contents: read
27
+
28
+ concurrency:
29
+ group: atlas-procedures-${{ github.event.pull_request.number }}
30
+ cancel-in-progress: true
31
+
32
+ jobs:
33
+ procedures:
34
+ name: procedures · tagged steps
35
+ runs-on: ubuntu-latest
36
+ timeout-minutes: 10
37
+ steps:
38
+ # actions/checkout v7.0.1
39
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
40
+ with:
41
+ fetch-depth: 0
42
+ persist-credentials: false
43
+ # actions/setup-node v7.0.0
44
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
45
+ with:
46
+ node-version: "22.22.0"
47
+ - name: Run the tagged steps of each procedure this pull request changes
48
+ env:
49
+ BASE_REF: ${{ github.base_ref }}
50
+ run: |
51
+ set -euo pipefail
52
+ cd "$RUNNER_TEMP"
53
+ npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ upkeep procedures --root "$GITHUB_WORKSPACE" --base "origin/$BASE_REF" | tee -a "$GITHUB_STEP_SUMMARY"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.35",
3
+ "version": "0.4.37",
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",