@ikon85/agent-workflow-kit 0.34.0 → 0.34.3

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,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.34.0",
2
+ "kitVersion": "0.34.3",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -137,7 +137,7 @@
137
137
  "ownerSkill": "diagnose",
138
138
  "surface": "codex",
139
139
  "installRole": "consumer",
140
- "sha256": "38ae64634933b42a6fea8b480c1962fc494a2dc08d7b07b0ab9a114eaeaa631e",
140
+ "sha256": "8a6b1036968146beff15a03e03c7b0f7d71ab65a666201ee8e1588c484f32ce6",
141
141
  "mode": 420,
142
142
  "origin": "kit"
143
143
  },
@@ -267,7 +267,7 @@
267
267
  "ownerSkill": "implement",
268
268
  "surface": "codex",
269
269
  "installRole": "consumer",
270
- "sha256": "5b55552c4a0d90fe15c9ee44abce4498faa03b73e332024e30ab69c2b7065770",
270
+ "sha256": "dff8c4bd4a1cfd376bc5fdd9d97641301d61055fd9155bde50c1e9634a6015d2",
271
271
  "mode": 420,
272
272
  "origin": "kit"
273
273
  },
@@ -367,7 +367,7 @@
367
367
  "ownerSkill": "kit-release",
368
368
  "surface": "codex",
369
369
  "installRole": "maintainer",
370
- "sha256": "d05a4059a19816ff302c119f797f1ea2b71554ecffac7a8f0b919cfe5517d940",
370
+ "sha256": "7e08d5115d34f36da08491fa3bb8d5f4ca45e2908560e8f8c924cf3fd619d7f9",
371
371
  "mode": 420,
372
372
  "origin": "kit"
373
373
  },
@@ -447,7 +447,7 @@
447
447
  "ownerSkill": "orchestrate-wave",
448
448
  "surface": "codex",
449
449
  "installRole": "consumer",
450
- "sha256": "3a281805aee83a771675f8f80b9bd9dee3c1b2f2b2ca58001b37d8565a7d9d7d",
450
+ "sha256": "4c40d95e773649ba87e5010740180b66682a18cdf98ba7d8b7907397ea212ed0",
451
451
  "mode": 420,
452
452
  "origin": "kit"
453
453
  },
@@ -627,7 +627,7 @@
627
627
  "ownerSkill": "setup-workflow",
628
628
  "surface": "codex",
629
629
  "installRole": "consumer",
630
- "sha256": "5f5ceed4ec89d8eae8f1199d08b4948e9627eef7ce128e1858b208511c726e52",
630
+ "sha256": "6c896543b1a1a50ca3f591d63e364a3f0d624b8ae02ef93183e3665a27248d3c",
631
631
  "mode": 420,
632
632
  "origin": "kit"
633
633
  },
@@ -637,7 +637,7 @@
637
637
  "ownerSkill": "setup-workflow",
638
638
  "surface": "codex",
639
639
  "installRole": "consumer",
640
- "sha256": "7a72f72bc4c42401e6bfefb1253d87a2970b633d5f9e9bf975c5d486813cacd2",
640
+ "sha256": "be08d8e86f4573049c9cd809cde9417a334d364f3e823548bcd54136b1da4b01",
641
641
  "mode": 420,
642
642
  "origin": "kit"
643
643
  },
@@ -647,7 +647,7 @@
647
647
  "ownerSkill": "setup-workflow",
648
648
  "surface": "codex",
649
649
  "installRole": "consumer",
650
- "sha256": "e0dd9835e3658909132058e9a5fdd851e972220bbadaf78a57e3c6220d470922",
650
+ "sha256": "b4bf369b9c7791b9b9c76438aaa4cd9a07a0860fcc6bb9b52eebe12c6427ccbb",
651
651
  "mode": 420,
652
652
  "origin": "kit"
653
653
  },
@@ -1359,7 +1359,7 @@
1359
1359
  "ownerSkill": "diagnose",
1360
1360
  "surface": "claude",
1361
1361
  "installRole": "consumer",
1362
- "sha256": "2cdcb0ff5699dfeda8f8f9f487a8290d97d44176ddb6942638f4a9a32ecbaaad",
1362
+ "sha256": "44edd6a5af6429fd711ddbb3a4123b51a9c497573c147c2fc7b874e6e2a1336c",
1363
1363
  "mode": 420,
