@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.
- package/.agents/skills/kit-release/SKILL.md +29 -4
- package/.claude/skills/kit-release/SKILL.md +29 -4
- package/README.md +10 -2
- package/agent-workflow-kit.package.json +4 -4
- package/docs/adr/0004-release-intent-is-a-version-tag.md +42 -0
- package/package.json +1 -1
- package/scripts/kit-release.test.mjs +178 -0
- package/scripts/release-state.mjs +35 -5
- package/scripts/release-state.test.mjs +122 -5
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kit-release
|
|
3
|
-
description: "Prepare a verified
|
|
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,
|
|
10
|
-
|
|
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,
|
|
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
|
|
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,
|
|
10
|
-
|
|
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,
|
|
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
|
|
28
|
-
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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 () => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 });
|