@arjunkhera/atlas 0.4.26 → 0.4.28

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.26",
3
+ "version": "0.4.28",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
@@ -22,12 +22,14 @@
22
22
  // An owner result turns into success when a comment of the pull request holds
23
23
  // the owner's signed act for this repo, this pull request and this head
24
24
  // (roadmap R72, signed-act.mjs). The key comes from atlas/owner-signers on the
25
- // base, never from the head.
25
+ // base, never from the head. An act for an older head carries to this head
26
+ // when the new commits only merge the base in with no conflict (item 34b554e7).
26
27
  //
27
28
  // Red wins over owner: the pull request must be fixed before the owner signs.
28
29
  // Rules that hold everywhere here:
29
- // 1. The owner-only list and atlas.yaml come from the merge base, never from
30
- // the head (owner-only.mjs reads them there).
30
+ // 1. The owner-only list, atlas.yaml and the guarded workflows come from the
31
+ // base commit, never from the head and never from the merge base, which
32
+ // an agent can pick (item d56a9c03).
31
33
  // 2. Nothing of the head runs. Every read is a git object.
32
34
  // 3. It fails closed: a head or a merge base that git cannot read is red.
33
35
  import { spawnSync } from 'node:child_process';
@@ -205,7 +207,7 @@ export function saysNoBehaviourChange(body) {
205
207
  return !/^#/.test(first) && /^no\b/i.test(first);
206
208
  }
207
209
 
208
- // Product code sits under the `product_paths:` of atlas.yaml at the merge base
210
+ // Product code sits under the `product_paths:` of atlas.yaml at the base
209
211
  // (roadmap R4 line 5). It is not a doc by the rule of fails-before, and not a
210
212
  // markdown file: a unit test cannot test skill text yet (roadmap R2 line 4).
211
213
  // A repo with no such key keeps the other two rules alone.
@@ -230,7 +232,7 @@ export function coveredFiles(root, green, mergeBase) {
230
232
  // for the files that main changed since that check was last green.
231
233
  function testRows({ root, mergeBase, head, body, redMain, rules, undo = null }) {
232
234
  const list = listChanged({ root, base: mergeBase, head });
233
- const product = list.other_paths.filter(productTest(root, rules ?? mergeBase));
235
+ const product = list.other_paths.filter(productTest(root, rules));
234
236
  if (!product.length) return [];
235
237
  // The exact undo of a change that tripped the brake puts main back as it
236
238
  // was before that change, so it needs no new test (roadmap R13 line 4).
@@ -292,11 +294,16 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
292
294
  answer.brake = { on: state.on, until: state.until, days: state.days, trips: state.trips, undo: undo?.culprit ?? null };
293
295
  answer.rows.push(...brakeRows(state, undo));
294
296
  }
295
- for (const row of classify({ root: top, base: mergeBase, head: headSha, rules }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
296
- answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
297
+ // The rules come from the base commit, never from the merge base. An agent
298
+ // picks the merge base when it picks where its branch starts, also a middle
299
+ // commit of a merged pull request that weakened the list and then took the
300
+ // change back (item d56a9c03). The diff stays merge base to head.
301
+ const ruleRef = rules ?? baseSha;
302
+ for (const row of classify({ root: top, base: mergeBase, head: headSha, rules: ruleRef }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
303
+ answer.rows.push(...jobRows(top, ruleRef, headSha, changes));
297
304
  answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
298
305
  if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
299
- answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
306
+ answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules: ruleRef, undo }));
300
307
  if (signed) signedRows(top, baseSha, headSha, signed, answer);
301
308
  return finish(answer);
302
309
  }
@@ -318,17 +325,106 @@ function signedRows(root, baseSha, headSha, { repo, pr, baseRef, texts }, answer
318
325
  if (!keys.length) { note(`no owner key in ${SIGNERS_FILE} on the base, so no signed act counts`); return; }
319
326
  note(`${keys.length} owner key(s) in ${SIGNERS_FILE} on the base: ${keys.map((key) => key.fingerprint).join(', ')}`);
320
327
  const verified = verifyTexts(texts, keys);
321
- const found = findAct(verified, { kind: 'merge', repo, pr: Number(pr), base: baseRef, merge_base: answer.merge_base, head: headSha });
328
+ let found = findAct(verified, { kind: 'merge', repo, pr: Number(pr), base: baseRef, merge_base: answer.merge_base, head: headSha });
329
+ let carried = null;
330
+ if (!found) {
331
+ carried = carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef }, answer);
332
+ if (carried?.found) found = carried.found;
333
+ }
322
334
  if (!found) {
323
335
  for (const row of verified.filter((each) => each.error).slice(0, 3)) note(`a signed act that does not count: ${row.error}`);
336
+ if (carried?.why) note(`a signed act for an older head does not carry: ${carried.why}`);
324
337
  if (verified.some((each) => !each.error)) note('a valid signed act names another pull request, base, merge base or head; sign this head');
325
338
  return;
326
339
  }