1364
1364
  "origin": "kit"
1365
1365
  },
@@ -1579,7 +1579,7 @@
1579
1579
  "ownerSkill": "implement",
1580
1580
  "surface": "claude",
1581
1581
  "installRole": "consumer",
1582
- "sha256": "5b55552c4a0d90fe15c9ee44abce4498faa03b73e332024e30ab69c2b7065770",
1582
+ "sha256": "dff8c4bd4a1cfd376bc5fdd9d97641301d61055fd9155bde50c1e9634a6015d2",
1583
1583
  "mode": 420,
1584
1584
  "origin": "kit"
1585
1585
  },
@@ -1679,7 +1679,7 @@
1679
1679
  "ownerSkill": "kit-release",
1680
1680
  "surface": "claude",
1681
1681
  "installRole": "maintainer",
1682
- "sha256": "d05a4059a19816ff302c119f797f1ea2b71554ecffac7a8f0b919cfe5517d940",
1682
+ "sha256": "7e08d5115d34f36da08491fa3bb8d5f4ca45e2908560e8f8c924cf3fd619d7f9",
1683
1683
  "mode": 420,
1684
1684
  "origin": "kit"
1685
1685
  },
@@ -1759,7 +1759,7 @@
1759
1759
  "ownerSkill": "orchestrate-wave",
1760
1760
  "surface": "claude",
1761
1761
  "installRole": "consumer",
1762
- "sha256": "fb244997866b4ad01db7c7de8a22467d4c8399f030d236a517b6b9cc958d9177",
1762
+ "sha256": "24c90d6048f04df29c0031cd6b643ab433536c8a176dd4983111c6be6e0fcb19",
1763
1763
  "mode": 420,
1764
1764
  "origin": "kit"
1765
1765
  },
@@ -1959,7 +1959,7 @@
1959
1959
  "ownerSkill": "setup-workflow",
1960
1960
  "surface": "claude",
1961
1961
  "installRole": "consumer",
1962
- "sha256": "5f5ceed4ec89d8eae8f1199d08b4948e9627eef7ce128e1858b208511c726e52",
1962
+ "sha256": "6c896543b1a1a50ca3f591d63e364a3f0d624b8ae02ef93183e3665a27248d3c",
1963
1963
  "mode": 420,
1964
1964
  "origin": "kit"
1965
1965
  },
@@ -1969,7 +1969,7 @@
1969
1969
  "ownerSkill": "setup-workflow",
1970
1970
  "surface": "claude",
1971
1971
  "installRole": "consumer",
1972
- "sha256": "7a72f72bc4c42401e6bfefb1253d87a2970b633d5f9e9bf975c5d486813cacd2",
1972
+ "sha256": "be08d8e86f4573049c9cd809cde9417a334d364f3e823548bcd54136b1da4b01",
1973
1973
  "mode": 420,
1974
1974
  "origin": "kit"
1975
1975
  },
@@ -1979,7 +1979,7 @@
1979
1979
  "ownerSkill": "setup-workflow",
1980
1980
  "surface": "claude",
1981
1981
  "installRole": "consumer",
1982
- "sha256": "e0dd9835e3658909132058e9a5fdd851e972220bbadaf78a57e3c6220d470922",
1982
+ "sha256": "b4bf369b9c7791b9b9c76438aaa4cd9a07a0860fcc6bb9b52eebe12c6427ccbb",
1983
1983
  "mode": 420,
1984
1984
  "origin": "kit"
1985
1985
  },
@@ -2671,7 +2671,7 @@
2671
2671
  "path": "scripts/release-state.mjs",
2672
2672
  "kind": "script",
2673
2673
  "installRole": "consumer",
2674
- "sha256": "2aae056a8e0e945dbf68298236719c78e22f13dbd8f0c3466b969355d3809058",
2674
+ "sha256": "23b4ffc77e9b499587fa563d067b1df9ae81b87a654d636281c7d139e5842687",
2675
2675
  "mode": 420,
2676
2676
  "origin": "kit"
2677
2677
  },
