@ikon85/agent-workflow-kit 0.34.0 → 0.34.1

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,13 +1,14 @@
1
1
  ---
2
2
  name: kit-release
3
- description: "Prepare a verified release PR for agent-workflow-kit, delegate landing to wrapup, then monitor the trusted post-merge publishing flow through npm/GitHub parity."
3
+ description: "Prepare and integrate a verified agent-workflow-kit release, then record separately confirmed publication intent with an annotated version tag and monitor npm/GitHub parity."
4
4
  ---
5
5
 
6
6
  # Kit Release
7
7
 
8
8
  Prepare a release deterministically. This skill owns the shipped-delta decision,
9
- metadata preparation, and verification. It does not implement commit, push, PR,
10
- merge, registry publishing, tags, or release creation.
9
+ metadata preparation, verification, and the separate post-merge publication
10
+ gate. It delegates commit, branch push, PR, merge, and cleanup to wrapup. It
11
+ never publishes to a registry or creates a GitHub release directly.
11
12
 
12
13
  ## Workflow
13
14
 
@@ -42,7 +43,20 @@ merge, registry publishing, tags, or release creation.
42
43
  `scripts/wrapup-land.py` exclusively own commit, push, PR creation, merge,
43
44
  and cleanup. Do not reproduce those operations here.
44
45
 
45
- 5. After merge, monitor the `release.yml` workflow and inspect its externally
46
+ 5. After merge, report the exact integrated version and commit as
47
+ `awaiting-tag`. Merging integrates the prepared release; it cannot start
48
+ publication. Ask the user for a second, explicit confirmation to publish
49
+ that exact `v<version>`. The earlier Semver confirmation authorized metadata
50
+ preparation, not publication.
51
+
52
+ 6. After that confirmation, verify that the package version on current
53
+ `origin/main` is exactly `<version>`. Create a matching annotated
54
+ `v<version>` tag on that commit and push only that tag. A lightweight tag,
55
+ mismatching version, or commit outside canonical `main` is invalid release
56
+ intent. Never infer a tag target, move an existing tag, or tag an unmerged
57
+ commit.
58
+
59
+ 7. Monitor the tag-triggered `release.yml` workflow and inspect its externally
46
60
  reconstructable state:
47
61
 
48
62
  ```sh
@@ -56,6 +70,17 @@ merge, registry publishing, tags, or release creation.
56
70
  release asset have identical version, manifest hash, and tarball integrity.
57
71
  The release is complete only at `released`.
58
72
 
73
+ Manual dispatch is recovery only. It requires the explicit existing
74
+ annotated tag and runs the same idempotent reconciler:
75
+
76
+ ```sh
77
+ gh workflow run release.yml -f tag=v<version>
78
+ ```
79
+
80
+ Use it only after reconstructing the release state for that tag. Never use
81
+ dispatch as the normal publication route or substitute a new version for a
82
+ partial release.
83
+
59
84
  The registry identity is **`@ikon85/agent-workflow-kit`**. The unscoped
60
85
  `agent-workflow-kit` package is owned by another publisher and must never be
61
86
  queried, published, or treated as this repository's release.
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  name: kit-release
3
- description: "Prepare a verified release PR for agent-workflow-kit, delegate landing to wrapup, then monitor the trusted post-merge publishing flow through npm/GitHub parity."
3
+ description: "Prepare and integrate a verified agent-workflow-kit release, then record separately confirmed publication intent with an annotated version tag and monitor npm/GitHub parity."
4
4
  ---
5
5
 
6
6
  # Kit Release
7
7
 
8
8
  Prepare a release deterministically. This skill owns the shipped-delta decision,
9
- metadata preparation, and verification. It does not implement commit, push, PR,
10
- merge, registry publishing, tags, or release creation.
9
+ metadata preparation, verification, and the separate post-merge publication
10
+ gate. It delegates commit, branch push, PR, merge, and cleanup to wrapup. It
11
+ never publishes to a registry or creates a GitHub release directly.
11
12
 
12
13
  ## Workflow
13
14
 
@@ -42,7 +43,20 @@ merge, registry publishing, tags, or release creation.
42
43
  `scripts/wrapup-land.py` exclusively own commit, push, PR creation, merge,
43
44
  and cleanup. Do not reproduce those operations here.
44
45
 
45
- 5. After merge, monitor the `release.yml` workflow and inspect its externally
46
+ 5. After merge, report the exact integrated version and commit as
47
+ `awaiting-tag`. Merging integrates the prepared release; it cannot start
48
+ publication. Ask the user for a second, explicit confirmation to publish
49
+ that exact `v<version>`. The earlier Semver confirmation authorized metadata
50
+ preparation, not publication.
51
+
52
+ 6. After that confirmation, verify that the package version on current
53
+ `origin/main` is exactly `<version>`. Create a matching annotated
54
+ `v<version>` tag on that commit and push only that tag. A lightweight tag,
55
+ mismatching version, or commit outside canonical `main` is invalid release
56
+ intent. Never infer a tag target, move an existing tag, or tag an unmerged
57
+ commit.
58
+
59
+ 7. Monitor the tag-triggered `release.yml` workflow and inspect its externally
46
60
  reconstructable state:
47
61
 
48
62
  ```sh