327
340
  const locked = answer.rows.filter((row) => row.kind === 'owner' && row.class === 'gate-checks');
328
341
  if (locked.length) { note('a signed act never clears a head job named like a check: remove that job'); return; }
329
342
  for (const row of answer.rows) if (row.kind === 'owner') row.kind = 'signed';
330
- answer.signed = { key: found.key, signed_at: found.signed_at, verified: found.verified };
331
- note(`the owner signed this head with key ${found.key} at ${found.signed_at}`);
343
+ answer.signed = { key: found.key, signed_at: found.signed_at, verified: found.verified, ...(carried?.found ? { carried_from: found.act.head } : {}) };
344
+ if (carried?.found) note(`the owner signed head ${found.act.head.slice(0, 7)} with key ${found.key} at ${found.signed_at}; this head only adds ${carried.merges} clean merge(s) of the base`);
345
+ else note(`the owner signed this head with key ${found.key} at ${found.signed_at}`);
346
+ }
347
+
348
+ // At most this many new commits between a signed head and this head.
349
+ export const CARRY_LIMIT = 20;
350
+ // At most this many signed heads are tried, so copies of one block cost little.
351
+ export const CARRY_TRIES = 3;
352
+ const contains = (root, older, newer) => spawnSync('git', [MERGE_BASE, '--is-ancestor', '--end-of-options', older, newer], { cwd: root }).status === 0;
353
+
354
+ // Tracker item 34b554e7: a signed act survives a clean merge of the base. A
355
+ // batch of signed pull requests merges one at a time, so each next one is
356
+ // behind and must merge the base in, which makes a new head. The act for the
357
+ // old head H0 carries to this head H only when all of these hold:
358
+ // 1. The act names this repo, this pull request and this base branch.
359
+ // 2. H0 is in the history of H, and the merge base of the act is in the
360
+ // history of the merge base now: the base only moved forward.
361
+ // 3. Each commit in H that is not in H0 and not in the base is a merge with
362
+ // two parents. Its second parent was a tip of the base branch after the
363
+ // touch: a first-parent commit of the base after the signed merge base. Its tree is the tree that
364
+ // git makes for a merge of its two parents with no conflict. So no commit
365
+ // adds a line that the owner did not see, and a conflict fix needs a new touch.
366
+ // A clean merge brings in only what the base already holds, so the pull
367
+ // request changes no path now that it did not change at H0.
368
+ // 4. The brake is not on: an act signed before a brake never clears it.
369
+ // 5. Only the newest touch on this pull request carries, so a rollback to a
370
+ // head that the owner moved past does not clear.
371
+ // It fails closed: a commit or a merge that git cannot read stops the carry.
372
+ function carryAct(root, baseSha, headSha, verified, { repo, pr, baseRef }, answer) {
373
+ const same = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
374
+ // Every valid merge act of this pull request, on any base branch and head.
375
+ const mine = [];
376
+ for (const row of verified) {
377
+ if (row.error) continue;
378
+ for (const act of row.acts) if (act.kind === 'merge' && same(act.repo, repo) && Number(act.pr) === Number(pr)) mine.push({ act, signed_at: row.signed_at, key: row.key, verified: row.verified });
379
+ }
380
+ if (!mine.length) return null;
381
+ // Only the newest touch carries. An older act names a head that the owner
382
+ // moved past, so a rollback to it must not clear (security review finding 2).
383
+ const newest = mine.reduce((top, one) => (one.signed_at > top ? one.signed_at : top), '');
384
+ const seen = new Set();
385
+ const candidates = mine.filter((one) => {
386
+ const id = `${one.act.base} ${one.act.merge_base} ${one.act.head}`;
387
+ if (one.signed_at !== newest || one.act.base !== baseRef || one.act.head === headSha || seen.has(id)) return false;
388
+ seen.add(id);
389
+ return true;
390
+ }).slice(0, CARRY_TRIES);
391
+ if (!candidates.length) return { why: 'the newest signed act of this pull request names another base branch or head' };
392
+ if (answer.rows.some((row) => row.kind === 'owner' && row.class === 'brake')) return { why: 'the brake is on, so the owner signs this head' };
393
+ let why = null;
394
+ for (const one of candidates) {
395
+ const result = carryOne(root, baseSha, headSha, answer.merge_base, one.act);
396
+ if (result.merges) return { found: one, merges: result.merges };
397
+ why ??= result.why;
398
+ }
399
+ return { why: `${why} (${candidates.length} signed head(s) tried)` };
400
+ }
401
+
402
+ // git with no replace objects and no system config, so the merge that the
403
+ // gate makes depends on the commits alone.
404
+ const PINNED = { ...process.env, GIT_NO_REPLACE_OBJECTS: '1', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' };
405
+
406
+ function carryOne(root, baseSha, headSha, mergeBase, act) {
407
+ const old = commitOf(root, act.head);
408
+ if (old !== act.head) return { why: `the signed head ${act.head.slice(0, 7)} is not a commit in this clone` };
409
+ if (commitOf(root, act.merge_base) !== act.merge_base) return { why: `the signed merge base ${act.merge_base.slice(0, 7)} is not a commit in this clone` };
410
+ if (!contains(root, old, headSha)) return { why: `the signed head ${old.slice(0, 7)} is not in the history of this head` };
411
+ if (!contains(root, act.merge_base, mergeBase)) return { why: 'the base did not only move forward since the touch' };
412
+ const shas = (git(root, ['rev-list', `--max-count=${CARRY_LIMIT + 1}`, headSha, `^${old}`, `^${baseSha}`], { allowFail: true }) ?? '').split('\n').filter(Boolean);
413
+ if (!shas.length) return { why: 'this head adds no merge of the base' };
414
+ if (shas.length > CARRY_LIMIT) return { why: `this head adds more than ${CARRY_LIMIT} commits` };
415
+ // The tips that the base branch held since the touch: its first-parent
416
+ // commits after the signed merge base. A merge may bring in only one of
417
+ // them, never a middle commit of a merged pull request (finding 3).
418
+ const tips = new Set((git(root, ['rev-list', '--first-parent', baseSha, `^${act.merge_base}`], { allowFail: true }) ?? '').split('\n').filter(Boolean));
419
+ for (const sha of shas) {
420
+ const [tree, ...parents] = (git(root, ['show', '-s', '--format=%T %P', sha], { allowFail: true }) ?? '').trim().split(' ');
421
+ if (parents.length !== 2) return { why: `commit ${sha.slice(0, 7)} is not a merge of two parents` };
422
+ if (!tips.has(parents[1])) return { why: `merge ${sha.slice(0, 7)} brings in ${parents[1].slice(0, 7)}, which was never a tip of the base after the touch` };
423
+ const made = spawnSync('git', ['-c', 'merge.renames=true', '-c', 'merge.conflictStyle=merge', 'merge-tree', '--write-tree', '--no-messages', '--end-of-options', parents[0], parents[1]], { cwd: root, encoding: 'utf8', env: PINNED, timeout: 30_000 });
424
+ if (made.status !== 0) return { why: `merge ${sha.slice(0, 7)} has a conflict, so its fix needs a new touch` };
425
+ if (made.stdout.split('\n')[0].trim() !== tree) return { why: `merge ${sha.slice(0, 7)} changes more than a clean merge of its parents` };
426
+ }
427
+ return { merges: shas.length };
332
428
  }
333
429
 
334
430
  // Line 1: while the brake is on, every pull request of the repo comes to the
@@ -366,6 +462,7 @@ export function renderText(answer) {
366
462
  }
367
463
  if (answer.result === 'red') out.push(`RED: ${oneLine(answer.rows.find((row) => row.kind === 'red').cause)}. Fix it; red wins over owner.`);
368
464
  else if (answer.result === 'owner') out.push(`OWNER: ${oneLine(answer.class)}. ${answer.signed_act}`);
465
+ else if (answer.signed?.carried_from) out.push(`SUCCESS: the owner signed head ${oneLine(answer.signed.carried_from).slice(0, 7)}, and this head only merges in the base.`);
369
466
  else if (answer.signed) out.push('SUCCESS: the owner signed this head.');
370
467
  else out.push('SUCCESS: no rule holds this pull request.');
371
468
  return out;
@@ -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/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/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]);
@@ -42,25 +42,34 @@ const oldWorkflow = (root) => existsSync(join(root, OLD_WORKFLOW_PATH));
42
42
  // repo backwards, and never replaces a hand edit at the same version.
