@ikon85/agent-workflow-kit 0.12.0 → 0.14.0
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/setup-workflow/SKILL.md +35 -0
- package/.agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml +31 -0
- package/.claude/skills/setup-workflow/SKILL.md +35 -0
- package/.claude/skills/setup-workflow/assets/agent-workflow-kit-update.yml +31 -0
- package/README.md +17 -0
- package/agent-workflow-kit.package.json +70 -3
- package/package.json +3 -2
- package/scripts/build-kit.test.mjs +25 -0
- package/scripts/census/census.test.mjs +252 -0
- package/scripts/census/delta.mjs +14 -0
- package/scripts/census/fingerprint.mjs +20 -0
- package/scripts/census/index.mjs +9 -0
- package/scripts/census/scan.mjs +174 -0
- package/scripts/census/state.mjs +23 -0
- package/scripts/census/state.test.mjs +15 -0
- package/scripts/census/transaction.mjs +50 -0
- package/scripts/census/transaction.test.mjs +103 -0
- package/scripts/kit-update-pr.mjs +115 -0
- package/scripts/kit-update-pr.test.mjs +99 -0
- package/scripts/test_skill_setup_workflow_seeds.py +65 -0
- package/src/lib/bundle.mjs +11 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { fingerprintCensus, sha256 } from './fingerprint.mjs';
|
|
6
|
+
import { CENSUS_VERDICTS, resolveCensusState } from './state.mjs';
|
|
7
|
+
|
|
8
|
+
const exec = promisify(execFile);
|
|
9
|
+
const EVIDENCE_PARTS = new Set(['docs', 'test', 'tests', '__tests__']);
|
|
10
|
+
const EXCLUDED_PARTS = new Set([
|
|
11
|
+
'.git', '.next', 'build', 'coverage', 'dist', 'generated', 'node_modules', 'vendor',
|
|
12
|
+
]);
|
|
13
|
+
const CONFIG_NAMES = new Set([
|
|
14
|
+
'package.json', 'tsconfig.json', 'vite.config.js', 'vite.config.mjs',
|
|
15
|
+
'vite.config.ts', 'next.config.js', 'next.config.mjs', 'Dockerfile',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function pathParts(path) {
|
|
19
|
+
return path.split('/');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isEvidence(path) {
|
|
23
|
+
const parts = pathParts(path);
|
|
24
|
+
return parts.some((part) => EVIDENCE_PARTS.has(part))
|
|
25
|
+
|| /(?:^|\.)test\.[^.]+$/.test(path)
|
|
26
|
+
|| /(?:^|\.)spec\.[^.]+$/.test(path)
|
|
27
|
+
|| /(?:^|\/)README(?:\.[^/]*)?$/.test(path);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isSecret(path) {
|
|
31
|
+
return pathParts(path).some((part) => {
|
|
32
|
+
const name = part.toLowerCase();
|
|
33
|
+
return name === '.env' || name.startsWith('.env.')
|
|
34
|
+
|| /^(?:secrets?|credentials?)(?:[._-]|$)/.test(name)
|
|
35
|
+
|| /private[-_.]?key/.test(name);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isExcluded(path) {
|
|
40
|
+
return pathParts(path).some((part) => EXCLUDED_PARTS.has(part));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isProduct(path) {
|
|
44
|
+
const parts = pathParts(path);
|
|
45
|
+
if (CONFIG_NAMES.has(parts.at(-1))) return true;
|
|
46
|
+
return parts.some((part) => ['src', 'app', 'apps', 'lib', 'packages'].includes(part));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function familyFor(path, kind) {
|
|
50
|
+
if (kind === 'config') return 'production-config';
|
|
51
|
+
const parts = pathParts(path);
|
|
52
|
+
const rootIndex = parts.findIndex((part) => ['src', 'app', 'apps', 'lib', 'packages'].includes(part));
|
|
53
|
+
const packageRoot = ['apps', 'packages'].includes(parts[rootIndex]);
|
|
54
|
+
return parts.slice(0, Math.min(rootIndex + (packageRoot ? 2 : 1), parts.length)).join('/');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function gitPaths(repoRoot, args) {
|
|
58
|
+
const { stdout } = await exec('git', args, { cwd: repoRoot, encoding: 'utf8' });
|
|
59
|
+
return stdout.split('\0').filter(Boolean).sort();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function gitIgnoredPaths(repoRoot, paths) {
|
|
63
|
+
if (!paths.length) return new Set();
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const child = spawn('git', ['check-ignore', '--no-index', '-z', '--stdin'], { cwd: repoRoot });
|
|
66
|
+
const chunks = [];
|
|
67
|
+
child.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
68
|
+
child.on('error', reject);
|
|
69
|
+
child.on('close', (code) => {
|
|
70
|
+
if (code !== 0 && code !== 1) return reject(new Error(`git check-ignore exited ${code}`));
|
|
71
|
+
resolve(new Set(Buffer.concat(chunks).toString('utf8').split('\0').filter(Boolean)));
|
|
72
|
+
});
|
|
73
|
+
child.stdin.end(`${paths.join('\0')}\0`);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function regularContainedPath(repoRoot, realRepoRoot, path) {
|
|
78
|
+
const candidate = resolve(repoRoot, path);
|
|
79
|
+
const lexicalRelative = relative(resolve(repoRoot), candidate);
|
|
80
|
+
if (lexicalRelative === '..' || lexicalRelative.startsWith(`..${sep}`) || isAbsolute(lexicalRelative)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
let stats;
|
|
84
|
+
let canonical;
|
|
85
|
+
try {
|
|
86
|
+
stats = await lstat(candidate);
|
|
87
|
+
// Symlinks, including links that resolve inside the repository, are excluded.
|
|
88
|
+
// Only a regular file whose canonical path remains inside the repository may be read.
|
|
89
|
+
if (!stats.isFile()) return null;
|
|
90
|
+
canonical = await realpath(candidate);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const canonicalRelative = relative(realRepoRoot, canonical);
|
|
95
|
+
if (canonicalRelative === '..' || canonicalRelative.startsWith(`..${sep}`) || isAbsolute(canonicalRelative)) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return candidate;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function behaviorSummary(entries) {
|
|
102
|
+
const verdicts = new Set(Object.values(CENSUS_VERDICTS));
|
|
103
|
+
return entries.map(({ name, status }) => {
|
|
104
|
+
if (typeof name !== 'string' || !name) throw new TypeError('behavior family requires a name');
|
|
105
|
+
if (!verdicts.has(status)) throw new TypeError(`invalid behavior family verdict: ${status}`);
|
|
106
|
+
return { name, status, type: 'behavior' };
|
|
107
|
+
}).sort((left, right) => {
|
|
108
|
+
const leftKey = `${left.name}\0${left.status}`;
|
|
109
|
+
const rightKey = `${right.name}\0${right.status}`;
|
|
110
|
+
return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function surfaceSummary(entries, openPaths) {
|
|
115
|
+
const statuses = new Map(entries.map(({ family }) => [family, CENSUS_VERDICTS.covered]));
|
|
116
|
+
for (const path of openPaths) {
|
|
117
|
+
if (isProduct(path) && !isExcluded(path) && !isSecret(path)) {
|
|
118
|
+
const kind = CONFIG_NAMES.has(pathParts(path).at(-1)) ? 'config' : 'source';
|
|
119
|
+
statuses.set(familyFor(path, kind), CENSUS_VERDICTS.open);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return [...statuses].sort(([left], [right]) => (
|
|
123
|
+
left < right ? -1 : left > right ? 1 : 0
|
|
124
|
+
)).map(([name, status]) => ({
|
|
125
|
+
name, status, type: 'surface',
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function scanCensus({
|
|
130
|
+
repoRoot,
|
|
131
|
+
behaviorFamilies = [],
|
|
132
|
+
enabled = false,
|
|
133
|
+
hasActive = false,
|
|
134
|
+
readText = (path) => readFile(path, 'utf8'),
|
|
135
|
+
}) {
|
|
136
|
+
const realRepoRoot = await realpath(repoRoot);
|
|
137
|
+
const tracked = await gitPaths(repoRoot, ['ls-files', '-z']);
|
|
138
|
+
const untracked = await gitPaths(repoRoot, ['ls-files', '--others', '--exclude-standard', '-z']);
|
|
139
|
+
const ignored = await gitIgnoredPaths(repoRoot, tracked);
|
|
140
|
+
const denominator = [];
|
|
141
|
+
const evidence = [];
|
|
142
|
+
const openPaths = [...untracked];
|
|
143
|
+
for (const path of tracked) {
|
|
144
|
+
if (ignored.has(path) || isSecret(path) || isExcluded(path)) continue;
|
|
145
|
+
const target = isEvidence(path) ? evidence : isProduct(path) ? denominator : null;
|
|
146
|
+
if (!target) continue;
|
|
147
|
+
const readablePath = await regularContainedPath(repoRoot, realRepoRoot, path);
|
|
148
|
+
if (!readablePath) {
|
|
149
|
+
if (target === denominator) openPaths.push(path);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
let content;
|
|
153
|
+
try {
|
|
154
|
+
content = await readText(readablePath);
|
|
155
|
+
} catch {
|
|
156
|
+
if (target === denominator) openPaths.push(path);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const kind = CONFIG_NAMES.has(pathParts(path).at(-1)) ? 'config' : 'source';
|
|
160
|
+
target.push({ family: familyFor(path, kind), hash: sha256(content), kind, path });
|
|
161
|
+
}
|
|
162
|
+
const families = {
|
|
163
|
+
surfaces: surfaceSummary(denominator, openPaths),
|
|
164
|
+
behaviors: behaviorSummary(behaviorFamilies),
|
|
165
|
+
};
|
|
166
|
+
const result = { denominator, evidence, families };
|
|
167
|
+
const hasOpen = [...families.surfaces, ...families.behaviors]
|
|
168
|
+
.some(({ status }) => status === CENSUS_VERDICTS.open);
|
|
169
|
+
return {
|
|
170
|
+
...result,
|
|
171
|
+
fingerprints: fingerprintCensus(result),
|
|
172
|
+
state: resolveCensusState({ enabled, hasActive, hasOpen }),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const CENSUS_STATES = Object.freeze([
|
|
2
|
+
'disabled',
|
|
3
|
+
'bootstrap',
|
|
4
|
+
'current',
|
|
5
|
+
'refresh_required',
|
|
6
|
+
'updating',
|
|
7
|
+
'failed',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const CENSUS_VERDICTS = Object.freeze({
|
|
11
|
+
covered: 'abgedeckt',
|
|
12
|
+
notRelevant: 'nicht relevant',
|
|
13
|
+
open: 'offen',
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export function resolveCensusState({ enabled, hasActive, hasOpen = false, updating = false, failed = false }) {
|
|
17
|
+
if (!enabled) return 'disabled';
|
|
18
|
+
if (updating) return 'updating';
|
|
19
|
+
if (failed) return 'failed';
|
|
20
|
+
if (!hasActive) return 'bootstrap';
|
|
21
|
+
if (hasOpen) return 'refresh_required';
|
|
22
|
+
return 'current';
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CENSUS_STATES, resolveCensusState } from './index.mjs';
|
|
4
|
+
|
|
5
|
+
test('state resolver implements the complete census state contract', () => {
|
|
6
|
+
assert.deepEqual(CENSUS_STATES, [
|
|
7
|
+
'disabled', 'bootstrap', 'current', 'refresh_required', 'updating', 'failed',
|
|
8
|
+
]);
|
|
9
|
+
assert.equal(resolveCensusState({ enabled: false }), 'disabled');
|
|
10
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: false }), 'bootstrap');
|
|
11
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: true }), 'current');
|
|
12
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: true, hasOpen: true }), 'refresh_required');
|
|
13
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: true, updating: true }), 'updating');
|
|
14
|
+
assert.equal(resolveCensusState({ enabled: true, hasActive: true, failed: true }), 'failed');
|
|
15
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export class CensusTransactionError extends Error {
|
|
5
|
+
constructor(message, state, cause) {
|
|
6
|
+
super(message, { cause });
|
|
7
|
+
this.name = 'CensusTransactionError';
|
|
8
|
+
this.state = state;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async function acquireLock(lockPath) {
|
|
12
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
13
|
+
try {
|
|
14
|
+
const handle = await open(lockPath, 'wx');
|
|
15
|
+
await handle.close();
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error.code === 'EEXIST') {
|
|
18
|
+
throw new CensusTransactionError(`census update already locked: ${lockPath}`, 'updating', error);
|
|
19
|
+
}
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stage, verify, then atomically replace the active census while holding a local lock. */
|
|
25
|
+
export async function activateCensus({
|
|
26
|
+
activePath,
|
|
27
|
+
candidate,
|
|
28
|
+
verify,
|
|
29
|
+
lockPath = `${activePath}.lock`,
|
|
30
|
+
stagePath = `${activePath}.candidate`,
|
|
31
|
+
renameCandidate = rename,
|
|
32
|
+
}) {
|
|
33
|
+
if (typeof verify !== 'function') {
|
|
34
|
+
throw new TypeError('census activation requires a callable verifier');
|
|
35
|
+
}
|
|
36
|
+
await acquireLock(lockPath);
|
|
37
|
+
try {
|
|
38
|
+
const serialized = `${JSON.stringify(candidate)}\n`;
|
|
39
|
+
await writeFile(stagePath, serialized, 'utf8');
|
|
40
|
+
const verified = await verify(candidate, { activePath, stagePath });
|
|
41
|
+
if (verified === false) throw new Error('census candidate verification returned false');
|
|
42
|
+
await renameCandidate(stagePath, activePath);
|
|
43
|
+
return { activePath, state: 'current' };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await rm(stagePath, { force: true });
|
|
46
|
+
throw new CensusTransactionError(`census activation failed: ${error.message}`, 'failed', error);
|
|
47
|
+
} finally {
|
|
48
|
+
await rm(lockPath, { force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { activateCensus } from './index.mjs';
|
|
7
|
+
|
|
8
|
+
test('activation requires a callable verifier before staging or swapping', async () => {
|
|
9
|
+
for (const verify of [undefined, 'not-callable']) {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-transaction-'));
|
|
11
|
+
const activePath = join(root, 'active.json');
|
|
12
|
+
const previous = '{"generation":"previous"}\n';
|
|
13
|
+
await writeFile(activePath, previous);
|
|
14
|
+
let swapped = false;
|
|
15
|
+
try {
|
|
16
|
+
await assert.rejects(
|
|
17
|
+
activateCensus({
|
|
18
|
+
activePath,
|
|
19
|
+
candidate: { generation: 'next' },
|
|
20
|
+
renameCandidate: async () => { swapped = true; },
|
|
21
|
+
verify,
|
|
22
|
+
}),
|
|
23
|
+
/callable verifier/,
|
|
24
|
+
);
|
|
25
|
+
assert.equal(await readFile(activePath, 'utf8'), previous);
|
|
26
|
+
await assert.rejects(readFile(`${activePath}.candidate`), { code: 'ENOENT' });
|
|
27
|
+
await assert.rejects(readFile(`${activePath}.lock`), { code: 'ENOENT' });
|
|
28
|
+
assert.equal(swapped, false);
|
|
29
|
+
} finally {
|
|
30
|
+
await rm(root, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('verify failure preserves active bytes and a second run can succeed', async () => {
|
|
36
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-transaction-'));
|
|
37
|
+
const activePath = join(root, 'active.json');
|
|
38
|
+
const previous = '{"generation":"previous"}\n';
|
|
39
|
+
await writeFile(activePath, previous);
|
|
40
|
+
try {
|
|
41
|
+
await assert.rejects(
|
|
42
|
+
activateCensus({
|
|
43
|
+
activePath,
|
|
44
|
+
candidate: { generation: 'next' },
|
|
45
|
+
verify: async () => { throw new Error('fixture verify failure'); },
|
|
46
|
+
}),
|
|
47
|
+
(error) => error.state === 'failed',
|
|
48
|
+
);
|
|
49
|
+
assert.equal(await readFile(activePath, 'utf8'), previous);
|
|
50
|
+
|
|
51
|
+
const result = await activateCensus({
|
|
52
|
+
activePath,
|
|
53
|
+
candidate: { generation: 'next' },
|
|
54
|
+
verify: async ({ generation }) => generation === 'next',
|
|
55
|
+
});
|
|
56
|
+
assert.equal(result.state, 'current');
|
|
57
|
+
assert.equal(await readFile(activePath, 'utf8'), '{"generation":"next"}\n');
|
|
58
|
+
} finally {
|
|
59
|
+
await rm(root, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('swap failure preserves active bytes', async () => {
|
|
64
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-transaction-'));
|
|
65
|
+
const activePath = join(root, 'active.json');
|
|
66
|
+
const previous = '{"generation":"previous"}\n';
|
|
67
|
+
await writeFile(activePath, previous);
|
|
68
|
+
try {
|
|
69
|
+
await assert.rejects(
|
|
70
|
+
activateCensus({
|
|
71
|
+
activePath,
|
|
72
|
+
candidate: { generation: 'next' },
|
|
73
|
+
renameCandidate: async () => { throw new Error('fixture swap failure'); },
|
|
74
|
+
verify: async () => true,
|
|
75
|
+
}),
|
|
76
|
+
(error) => error.state === 'failed',
|
|
77
|
+
);
|
|
78
|
+
assert.equal(await readFile(activePath, 'utf8'), previous);
|
|
79
|
+
} finally {
|
|
80
|
+
await rm(root, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('an existing local lock reports updating without touching active bytes', async () => {
|
|
85
|
+
const root = await mkdtemp(join(tmpdir(), 'awk-census-transaction-'));
|
|
86
|
+
const activePath = join(root, 'active.json');
|
|
87
|
+
const previous = '{"generation":"previous"}\n';
|
|
88
|
+
await writeFile(activePath, previous);
|
|
89
|
+
await writeFile(`${activePath}.lock`, 'held\n');
|
|
90
|
+
try {
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
activateCensus({
|
|
93
|
+
activePath,
|
|
94
|
+
candidate: { generation: 'next' },
|
|
95
|
+
verify: async () => true,
|
|
96
|
+
}),
|
|
97
|
+
(error) => error.state === 'updating',
|
|
98
|
+
);
|
|
99
|
+
assert.equal(await readFile(activePath, 'utf8'), previous);
|
|
100
|
+
} finally {
|
|
101
|
+
await rm(root, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { realpathSync } from 'node:fs';
|
|
4
|
+
import { appendFile } from 'node:fs/promises';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
|
|
9
|
+
export const UPDATE_BRANCH = 'agent-workflow-kit/update';
|
|
10
|
+
export const UPDATE_TITLE = 'chore: update agent workflow kit';
|
|
11
|
+
const UPDATE_BODY = [
|
|
12
|
+
'Automated, parity-verified update of `@ikon85/agent-workflow-kit`.',
|
|
13
|
+
'',
|
|
14
|
+
'The transactional update candidate passed the consumer test suite before this branch was published.',
|
|
15
|
+
'',
|
|
16
|
+
'This pull request is never merged automatically.',
|
|
17
|
+
].join('\n');
|
|
18
|
+
const exec = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
export async function orchestrateUpdatePullRequest(options) {
|
|
21
|
+
const {
|
|
22
|
+
runUpdate, hasChanges, listPullRequests, publishBranch,
|
|
23
|
+
createPullRequest, updatePullRequest, branch = UPDATE_BRANCH,
|
|
24
|
+
} = options;
|
|
25
|
+
const update = await runUpdate();
|
|
26
|
+
if (update.exitCode !== 0) {
|
|
27
|
+
return { status: update.exitCode === 2 ? 'conflicted' : 'failed', branch, update };
|
|
28
|
+
}
|
|
29
|
+
if (!await hasChanges()) return { status: 'current', branch };
|
|
30
|
+
|
|
31
|
+
const pulls = await listPullRequests(branch);
|
|
32
|
+
if (pulls.length > 1) {
|
|
33
|
+
return { status: 'failed', branch, reason: 'multiple-open-pull-requests' };
|
|
34
|
+
}
|
|
35
|
+
await publishBranch(branch);
|
|
36
|
+
const input = { title: UPDATE_TITLE, body: UPDATE_BODY, branch };
|
|
37
|
+
if (pulls.length === 1) {
|
|
38
|
+
await updatePullRequest(pulls[0].number, input);
|
|
39
|
+
return { status: 'updated', branch, pullRequest: pulls[0].number };
|
|
40
|
+
}
|
|
41
|
+
await createPullRequest(input);
|
|
42
|
+
return { status: 'created', branch };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createSystemAdapters({
|
|
46
|
+
cwd = process.cwd(), env = process.env, execute = exec,
|
|
47
|
+
} = {}) {
|
|
48
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
49
|
+
const command = async (file, args) => {
|
|
50
|
+
try {
|
|
51
|
+
const result = await execute(file, args, { cwd, env, encoding: 'utf8' });
|
|
52
|
+
return { exitCode: 0, stdout: result.stdout, stderr: result.stderr };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (typeof error.code !== 'number') throw error;
|
|
55
|
+
return { exitCode: error.code, stdout: error.stdout || '', stderr: error.stderr || '' };
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const checked = async (file, args) => {
|
|
59
|
+
const result = await command(file, args);
|
|
60
|
+
if (result.exitCode !== 0) throw new Error(result.stderr || `${file} exited ${result.exitCode}`);
|
|
61
|
+
return result.stdout.trim();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
runUpdate: () => command(process.execPath, [join(packageRoot, 'src/cli.mjs'), 'update', '--yes']),
|
|
66
|
+
hasChanges: async () => Boolean(await checked('git', ['status', '--porcelain', '--untracked-files=all'])),
|
|
67
|
+
listPullRequests: async (branch) => JSON.parse(await checked('gh', [
|
|
68
|
+
'pr', 'list', '--state', 'open', '--head', branch, '--json', 'number,url',
|
|
69
|
+
]) || '[]'),
|
|
70
|
+
publishBranch: async (branch) => {
|
|
71
|
+
await checked('git', ['config', 'user.name', 'agent-workflow-kit[bot]']);
|
|
72
|
+
await checked('git', ['config', 'user.email', 'agent-workflow-kit[bot]@users.noreply.github.com']);
|
|
73
|
+
await checked('git', ['add', '--all']);
|
|
74
|
+
await checked('git', ['commit', '-m', UPDATE_TITLE]);
|
|
75
|
+
const remote = await checked('git', ['ls-remote', 'origin', `refs/heads/${branch}`]);
|
|
76
|
+
const expected = remote.split(/\s+/)[0] || '';
|
|
77
|
+
await checked('git', [
|
|
78
|
+
'push', `--force-with-lease=refs/heads/${branch}:${expected}`,
|
|
79
|
+
'origin', `HEAD:refs/heads/${branch}`,
|
|
80
|
+
]);
|
|
81
|
+
},
|
|
82
|
+
createPullRequest: ({ title, body, branch }) => checked('gh', [
|
|
83
|
+
'pr', 'create', '--head', branch, '--title', title, '--body', body,
|
|
84
|
+
]),
|
|
85
|
+
updatePullRequest: (number, { title, body }) => checked('gh', [
|
|
86
|
+
'pr', 'edit', String(number), '--title', title, '--body', body,
|
|
87
|
+
]),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runCli(options = {}) {
|
|
92
|
+
let report;
|
|
93
|
+
try {
|
|
94
|
+
report = await orchestrateUpdatePullRequest(createSystemAdapters(options));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
report = { status: 'failed', branch: UPDATE_BRANCH, reason: error.message };
|
|
97
|
+
}
|
|
98
|
+
const rendered = `${JSON.stringify(report, null, 2)}\n`;
|
|
99
|
+
process.stdout.write(rendered);
|
|
100
|
+
if (process.env.GITHUB_STEP_SUMMARY) {
|
|
101
|
+
await appendFile(process.env.GITHUB_STEP_SUMMARY, `## Agent Workflow Kit update\n\n\`\`\`json\n${rendered}\`\`\`\n`);
|
|
102
|
+
}
|
|
103
|
+
if (report.status === 'conflicted') process.exitCode = 2;
|
|
104
|
+
else if (report.status === 'failed') process.exitCode = 1;
|
|
105
|
+
return report;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : '';
|
|
109
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
110
|
+
if (process.argv.includes('--help') || process.argv.includes('-h') || process.argv.includes('help')) {
|
|
111
|
+
process.stdout.write('Usage: agent-workflow-kit-update-pr\n');
|
|
112
|
+
} else {
|
|
113
|
+
await runCli();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createSystemAdapters, orchestrateUpdatePullRequest } from './kit-update-pr.mjs';
|
|
4
|
+
|
|
5
|
+
function harness({ update = { exitCode: 0 }, changed = true, pulls = [] } = {}) {
|
|
6
|
+
const calls = [];
|
|
7
|
+
return {
|
|
8
|
+
calls,
|
|
9
|
+
options: {
|
|
10
|
+
runUpdate: async () => (calls.push('update'), update),
|
|
11
|
+
hasChanges: async () => (calls.push('changes'), changed),
|
|
12
|
+
listPullRequests: async (branch) => (calls.push(['list', branch]), pulls),
|
|
13
|
+
publishBranch: async (branch) => calls.push(['publish', branch]),
|
|
14
|
+
createPullRequest: async (input) => calls.push(['create', input]),
|
|
15
|
+
updatePullRequest: async (number, input) => calls.push(['upsert', number, input]),
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('a successful update creates one stable update pull request', async () => {
|
|
21
|
+
const h = harness();
|
|
22
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
23
|
+
|
|
24
|
+
assert.equal(report.status, 'created');
|
|
25
|
+
assert.equal(report.branch, 'agent-workflow-kit/update');
|
|
26
|
+
assert.deepEqual(h.calls.map((call) => Array.isArray(call) ? call[0] : call), [
|
|
27
|
+
'update', 'changes', 'list', 'publish', 'create',
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a conflict produces a structured report without touching the stable branch', async () => {
|
|
32
|
+
const h = harness({ update: { exitCode: 2, stdout: 'conflicts: 1', stderr: '' } });
|
|
33
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
34
|
+
|
|
35
|
+
assert.deepEqual(report, {
|
|
36
|
+
status: 'conflicted',
|
|
37
|
+
branch: 'agent-workflow-kit/update',
|
|
38
|
+
update: { exitCode: 2, stdout: 'conflicts: 1', stderr: '' },
|
|
39
|
+
});
|
|
40
|
+
assert.deepEqual(h.calls, ['update']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a release mismatch fails without touching the consumer update branch', async () => {
|
|
44
|
+
const h = harness({ update: { exitCode: 1, stdout: '', stderr: 'release mismatch' } });
|
|
45
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
46
|
+
|
|
47
|
+
assert.equal(report.status, 'failed');
|
|
48
|
+
assert.equal(report.update.stderr, 'release mismatch');
|
|
49
|
+
assert.deepEqual(h.calls, ['update']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('ambiguous existing pull requests fail before the last good branch is changed', async () => {
|
|
53
|
+
const h = harness({ pulls: [{ number: 7 }, { number: 9 }] });
|
|
54
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
55
|
+
|
|
56
|
+
assert.equal(report.status, 'failed');
|
|
57
|
+
assert.equal(report.reason, 'multiple-open-pull-requests');
|
|
58
|
+
assert.deepEqual(h.calls.map((call) => Array.isArray(call) ? call[0] : call), [
|
|
59
|
+
'update', 'changes', 'list',
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('an already current consumer is a no-op', async () => {
|
|
64
|
+
const h = harness({ changed: false });
|
|
65
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
66
|
+
|
|
67
|
+
assert.equal(report.status, 'current');
|
|
68
|
+
assert.deepEqual(h.calls, ['update', 'changes']);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a repeated run updates the one existing stable pull request', async () => {
|
|
72
|
+
const h = harness({ pulls: [{ number: 7 }] });
|
|
73
|
+
const report = await orchestrateUpdatePullRequest(h.options);
|
|
74
|
+
|
|
75
|
+
assert.equal(report.status, 'updated');
|
|
76
|
+
assert.equal(report.pullRequest, 7);
|
|
77
|
+
assert.deepEqual(h.calls.map((call) => Array.isArray(call) ? call[0] : call), [
|
|
78
|
+
'update', 'changes', 'list', 'publish', 'upsert',
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('publishing commits in place and lease-protects the stable remote branch', async () => {
|
|
83
|
+
const commands = [];
|
|
84
|
+
const execute = async (file, args) => {
|
|
85
|
+
commands.push([file, ...args]);
|
|
86
|
+
if (args[0] === 'ls-remote') return { stdout: 'abc123\trefs/heads/agent-workflow-kit/update\n', stderr: '' };
|
|
87
|
+
return { stdout: '', stderr: '' };
|
|
88
|
+
};
|
|
89
|
+
const adapter = createSystemAdapters({ cwd: '/consumer', env: {}, execute });
|
|
90
|
+
|
|
91
|
+
await adapter.publishBranch('agent-workflow-kit/update');
|
|
92
|
+
|
|
93
|
+
const gitCommands = commands.map((command) => command.slice(1));
|
|
94
|
+
assert.equal(gitCommands.some(([verb]) => ['switch', 'checkout', 'reset'].includes(verb)), false);
|
|
95
|
+
assert.deepEqual(gitCommands.at(-1), [
|
|
96
|
+
'push', '--force-with-lease=refs/heads/agent-workflow-kit/update:abc123',
|
|
97
|
+
'origin', 'HEAD:refs/heads/agent-workflow-kit/update',
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
@@ -45,6 +45,16 @@ def classify(first_line, is_empty):
|
|
|
45
45
|
return "create" if is_empty else "skip"
|
|
46
46
|
|
|
47
47
|
|
|
48
|
+
def update_workflow_action(
|
|
49
|
+
provider, choice, destination_exists, prerequisites=True,
|
|
50
|
+
pull_requests_allowed=True,
|
|
51
|
+
):
|
|
52
|
+
"""Reference decision table for the prompt-driven setup contract."""
|
|
53
|
+
if provider != "github" or destination_exists or choice != "enable":
|
|
54
|
+
return "skip"
|
|
55
|
+
return "create" if prerequisites and pull_requests_allowed else "skip"
|
|
56
|
+
|
|
57
|
+
|
|
48
58
|
class IdempotencyRule(unittest.TestCase):
|
|
49
59
|
CASES = [
|
|
50
60
|
# (first_line, is_empty, expected)
|
|
@@ -71,6 +81,61 @@ class IdempotencyRule(unittest.TestCase):
|
|
|
71
81
|
|
|
72
82
|
|
|
73
83
|
class SeedTemplatesValid(unittest.TestCase):
|
|
84
|
+
def test_update_workflow_provider_and_choice_fixtures(self):
|
|
85
|
+
fixtures = [
|
|
86
|
+
("github", "enable", False, True, True, "create"),
|
|
87
|
+
("github", "opt-out", False, True, True, "skip"),
|
|
88
|
+
("github", "later", False, True, True, "skip"),
|
|
89
|
+
("github", "enable", True, True, True, "skip"),
|
|
90
|
+
("github", "enable", False, False, True, "skip"),
|
|
91
|
+
("github", "enable", False, True, False, "skip"),
|
|
92
|
+
("gitlab", "enable", False, True, True, "skip"),
|
|
93
|
+
("local", "enable", False, True, True, "skip"),
|
|
94
|
+
]
|
|
95
|
+
for provider, choice, exists, prerequisites, allowed, expected in fixtures:
|
|
96
|
+
self.assertEqual(
|
|
97
|
+
update_workflow_action(
|
|
98
|
+
provider, choice, exists, prerequisites, allowed,
|
|
99
|
+
), expected,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def test_github_update_opt_in_is_provider_aware_and_idempotent(self):
|
|
103
|
+
skill = (SKILL / "SKILL.md").read_text(encoding="utf-8")
|
|
104
|
+
for token in (
|
|
105
|
+
"Automatic Kit update pull requests",
|
|
106
|
+
".github/workflows/agent-workflow-kit-update.yml",
|
|
107
|
+
"Opt out",
|
|
108
|
+
"Ask later",
|
|
109
|
+
"GitHub tracker",
|
|
110
|
+
"skipped (already present)",
|
|
111
|
+
"package-lock.json",
|
|
112
|
+
"npm test",
|
|
113
|
+
"can_approve_pull_request_reviews",
|
|
114
|
+
"Allow GitHub Actions to create and approve pull requests",
|
|
115
|
+
"explicit confirmation",
|
|
116
|
+
):
|
|
117
|
+
self.assertIn(token, skill)
|
|
118
|
+
self.assertIn("do not create a GitHub workflow", skill)
|
|
119
|
+
|
|
120
|
+
def test_github_update_workflow_has_safe_triggers_and_scoped_runner(self):
|
|
121
|
+
workflow = (SKILL / "assets/agent-workflow-kit-update.yml").read_text(encoding="utf-8")
|
|
122
|
+
for token in (
|
|
123
|
+
"schedule:", "workflow_dispatch:", "contents: write",
|
|
124
|
+
"pull-requests: write", "agent-workflow-kit-update-pr",
|
|
125
|
+
"@ikon85/agent-workflow-kit@latest", "fetch-depth: 0",
|
|
126
|
+
"node-version: 22.14", "npm ci --ignore-scripts",
|
|
127
|
+
):
|
|
128
|
+
self.assertIn(token, workflow)
|
|
129
|
+
for forbidden in ("npm_token", "NPM_TOKEN", "auto-merge", "gh pr merge"):
|
|
130
|
+
self.assertNotIn(forbidden, workflow)
|
|
131
|
+
|
|
132
|
+
mirror = (REPO / ".agents/skills/setup-workflow/assets/agent-workflow-kit-update.yml")
|
|
133
|
+
self.assertEqual(workflow, mirror.read_text(encoding="utf-8"))
|
|
134
|
+
|
|
135
|
+
def test_update_pr_runner_is_an_installed_package_binary(self):
|
|
136
|
+
package = json.loads((REPO / "package.json").read_text(encoding="utf-8"))
|
|
137
|
+
self.assertEqual(package["bin"]["agent-workflow-kit-update-pr"], "scripts/kit-update-pr.mjs")
|
|
138
|
+
|
|
74
139
|
def test_spec_completeness_seed_has_valid_self_critique_block(self):
|
|
75
140
|
"""A convention without a valid Trigger/Check/Korrektur block makes
|
|
76
141
|
spec-self-critique point 8 warn — the seed must carry one (Codex R1 #14)."""
|