@@ -56,6 +70,17 @@ merge, registry publishing, tags, or release creation.
56
70
  release asset have identical version, manifest hash, and tarball integrity.
57
71
  The release is complete only at `released`.
58
72
 
73
+ Manual dispatch is recovery only. It requires the explicit existing
74
+ annotated tag and runs the same idempotent reconciler:
75
+
76
+ ```sh
77
+ gh workflow run release.yml -f tag=v<version>
78
+ ```
79
+
80
+ Use it only after reconstructing the release state for that tag. Never use
81
+ dispatch as the normal publication route or substitute a new version for a
82
+ partial release.
83
+
59
84
  The registry identity is **`@ikon85/agent-workflow-kit`**. The unscoped
60
85
  `agent-workflow-kit` package is owned by another publisher and must never be
61
86
  queried, published, or treated as this repository's release.
package/README.md CHANGED
@@ -24,8 +24,10 @@ changes.
24
24
  Maintainers prepare releases with `/kit-release`. It derives the shipped delta
25
25
  from a fresh manifest, recommends Semver, applies only the confirmed target,
26
26
  regenerates the checked-in manifest, and runs the full test and pack gates.
27
- Landing remains owned by `/wrapup`; publishing the matching npm package and
28
- GitHub release happens only through the configured post-merge release flow.
27
+ Landing remains owned by `/wrapup` and only integrates the prepared version.
28
+ After a separate publication confirmation, a matching annotated `v<version>`
29
+ tag on canonical `main` starts the trusted publish flow. Manual dispatch
30
+ requires an explicit existing tag and only reconciles an incomplete release.
29
31
 
30
32
  These are the skills, helper scripts, and conventions one team actually uses to
31
33
  take work from a vague idea to a merged, verified PR with [Claude Code] and
@@ -385,6 +387,12 @@ the old way. Decision record:
385
387
 
386
388
  ## Release notes
387
389
 
390
+ ### 0.34.1
391
+
392
+ - changed: `.agents/skills/kit-release/SKILL.md`
393
+ - changed: `.claude/skills/kit-release/SKILL.md`
394
+ - changed: `scripts/release-state.mjs`
395
+
388
396
  ### 0.34.0
389
397
 
390
398
  - added: `src/commands/routing-policy-update.mjs`