43
43
  export const JOBS_PATH = '.github/workflows/atlas-jobs.yml';
44
44
  export const PACKAGE_JOBS_SOURCE = 'door/templates/atlas-jobs.yml';
45
+ // The upkeep check on each pull request (roadmap R41). The same pin rule holds.
46
+ // The Atlas repo keeps its own atlas-upkeep.yml, which runs the package of
47
+ // master and has no pin, so the writer keeps it and Atlas runs one upkeep job.
48
+ export const UPKEEP_PATH = '.github/workflows/atlas-upkeep.yml';
49
+ export const PACKAGE_UPKEEP_SOURCE = 'door/templates/atlas-upkeep.yml';
50
+ // 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]]);
45
52
  const VERSION_SLOT = '__ATLAS_VERSION__';
46
- export function packagedJobsWorkflow(packageRoot) {
47
- const full = join(packageRoot, PACKAGE_JOBS_SOURCE);
53
+ function packagedPinned(packageRoot, source) {
54
+ const full = join(packageRoot, source);
48
55
  return existsSync(full) ? readFileSync(full, 'utf8').split(VERSION_SLOT).join(packagePackageVersion(packageRoot)) : null;
49
56
  }
50
- const jobsPin = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
57
+ export const packagedJobsWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_JOBS_SOURCE);
58
+ export const packagedUpkeepWorkflow = (packageRoot) => packagedPinned(packageRoot, PACKAGE_UPKEEP_SOURCE);
59
+ const pinOf = (text) => /@arjunkhera\/atlas@(\d+)\.(\d+)\.(\d+)\b/.exec(text)?.slice(1).map(Number) ?? null;
51
60
  const older = (a, b) => { for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] < b[i]; return false; };