@@ -0,0 +1,51 @@
1
+ # Release intent is an explicit version tag
2
+
3
+ Status: accepted (2026-07-22, issue #204)
4
+
5
+ Clarified 2026-07-25 by issue #239: an explicit AFK end-to-end mandate may
6
+ authorize deterministic reversible release preparation, but never the
7
+ publication tag itself.
8
+
9
+ The release workflow currently publishes whenever a merge to `main` changes
10
+ `package.json`. That keeps the canonical branch and npm close together, but it
11
+ also makes an ordinary merge an irreversible public action whose consequence is
12
+ not visible in the merge affordance. A failed post-publish readback can further
13
+ make the run red after npm has already accepted the version.
14
+
15
+ We decided that integration and publication are separate transitions:
16
+
17
+ 1. Merging a prepared version into `main` integrates it but does not publish it.
18
+ 2. A matching annotated `v<version>` tag on that `main` commit records Release
19
+ intent and starts the normal publish workflow.
20
+ 3. The workflow validates tag, package version, canonical-branch ancestry,
21
+ artifact integrity, and tests before publishing.
22
+ 4. A manual dispatch is a recovery/reconciliation route for one explicit tag,
23
+ never the ordinary release trigger.
24
+ 5. Publication completes only when npm and the GitHub release are both visible
25
+ and match the local version, manifest, and tarball. Eventual-consistency
26
+ readbacks use bounded retry and preserve an externally reconstructable state.
27
+
28
+ ## Considered options
29
+
30
+ - **Merge-triggered publishing:** rejected because merge acceptance and
31
+ irreversible publication remain one implicit action.
32
+ - **Manual dispatch as the normal route:** rejected because the release identity
33
+ is easier to forget and less durable than a version tag.
34
+ - **PR label or commit marker:** rejected because it introduces hidden release
35
+ semantics without giving the publication a first-class Git identity.
36
+ - **Environment approval on the merge-triggered workflow:** rejected as the
37
+ primary contract because it adds a UI gate but still does not represent the
38
+ approved version as a durable release object.
39
+
40
+ ## Consequences
41
+
42
+ - A prepared version may temporarily exist on `main` in an `awaiting-tag` state.
43
+ - An explicit AFK Wave/Program mandate may cover the deterministic reversible
44
+ metadata preparation and landing that it names. It never by itself authorizes
45
+ creating or pushing the publication tag; that irreversible action requires a
46
+ separate explicit confirmation.
47
+ - A single-Wave mandate does not authorize the next Program Wave. A later
48
+ explicit whole-Program mandate does authorize all planned Waves and remains in
49
+ force across their boundaries.
50
+ - Reruns reconcile the same tag and never infer that a red workflow means
51
+ nothing was published.
@@ -10,3 +10,14 @@ Issues and PRDs for this repo live in `iKon85/agent-workflow-kit` as GitHub issu
10
10
  - Route board writes, status/wave/cluster field edits, dependencies, and sub-issue links through `scripts/board-sync.py`, not bare `gh project` commands.
11
11
  - Board state is authoritative for workflow status; GitHub issues remain the durable content source.
12
12
 
13
+ ## Pickup claim
14
+
15
+ Used by `/implement`, `/diagnose`, and `/orchestrate-wave` before the first
16
+ edit. Board status `In Progress` is the human-facing signal; the claim below is
17
+ the machine-readable one, and it is the only one that names the branch and
18
+ worktree a colliding session would have to find.
19
+
20
+ - **Check**: `gh issue view <n> --json assignees,comments --jq '{assignees: [.assignees[].login], claims: [.comments[].body | select(contains("<!-- agent-claim:"))]}'` — a foreign assignee or a marker this session did not plant is a foreign claim: stop, report the claimed branch/worktree, and leave it alone.
21
+ - **Claim**: `gh issue edit <n> --add-assignee @me`, then `gh issue comment <n> --body '<!-- agent-claim: branch=<branch>; worktree=<absolute-path>; date=<YYYY-MM-DD> -->'`. Board status stays a `scripts/board-sync.py` write, never a bare `gh project` call.
22
+ - **Release**: the slice PR (`closes #<n>` / `Part of #<anchor>`) supersedes the claim, so `/wrapup` needs no extra step. On abandon: `gh issue edit <n> --remove-assignee @me` plus a `claim released` comment.
23
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ikon85/agent-workflow-kit",
3
- "version": "0.34.0",
3
+ "version": "0.34.3",
4
4
  "description": "Portable AI-agent workflow skills (plan → execute → land → learn) for Claude Code & Codex — grilling, TDD, diagnosis, two-axis code review, cross-model Codex review, design & domain-modeling, plus a skill router (ask-matt). npx init/update/diff/uninstall.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,12 +1,170 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { execFile } from 'node:child_process';
3
4
  import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { dirname, join } from 'node:path';
7
+ import { promisify } from 'node:util';
6
8
  import { fileURLToPath } from 'node:url';
7
9
  import { nextVersion, prepareRelease } from './kit-release.mjs';
8
10
 
9
11
  const REPO = join(dirname(fileURLToPath(import.meta.url)), '..');
12
+ const exec = promisify(execFile);
13
+
14
+ async function releaseWorkflow() {
15
+ return readFile(join(REPO, '.github/workflows/release.yml'), 'utf8');
16
+ }
17
+
18
+ function workflowRunScript(workflow, stepName) {
19
+ const lines = workflow.split('\n');
20
+ const nameIndex = lines.findIndex((line) => line.trim() === `- name: ${stepName}`);
21
+ assert.notEqual(nameIndex, -1, `workflow step not found: ${stepName}`);
22
+ const runIndex = lines.findIndex(
23
+ (line, index) => index > nameIndex && line.trim() === 'run: |',
24
+ );
25
+ assert.notEqual(runIndex, -1, `run block not found for workflow step: ${stepName}`);
26
+ const indent = lines[runIndex].match(/^\s*/)[0].length + 2;
27
+ const body = [];
28
+ for (const line of lines.slice(runIndex + 1)) {
29
+ if (line.trim() && line.match(/^\s*/)[0].length < indent) break;
30
+ body.push(line.slice(indent));
31
+ }
32
+ return body.join('\n');
33
+ }
34
+
35
+ async function git(runCwd, args, options = {}) {
36
+ return exec('git', args, { cwd: runCwd, ...options });
37
+ }
38
+
39
+ async function releaseIntentFixture({
40
+ packageVersion = '1.2.3',
41
+ annotated = true,
42
+ onMain = true,
43
+ mainAhead = false,
44
+ } = {}) {
45
+ const root = await mkdtemp(join(tmpdir(), 'kit-release-intent-'));
46
+ const origin = join(root, 'origin.git');
47
+ const repo = join(root, 'repo');
48
+ await git(root, ['init', '--bare', origin]);
49
+ await git(root, ['init', '--initial-branch=main', repo]);
50
+ await git(repo, ['config', 'user.name', 'Release Test']);
51
+ await git(repo, ['config', 'user.email', 'release-test@example.invalid']);
52
+ await writeFile(join(repo, 'package.json'), `${JSON.stringify({
53
+ name: '@ikon85/agent-workflow-kit',
54
+ version: packageVersion,
55
+ }, null, 2)}\n`);
56
+ await git(repo, ['add', 'package.json']);
57
+ await git(repo, ['commit', '-m', 'release fixture']);
58
+ await git(repo, ['remote', 'add', 'origin', origin]);
59
+ await git(repo, ['push', '-u', 'origin', 'main']);
60
+ if (!onMain) {
61
+ await git(repo, ['checkout', '-b', 'not-main']);
62
+ await writeFile(join(repo, 'outside-main.txt'), 'not canonical\n');
63
+ await git(repo, ['add', 'outside-main.txt']);
64
+ await git(repo, ['commit', '-m', 'outside main']);
65
+ }
66
+ const tag = 'v1.2.3';
67
+ await git(repo, annotated
68
+ ? ['tag', '-a', tag, '-m', `Release ${tag}`]
69
+ : ['tag', tag]);
70
+ if (mainAhead) {
71
+ await writeFile(join(repo, 'after-release.txt'), 'main moved\n');
72
+ await git(repo, ['add', 'after-release.txt']);
73
+ await git(repo, ['commit', '-m', 'move canonical main']);
74
+ await git(repo, ['push', 'origin', 'main']);
75
+ }
76
+ return { root, repo, tag };
77
+ }
78
+
79
+ async function validateReleaseIntent(fixture, releaseTag = fixture.tag) {
80
+ const workflow = await releaseWorkflow();
81
+ const script = workflowRunScript(workflow, 'Validate release intent');
82
+ const output = join(fixture.root, 'github-output');
83
+ try {
84
+ const result = await exec('bash', ['-euo', 'pipefail', '-c', script], {
85
+ cwd: fixture.repo,
86
+ env: {
87
+ ...process.env,
88
+ GITHUB_OUTPUT: output,
89
+ RELEASE_TAG: releaseTag,
90
+ },
91
+ });
92
+ return { ...result, output: await readFile(output, 'utf8') };
93
+ } catch (error) {
94
+ return { error, stderr: error.stderr ?? '', stdout: error.stdout ?? '' };
95
+ }
96
+ }
97
+
98
+ test('release workflow treats an annotated version tag as normal intent and merge as integration only', async () => {
99
+ const workflow = await releaseWorkflow();
100
+ assert.match(workflow, /push:\s*\n\s+tags:\s*\n\s+- ['"]v\*\.\*\.\*['"]/);
101
+ assert.doesNotMatch(workflow, /push:\s*\n\s+branches:\s*\[main\]/);
102
+ assert.match(workflow, /workflow_dispatch:\s*\n\s+inputs:\s*\n\s+tag:/);
103
+ assert.match(workflow, /tag:[\s\S]*?required:\s*true/);
104
+ assert.match(
105
+ workflow,
106
+ /group:\s*release-\$\{\{ github\.event_name == 'workflow_dispatch' && inputs\.tag \|\| github\.ref_name \}\}/,
107
+ );
108
+ assert.doesNotMatch(workflow, /git diff --name-only[\s\S]*package\.json/);
109
+ assert.ok(
110
+ workflow.indexOf('- name: Validate release intent')
111
+ < workflow.indexOf('node scripts/release-state.mjs'),
112
+ 'release intent must be validated before the reconciler can publish',
113
+ );
114
+ assert.equal(workflow.match(/node scripts\/release-state\.mjs/g)?.length, 1);
115
+ });
116
+
117
+ test('the same pre-publish validator accepts a matching annotated tag', async () => {
118
+ const fixture = await releaseIntentFixture();
119
+ try {
120
+ const result = await validateReleaseIntent(fixture);
121
+ assert.equal(result.error, undefined, result.stderr);
122
+ assert.match(result.output, /^tag=v1\.2\.3$/m);
123
+ assert.match(result.output, /^commit=[0-9a-f]{40}$/m);
124
+ } finally {
125
+ await rm(fixture.root, { recursive: true, force: true });
126
+ }
127
+ });
128
+
129
+ for (const scenario of [
130
+ {
131
+ name: 'missing manual recovery tag',
132
+ options: {},
133
+ tag: 'v9.9.9',
134
+ error: /does not exist/,
135
+ },
136
+ {
137
+ name: 'tag/package version mismatch',
138
+ options: { packageVersion: '1.2.4' },
139
+ error: /does not match package version/,
140
+ },
141
+ {
142
+ name: 'lightweight tag',
143
+ options: { annotated: false },
144
+ error: /must be annotated/,
145
+ },
146
+ {
147
+ name: 'tag outside canonical main ancestry',
148
+ options: { onMain: false },
149
+ error: /not an ancestor of origin\/main/,
150
+ },
151
+ {
152
+ name: 'stale canonical main commit with the same package version',
153
+ options: { mainAhead: true },
154
+ error: /does not identify the current origin\/main commit/,
155
+ },
156
+ ]) {
157
+ test(`the pre-publish validator rejects a ${scenario.name}`, async () => {
158
+ const fixture = await releaseIntentFixture(scenario.options);
159
+ try {
160
+ const result = await validateReleaseIntent(fixture, scenario.tag);
161
+ assert.ok(result.error, 'validation unexpectedly succeeded');
162
+ assert.match(`${result.stderr}\n${result.stdout}`, scenario.error);
163
+ } finally {
164
+ await rm(fixture.root, { recursive: true, force: true });
165
+ }
166
+ });
167
+ }
10
168
 
11
169
  test('the install manifest ships both release primitives named by the skill', async () => {
12
170
  const manifest = JSON.parse(await readFile(join(REPO, 'agent-workflow-kit.package.json')));
@@ -20,11 +178,43 @@ test('the install manifest ships both release primitives named by the skill', as
20
178
  test('both release skill surfaces name only the owned scoped npm package', async () => {
21
179
  const claude = await readFile(join(REPO, '.claude/skills/kit-release/SKILL.md'), 'utf8');
22
180
  const codex = await readFile(join(REPO, '.agents/skills/kit-release/SKILL.md'), 'utf8');
181
+ assert.equal(codex, claude);
23
182
  for (const body of [claude, codex]) {
24
183
  assert.match(body, /`@ikon85\/agent-workflow-kit`/);
25
184
  }
26
185
  });
27
186
 
187
+ test('an explicit AFK scope can authorize reversible preparation but never publication', async () => {
188
+ const claude = await readFile(join(REPO, '.claude/skills/kit-release/SKILL.md'), 'utf8');
189
+ const codex = await readFile(join(REPO, '.agents/skills/kit-release/SKILL.md'), 'utf8');
190
+ assert.equal(codex, claude);
191
+ for (const body of [claude, codex]) {
192
+ assert.match(body, /explicit AFK end-to-end mandate/i);
193
+ assert.match(body, /deterministic recommendation/i);
194
+ assert.match(body, /reversible metadata preparation/i);
195
+ assert.match(body, /annotated `v<version>` tag[\s\S]*separate explicit confirmation/i);
196
+ }
197
+ });
198
+
199
+ test('maintainer docs and the accepted ADR agree that merge integrates and an annotated tag publishes', async () => {
200
+ const paths = [
201
+ 'CLAUDE.md',
202
+ 'AGENTS.md',
203
+ 'README.md',
204
+ '.claude/skills/kit-release/SKILL.md',
205
+ '.agents/skills/kit-release/SKILL.md',
206
+ 'docs/adr/0004-release-intent-is-a-version-tag.md',
207
+ ];
208
+ const bodies = await Promise.all(
209
+ paths.map((path) => readFile(join(REPO, path), 'utf8')),
210
+ );
211
+ for (const [index, body] of bodies.entries()) {
212
+ assert.match(body, /annotated\s+[`*]?v<version>[`*]?\s+tag/i, paths[index]);
213
+ assert.match(body, /integrat/i, paths[index]);
214
+ }
215
+ assert.match(bodies.at(-1), /Status: accepted \(2026-07-22, issue #204\)/);
216
+ });
217
+
28
218
  test('patch, minor, and major confirmations select exactly one target version', () => {
29
219
  assert.equal(nextVersion('1.2.3', 'patch'), '1.2.4');
30
220
  assert.equal(nextVersion('1.2.3', 'minor'), '1.3.0');
@@ -2,11 +2,18 @@ import { execFile } from 'node:child_process';
2
2
  import { mkdtemp, rm } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
5
6
  import { promisify } from 'node:util';
6
7
  import { fileURLToPath } from 'node:url';
7
8
  import { assertReleaseParity, releaseIdentityFromTarball } from './release-parity.mjs';
8
9
 
9
10
  const exec = promisify(execFile);
11
+ const DEFAULT_VISIBILITY = {
12
+ attempts: 6,
13
+ initialDelayMs: 1_000,
14
+ backoffFactor: 2,
15
+ sleep,
16
+ };
10
17
 
11
18
  const assertMatches = (local, remote, label) => assertReleaseParity({
12
19
  local,
@@ -28,7 +35,24 @@ export async function inspectRelease(adapter) {
28
35
  return { status: 'released', identity };
29
36
  }
30
37
 
31
- export async function reconcileRelease(adapter) {
38
+ async function awaitVisibility(read, phase, options = {}) {
39
+ const policy = { ...DEFAULT_VISIBILITY, ...options };
40
+ let delay = policy.initialDelayMs;
41
+ for (let attempt = 1; attempt <= policy.attempts; attempt += 1) {
42
+ const visible = await read();
43
+ if (visible) return visible;
44
+ if (attempt < policy.attempts) {
45
+ await policy.sleep(delay);
46
+ delay *= policy.backoffFactor;
47
+ }
48
+ }
49
+ throw new Error(
50
+ `${phase.operation} succeeded but ${phase.subject} was not visible `
51
+ + `after ${policy.attempts} ${phase.service} read attempts`,
52
+ );
53
+ }
54
+
55
+ export async function reconcileRelease(adapter, { visibility } = {}) {
32
56
  const { identity, tarball } = await adapter.local();
33
57
  let npm = await adapter.npm(identity);
34
58
  let github = await adapter.github(identity);
@@ -36,15 +60,21 @@ export async function reconcileRelease(adapter) {
36
60
  if (!npm) {
37
61
  if (github) throw new Error('GitHub release exists before npm package');
38
62
  await adapter.publishNpm({ identity, tarball });
39
- npm = await adapter.npm(identity);
40
- if (!npm) throw new Error('npm package missing after publish');
63
+ npm = await awaitVisibility(() => adapter.npm(identity), {
64
+ service: 'npm',
65
+ operation: 'npm publish',
66
+ subject: 'package',
67
+ }, visibility);
41
68
  }
42
69
  assertMatches(identity, npm, 'npm');
43
70
 
44
71
  if (!github) {
45
72
  await adapter.createGithub({ identity, tarball });
46
- github = await adapter.github(identity);
47
- if (!github) throw new Error('GitHub release missing after create');
73
+ github = await awaitVisibility(() => adapter.github(identity), {
74
+ service: 'GitHub',
75
+ operation: 'GitHub release creation',
76
+ subject: 'release',
77
+ }, visibility);
48
78
  }
49
79
  assertReleaseParity({ local: identity, npm, github });
50
80
  return { status: 'released', identity };
@@ -18,17 +18,62 @@ const identity = {
18
18
  tarballIntegrity: 'sha512-example', manifestSha256: 'abc123',
19
19
  };
20
20
 
21
- function adapter({ npmPublished = false, githubReleased = false, npmIdentity = identity } = {}) {
21
+ function adapter({
22
+ npmPublished = false,
23
+ githubReleased = false,
24
+ npmIdentity = identity,
25
+ npmInvisibleReads = 0,
26
+ githubInvisibleReads = 0,
27
+ publishNpmError,
28
+ createGithubError,
29
+ } = {}) {
22
30
  const events = [];
23
31
  let npmExists = npmPublished;
24
32
  let githubExists = githubReleased;
33
+ let npmReadsUntilVisible = npmInvisibleReads;
34
+ let githubReadsUntilVisible = githubInvisibleReads;
25
35
  return {
26
36
  events,
27
37
  local: async () => ({ identity, tarball: '/tmp/kit.tgz' }),
28
- npm: async () => { events.push('read npm'); return npmExists ? npmIdentity : null; },
29
- github: async () => { events.push('read github'); return githubExists ? identity : null; },
30
- publishNpm: async () => { events.push('publish npm'); npmExists = true; },
31
- createGithub: async () => { events.push('create github'); githubExists = true; },
38
+ npm: async () => {
39
+ events.push('read npm');
40
+ if (!npmExists) return null;
41
+ if (npmReadsUntilVisible > 0) {
42
+ npmReadsUntilVisible -= 1;
43
+ return null;
44
+ }
45
+ return npmIdentity;
46
+ },
47
+ github: async () => {
48
+ events.push('read github');
49
+ if (!githubExists) return null;
50
+ if (githubReadsUntilVisible > 0) {
51
+ githubReadsUntilVisible -= 1;
52
+ return null;
53
+ }
54
+ return identity;
55
+ },
56
+ publishNpm: async () => {
57
+ events.push('publish npm');
58
+ if (publishNpmError) throw publishNpmError;
59
+ npmExists = true;
60
+ },
61
+ createGithub: async () => {
62
+ events.push('create github');
63
+ if (createGithubError) throw createGithubError;
64
+ githubExists = true;
65
+ },
66
+ };
67
+ }
68
+
69
+ function visibilityOptions(attempts, delays = []) {
70
+ return {
71
+ visibility: {
72
+ attempts,
73
+ initialDelayMs: 10,
74
+ backoffFactor: 2,
75
+ sleep: async (delay) => delays.push(delay),
76
+ },
32
77
  };
33
78
  }
34
79
 
@@ -40,6 +85,72 @@ test('a fresh release publishes npm, verifies registry readback, then creates Gi
40
85
  ]);
41
86
  });
42
87
 
88
+ test('npm visibility is retried with bounded backoff after a successful publish', async () => {
89
+ const fixture = adapter({ npmInvisibleReads: 2 });
90
+ const delays = [];
91
+ assert.deepEqual(
92
+ await reconcileRelease(fixture, visibilityOptions(4, delays)),
93
+ { status: 'released', identity },
94
+ );
95
+ assert.deepEqual(fixture.events, [
96
+ 'read npm', 'read github', 'publish npm',
97
+ 'read npm', 'read npm', 'read npm',
98
+ 'create github', 'read github',
99
+ ]);
100
+ assert.deepEqual(delays, [10, 20]);
101
+ });
102
+
103
+ test('GitHub visibility is retried with bounded backoff after a successful create', async () => {
104
+ const fixture = adapter({ npmPublished: true, githubInvisibleReads: 2 });
105
+ const delays = [];
106
+ assert.deepEqual(
107
+ await reconcileRelease(fixture, visibilityOptions(4, delays)),
108
+ { status: 'released', identity },
109
+ );
110
+ assert.deepEqual(fixture.events, [
111
+ 'read npm', 'read github', 'create github', 'read github', 'read github', 'read github',
112
+ ]);
113
+ assert.deepEqual(delays, [10, 20]);
114
+ });
115
+
116
+ test('permanent npm absence fails at the bound with post-publish phase evidence', async () => {
117
+ const fixture = adapter({ npmInvisibleReads: 4 });
118
+ const delays = [];
119
+ await assert.rejects(
120
+ reconcileRelease(fixture, visibilityOptions(3, delays)),
121
+ /npm publish succeeded but package was not visible after 3 npm read attempts/,
122
+ );
123
+ assert.deepEqual(delays, [10, 20]);
124
+ assert.equal(fixture.events.filter((event) => event === 'read npm').length, 4);
125
+ assert.ok(!fixture.events.includes('create github'));
126
+ });
127
+
128
+ test('permanent GitHub absence fails at the bound with post-create phase evidence', async () => {
129
+ const fixture = adapter({ npmPublished: true, githubInvisibleReads: 4 });
130
+ const delays = [];
131
+ await assert.rejects(
132
+ reconcileRelease(fixture, visibilityOptions(3, delays)),
133
+ /GitHub release creation succeeded but release was not visible after 3 GitHub read attempts/,
134
+ );
135
+ assert.deepEqual(delays, [10, 20]);
136
+ assert.equal(fixture.events.filter((event) => event === 'read github').length, 4);
137
+ assert.ok(!fixture.events.includes('publish npm'));
138
+ });
139
+
140
+ test('a rejected publish is not reported as a post-publish visibility timeout', async () => {
141
+ const rejected = new Error('registry rejected publish');
142
+ const fixture = adapter({ publishNpmError: rejected });
143
+ await assert.rejects(reconcileRelease(fixture, visibilityOptions(3)), (error) => error === rejected);
144
+ assert.deepEqual(fixture.events, ['read npm', 'read github', 'publish npm']);
145
+ });
146
+
147
+ test('a rejected GitHub create is not reported as a post-create visibility timeout', async () => {
148
+ const rejected = new Error('GitHub rejected release create');
149
+ const fixture = adapter({ npmPublished: true, createGithubError: rejected });
150
+ await assert.rejects(reconcileRelease(fixture, visibilityOptions(3)), (error) => error === rejected);
151
+ assert.deepEqual(fixture.events, ['read npm', 'read github', 'create github']);
152
+ });
153
+
43
154
  test('an npm-published release resumes at GitHub without a second npm publish', async () => {
44
155
  const fixture = adapter({ npmPublished: true });
45
156
  await reconcileRelease(fixture);
@@ -47,6 +158,12 @@ test('an npm-published release resumes at GitHub without a second npm publish',
47
158
  assert.ok(!fixture.events.includes('publish npm'));
48
159
  });
49
160
 
161
+ test('an already-published release at parity is a read-only no-op', async () => {
162
+ const fixture = adapter({ npmPublished: true, githubReleased: true });
163
+ assert.deepEqual(await reconcileRelease(fixture), { status: 'released', identity });
164
+ assert.deepEqual(fixture.events, ['read npm', 'read github']);
165
+ });
166
+
50
167
  test('post-merge status inspection is read-only and reports the reconstructable phase', async () => {
51
168
  const fixture = adapter({ npmPublished: true });
52
169
  assert.deepEqual(await inspectRelease(fixture), { status: 'awaiting-github', identity });