@@ -1,5 +1,5 @@
1
1
  {
2
- "kitVersion": "0.34.0",
2
+ "kitVersion": "0.34.1",
3
3
  "files": [
4
4
  {
5
5
  "path": ".agents/skills/ask-matt/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  "ownerSkill": "kit-release",
368
368
  "surface": "codex",
369
369
  "installRole": "maintainer",
370
- "sha256": "d05a4059a19816ff302c119f797f1ea2b71554ecffac7a8f0b919cfe5517d940",
370
+ "sha256": "168879858199f19f124e8bc3722f430f65b44b1fb6409d9c2112e6ec02d9b561",
371
371
  "mode": 420,
372
372
  "origin": "kit"
373
373
  },
@@ -1679,7 +1679,7 @@
1679
1679
  "ownerSkill": "kit-release",
1680
1680
  "surface": "claude",
1681
1681
  "installRole": "maintainer",
1682
- "sha256": "d05a4059a19816ff302c119f797f1ea2b71554ecffac7a8f0b919cfe5517d940",
1682
+ "sha256": "168879858199f19f124e8bc3722f430f65b44b1fb6409d9c2112e6ec02d9b561",
1683
1683
  "mode": 420,
1684
1684
  "origin": "kit"
1685
1685
  },
@@ -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,42 @@
1
+ # Release intent is an explicit version tag
2
+
3
+ Status: accepted (2026-07-22, issue #204)
4
+
5
+ The release workflow currently publishes whenever a merge to `main` changes
6
+ `package.json`. That keeps the canonical branch and npm close together, but it
7
+ also makes an ordinary merge an irreversible public action whose consequence is
8
+ not visible in the merge affordance. A failed post-publish readback can further
9
+ make the run red after npm has already accepted the version.
10
+
11
+ We decided that integration and publication are separate transitions:
12
+
13
+ 1. Merging a prepared version into `main` integrates it but does not publish it.
14
+ 2. A matching annotated `v<version>` tag on that `main` commit records Release
15
+ intent and starts the normal publish workflow.
16
+ 3. The workflow validates tag, package version, canonical-branch ancestry,
17
+ artifact integrity, and tests before publishing.
18
+ 4. A manual dispatch is a recovery/reconciliation route for one explicit tag,
19
+ never the ordinary release trigger.
20
+ 5. Publication completes only when npm and the GitHub release are both visible
21
+ and match the local version, manifest, and tarball. Eventual-consistency
22
+ readbacks use bounded retry and preserve an externally reconstructable state.
23
+
24
+ ## Considered options
25
+
26
+ - **Merge-triggered publishing:** rejected because merge acceptance and
27
+ irreversible publication remain one implicit action.
28
+ - **Manual dispatch as the normal route:** rejected because the release identity
29
+ is easier to forget and less durable than a version tag.
30
+ - **PR label or commit marker:** rejected because it introduces hidden release
31
+ semantics without giving the publication a first-class Git identity.
32
+ - **Environment approval on the merge-triggered workflow:** rejected as the
33
+ primary contract because it adds a UI gate but still does not represent the
34
+ approved version as a durable release object.
35
+
36
+ ## Consequences
37
+
38
+ - A prepared version may temporarily exist on `main` in an `awaiting-tag` state.
39
+ - An AFK wave may create its own tag after all pre-authorized landing gates pass;
40
+ the next program wave still requires separate authorization.
41
+ - Reruns reconcile the same tag and never infer that a red workflow means
42
+ nothing was published.
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.1",
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,31 @@ 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('maintainer docs and the accepted ADR agree that merge integrates and an annotated tag publishes', async () => {
188
+ const paths = [
189
+ 'CLAUDE.md',
190
+ 'AGENTS.md',
191
+ 'README.md',
192
+ '.claude/skills/kit-release/SKILL.md',
193
+ '.agents/skills/kit-release/SKILL.md',
194
+ 'docs/adr/0004-release-intent-is-a-version-tag.md',
195
+ ];
196
+ const bodies = await Promise.all(
197
+ paths.map((path) => readFile(join(REPO, path), 'utf8')),
198
+ );
199
+ for (const [index, body] of bodies.entries()) {
200
+ assert.match(body, /annotated\s+[`*]?v<version>[`*]?\s+tag/i, paths[index]);
201
+ assert.match(body, /integrat/i, paths[index]);
202
+ }
203
+ assert.match(bodies.at(-1), /Status: accepted \(2026-07-22, issue #204\)/);
204
+ });
205
+
28
206
  test('patch, minor, and major confirmations select exactly one target version', () => {
29
207
  assert.equal(nextVersion('1.2.3', 'patch'), '1.2.4');
30
208
  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 });