52
- // written, refreshed, unchanged or kept: what the writer does with the watcher.
53
- function jobsState(root, packageRoot) {
54
- const wanted = packagedJobsWorkflow(packageRoot);
61
+ // written, refreshed, unchanged or kept: what the writer does with one pinned workflow.
62
+ function pinnedState(root, packageRoot, path, source) {
63
+ const wanted = packagedPinned(packageRoot, source);
55
64
  if (wanted === null) return null;
56
- const full = join(root, JOBS_PATH);
65
+ const full = join(root, path);
57
66
  if (!existsSync(full)) return 'written';
58
67
  const was = readFileSync(full, 'utf8');
59
68
  if (was === wanted) return 'unchanged';
60
- const have = jobsPin(was);
61
- return have && older(have, jobsPin(wanted)) ? 'refreshed' : 'kept';
69
+ const have = pinOf(was);
70
+ return have && older(have, pinOf(wanted)) ? 'refreshed' : 'kept';
62
71
  }
63
- const jobsNeedWrite = (root, packageRoot) => ['written', 'refreshed'].includes(jobsState(root, packageRoot));
72
+ const pinnedNeedWrite = (root, packageRoot) => PINNED.some(([path, source]) => ['written', 'refreshed'].includes(pinnedState(root, packageRoot, path, source)));
64
73
 
65
74
  // The pull request template (roadmap R8). The writer puts it into a repo that
66
75
  // has none, and never replaces a template the repo keeps.
@@ -113,7 +122,7 @@ export function mayWrite(root, packageRoot, { replaceUnknown = false, workflowOn
113
122
  throw new ToolingRefusal(`this package's ${PACKAGE_CHECK_SOURCE} is ${pkg.row ? 'not the newest release' : 'in no release'} of the check, so it is not written into another repo. Use a released Atlas.`);
114
123
  }
115
124
  const now = status({ root, packageRoot });
116
- if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !jobsNeedWrite(root, packageRoot) && !oldWorkflow(root)) {
125
+ if (now.state === 'current' && !workflowOnly && !templateMissing(root, packageRoot) && !pinnedNeedWrite(root, packageRoot) && !oldWorkflow(root)) {
117
126
  throw new ToolingRefusal(`this repo's check is already current. ${readWorkflow(root) === WORKFLOW_TEMPLATE || workflowKept(root) ? 'Both files match the package, so there is nothing to write.' : 'Its workflow file differs from the package; pass --workflow to rewrite only that file.'}`, { current: true });
118
127
  }
119
128
  if (now.state === 'unknown' && !replaceUnknown && !own) {
@@ -145,16 +154,17 @@ export function write(root, packageRoot, { dryRun = false, replaceUnknown = fals
145
154
  if (!dryRun) rmSync(join(root, OLD_WORKFLOW_PATH));
146
155
  rows.push({ path: OLD_WORKFLOW_PATH, state: 'removed', sha256: null, was: sha256(was) });
147
156
  }
148
- const jobs = workflowOnly ? null : jobsState(root, packageRoot);
149
- if (jobs !== null) {
150
- const contents = packagedJobsWorkflow(packageRoot);
151
- const full = join(root, JOBS_PATH);
157
+ for (const [path, source] of workflowOnly ? [] : PINNED) {
158
+ const state = pinnedState(root, packageRoot, path, source);
159
+ if (state === null) continue;
160
+ const contents = packagedPinned(packageRoot, source);
161
+ const full = join(root, path);
152
162
  const was = existsSync(full) ? readFileSync(full, 'utf8') : null;
153
- if ((jobs === 'written' || jobs === 'refreshed') && !dryRun) {
163
+ if ((state === 'written' || state === 'refreshed') && !dryRun) {
154
164
  mkdirSync(dirname(full), { recursive: true });
155
165
  writeFileSync(full, contents);
156
166
  }
157
- rows.push({ path: JOBS_PATH, state: jobs, sha256: sha256(jobs === 'kept' ? was : contents), was: was === null ? null : sha256(was) });
167
+ rows.push({ path, state, sha256: sha256(state === 'kept' ? was : contents), was: was === null ? null : sha256(was) });
158
168
  }
159
169
  const template = workflowOnly ? null : packagedTemplate(packageRoot);
160
170
  if (template !== null) {
@@ -180,7 +190,7 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
180
190
  if (rows.every((row) => row.state === 'unchanged' || row.state === 'kept')) {
181
191
  throw new ToolingRefusal('this repo is already current: its Atlas files match the package, so there is nothing to open.', { current: true });
182
192
  }
183
- const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
193
+ const wanted = { ...packagedFiles(packageRoot), [JOBS_PATH]: packagedJobsWorkflow(packageRoot), [UPKEEP_PATH]: packagedUpkeepWorkflow(packageRoot), [PR_TEMPLATE_PATH]: packagedTemplate(packageRoot) };
184
194
  return rows
185
195
  .filter((row) => row.state !== 'unchanged' && row.state !== 'kept')
186
196
  .map((row) => (row.state === 'removed' ? {
@@ -196,8 +206,8 @@ export function changes(root, packageRoot, { replaceUnknown = false, workflowOnl
196
206
  ? `copied byte for byte from ${PACKAGE_CHECK_SOURCE}, check version ${CHECK_VERSION}, in Atlas ${version}`
197
207
  : row.path === PR_TEMPLATE_PATH
198
208
  ? `copied from ${PACKAGE_TEMPLATE_SOURCE}, Atlas ${version}; the repo had no pull request template`
199
- : row.path === JOBS_PATH
200
- ? `written from ${PACKAGE_JOBS_SOURCE}, with its pin set to Atlas ${version}`
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}`
201
211
  : `written from the WORKFLOW_TEMPLATE constant in ${PACKAGE_CHECK_SOURCE}, Atlas ${version}`,
202
212
  },
203
213
  }));
@@ -0,0 +1,45 @@
1
+ name: atlas-upkeep
2
+ # The Atlas upkeep check on each pull request: plain code, no model and no
3
+ # secret. `atlas tooling` wrote this file from the Atlas package; do not edit
4
+ # it by hand. A newer Atlas moves the pin below when `atlas tooling pr` runs.
5
+ #
6
+ # It stops drift at its source: the docs index lists each doc, every doc link
7
+ # resolves, each doc names its code, and the pull request names its tracker
8
+ # item, or says "no item:" and a reason. A repo with no docs/ folder passes
9
+ # the index part with one line.
10
+ #
11
+ # It runs a pinned Atlas release from npm, never code of the pull request. It
12
+ # runs on pull_request, not pull_request_target, so it holds only a read token
13
+ # and no secret, also for a pull request from a fork. npx runs outside the
14
+ # checkout, so an .npmrc in the pull request cannot pick the registry.
15
+ on:
16
+ pull_request:
17
+ types: [opened, edited, synchronize, reopened]
18
+
19
+ permissions:
20
+ contents: read
21
+
22
+ concurrency:
23
+ group: atlas-upkeep-${{ github.event.pull_request.number }}
24
+ cancel-in-progress: true
25
+
26
+ jobs:
27
+ upkeep:
28
+ name: docs · upkeep check
29
+ runs-on: ubuntu-latest
30
+ timeout-minutes: 5
31
+ steps:
32
+ # actions/checkout v7.0.1
33
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
34
+ with:
35
+ fetch-depth: 0
36
+ persist-credentials: false
37
+ # actions/setup-node v7.0.0
38
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
39
+ with:
40
+ node-version: "22.22.0"
41
+ - name: Upkeep check
42
+ run: |
43
+ set -euo pipefail
44
+ cd "$RUNNER_TEMP"
45
+ npx --yes --ignore-scripts @arjunkhera/atlas@__ATLAS_VERSION__ upkeep check --root "$GITHUB_WORKSPACE" --event "$GITHUB_EVENT_PATH" | tee -a "$GITHUB_STEP_SUMMARY"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
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",