release-agent-skills 0.0.0-stage → 0.1.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +115 -2
  4. package/bin/release-agent-skills.mjs +71 -0
  5. package/package.json +45 -4
  6. package/skills/release-agent-shared/scripts/changelog.mjs +120 -0
  7. package/skills/release-agent-shared/scripts/git-preflight.mjs +88 -0
  8. package/skills/release-agent-shared/scripts/github-release.mjs +82 -0
  9. package/skills/release-agent-shared/scripts/lib/cli.mjs +66 -0
  10. package/skills/release-agent-shared/scripts/lib/git.mjs +18 -0
  11. package/skills/release-agent-shared/scripts/lib/http.mjs +23 -0
  12. package/skills/release-agent-shared/scripts/lib/properties.mjs +32 -0
  13. package/skills/release-agent-shared/scripts/state.mjs +57 -0
  14. package/skills/release-maven/SKILL.md +202 -0
  15. package/skills/release-maven/errors.md +214 -0
  16. package/skills/release-maven/reference/central-portal.md +97 -0
  17. package/skills/release-maven/reference/gradle-setups.md +259 -0
  18. package/skills/release-maven/reference/kmp-notes.md +90 -0
  19. package/skills/release-maven/reference/pom-requirements.md +55 -0
  20. package/skills/release-maven/reference/signing-setup.md +96 -0
  21. package/skills/release-maven/scripts/bump-version.mjs +54 -0
  22. package/skills/release-maven/scripts/check-credentials.mjs +93 -0
  23. package/skills/release-maven/scripts/check-version.mjs +74 -0
  24. package/skills/release-maven/scripts/detect-project.mjs +15 -0
  25. package/skills/release-maven/scripts/inspect-artifacts.mjs +92 -0
  26. package/skills/release-maven/scripts/lib/credentials.mjs +42 -0
  27. package/skills/release-maven/scripts/lib/gradle.mjs +349 -0
  28. package/skills/release-maven/scripts/lib/maven.mjs +95 -0
  29. package/skills/release-maven/scripts/lib/pgp.mjs +74 -0
  30. package/skills/release-maven/scripts/lib/zip.mjs +75 -0
  31. package/skills/release-maven/scripts/portal.mjs +166 -0
  32. package/skills/release-maven/scripts/validate-pom.mjs +92 -0
  33. package/skills/release-maven/scripts/verify-signatures.mjs +123 -0
  34. package/skills/release-maven/scripts/wait-for-central.mjs +37 -0
  35. package/src/installer/install.mjs +62 -0
  36. package/src/installer/targets.mjs +16 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-28
11
+
12
+ ### Added
13
+
14
+ - `release-maven` skill: publishes JVM, Android and Kotlin Multiplatform libraries to Maven Central through the Sonatype Central Portal, with check-only, manual (default) and automatic modes, two approval gates and resumable state.
15
+ - Check scripts: project detection, version lookup on repo1.maven.org, credential presence, version bump, artifact inspection, POM validation, signature and keyserver verification, Portal Publisher API client, and sync polling.
16
+ - Shared scripts: git pre-flight, changelog drafting from conventional commits, GitHub release creation.
17
+ - Error library with 23 Maven Central and Gradle publishing errors.
18
+ - Reference docs for the Central Portal, Gradle publishing setups, POM requirements, signing and Kotlin Multiplatform.
19
+ - Installer for Claude Code, Cursor and Agent Skills folders (`npx release-agent-skills install`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahmad Hussain
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,116 @@
1
- # Temporary Holding Version
1
+ # release-agent-skills
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Agent skills that publish libraries safely. You type one sentence, the agent runs the checks, shows you exactly what will be released, and waits for your approval before anything leaves your machine.
4
+
5
+ Version 0.1 supports **Maven Central** (JVM, Android and Kotlin Multiplatform libraries built with Gradle) through the Sonatype Central Portal. npm and pub.dev are planned.
6
+
7
+ ```
8
+ > publish KMPermission 0.4.0 to Maven Central
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ 1. **Detect**: finds the publishable modules, groupId, artifactId, version, library type (JVM, Android, KMP), publishing setup, and any CI workflow that already publishes.
14
+ 2. **Pre-flight, nothing uploaded**:
15
+ - git tree clean, expected branch, tag not taken
16
+ - version not already on Maven Central
17
+ - credentials present (key names only, values are never read out)
18
+ - build and tests pass
19
+ - every artifact has a POM with the required fields, sources and javadoc jars, and a signature; signing key on a public keyserver
20
+ - for KMP, every target is published
21
+ 3. **Prepare**: version bump (or a `-PVERSION_NAME` override for projects that keep a SNAPSHOT in the file) and a changelog entry drafted from commits since the last tag.
22
+ 4. **Gate 1**: a summary of coordinates, version, files and changelog. Nothing is committed or uploaded without your explicit "yes".
23
+ 5. **Upload and validate** in the Central Portal. Manual release is the default: the deployment is validated but not published.
24
+ 6. **Gate 2**: the release becomes permanent only when you click Publish in the Portal or answer "yes" a second time. You can also drop the deployment; the version is not used up.
25
+ 7. **Verify**: polls repo1.maven.org with backoff until the version is visible (10 to 30 minutes is normal).
26
+ 8. **Finish**: tag, push and GitHub release, each after your approval.
27
+
28
+ When something fails, the agent looks the error up in an error library of 23 common Maven Central and Gradle publishing errors. It explains the cause in plain English, proposes a fix, and applies it only after you approve.
29
+
30
+ ## Quick start
31
+
32
+ Requires Node.js 20 or later.
33
+
34
+ ```sh
35
+ cd your-library
36
+ npx release-agent-skills install
37
+ ```
38
+
39
+ This copies the skills into `.claude/skills/` in the current project. Commit that folder, or install with `--user` instead, because the pre-flight requires a clean git tree. Then ask your agent:
40
+
41
+ ```
42
+ > check if my library is ready to publish to Maven Central
43
+ ```
44
+
45
+ That runs the checks only and changes nothing. When everything passes:
46
+
47
+ ```
48
+ > publish mylib 1.0.2 to Maven Central
49
+ ```
50
+
51
+ ### Other tools and locations
52
+
53
+ ```sh
54
+ npx release-agent-skills install --target agents # .agents/skills (Codex and other Agent Skills tools)
55
+ npx release-agent-skills install --target cursor # .cursor/skills
56
+ npx release-agent-skills install --target all
57
+ npx release-agent-skills install --user # ~/.claude/skills, for all your projects
58
+ npx release-agent-skills install --dir path/to/skills
59
+ npx release-agent-skills install --dry-run # show what would be copied
60
+ npx release-agent-skills list
61
+ ```
62
+
63
+ Cursor also reads `.claude/skills` and `.agents/skills`, so a separate Cursor install is only needed if you use Cursor alone. An existing installation is never overwritten unless you pass `--force`, and the installer never deletes files.
64
+
65
+ ## Safety model
66
+
67
+ - **No upload without approval.** Gate 1 comes before the upload; Gate 2 comes before the release, which cannot be undone. The Portal publish call needs a `--confirm-permanent` flag, which the skill only passes after your explicit answer.
68
+ - **Manual release by default.** Automatic release is used only when you ask for it in so many words.
69
+ - **Secrets stay secret.** `check-credentials` reports key names and where they were found, never values. The skill forbids commands that print secrets, including `./gradlew properties`, which dumps `~/.gradle/gradle.properties`. The only script that reads a secret value is `portal.mjs`. It uses the Portal token in-process for the HTTP header and never prints it.
70
+ - **Check-only mode.** Ask for a check or a dry run and nothing is changed, committed or uploaded.
71
+ - **Deterministic checks.** Version lookups, POM validation, signature checks and artifact listing are small Node.js scripts with no dependencies that print JSON, so the agent reads results instead of guessing.
72
+ - **Works with your pipeline.** If a CI workflow already publishes, the skill recommends running its checks locally and triggering that workflow, rather than bypassing it.
73
+
74
+ ## Supported setups
75
+
76
+ | | Supported |
77
+ |---|---|
78
+ | Publishing plugin | [vanniktech gradle-maven-publish-plugin](https://github.com/vanniktech/gradle-maven-publish-plugin) 0.34.0 or later (0.37.0 recommended); plain `maven-publish` + `signing` (uploaded by this skill through the Portal Publisher API) |
79
+ | Library types | Java / Kotlin JVM, Android library, Kotlin Multiplatform (including the AGP `com.android.kotlin.multiplatform.library` plugin) |
80
+ | Signing | in-memory key, key ring file, GnuPG agent (`useGpgCmd()`) |
81
+ | Repository | Sonatype Central Portal. Legacy OSSRH was shut down on June 30, 2025 and is detected and flagged |
82
+ | Agents | Claude Code, Codex, Cursor and other tools that read `SKILL.md` folders |
83
+
84
+ ## What gets installed
85
+
86
+ ```
87
+ .claude/skills/
88
+ ├── release-maven/
89
+ │ ├── SKILL.md the workflow the agent follows
90
+ │ ├── errors.md error library: cause, fix, phase to rerun
91
+ │ ├── reference/ Central Portal, Gradle setups, POM, signing, KMP notes
92
+ │ └── scripts/ detect, check-version, check-credentials, bump-version,
93
+ │ inspect-artifacts, validate-pom, verify-signatures,
94
+ │ portal, wait-for-central
95
+ └── release-agent-shared/ registry-independent scripts: git pre-flight,
96
+ changelog, GitHub release, release state
97
+ ```
98
+
99
+ Scripts can also be run by hand, for example:
100
+
101
+ ```sh
102
+ node .claude/skills/release-maven/scripts/check-version.mjs --group io.github.example --artifact mylib --version 1.0.2
103
+ node .claude/skills/release-maven/scripts/detect-project.mjs --project .
104
+ ```
105
+
106
+ ## Development
107
+
108
+ ```sh
109
+ npm test
110
+ ```
111
+
112
+ Tests use the built-in `node:test` runner and mock all network calls. See [docs/adding-a-registry.md](docs/adding-a-registry.md) for how npm, pub.dev and other registries fit in.
113
+
114
+ ## License
115
+
116
+ [MIT](LICENSE)
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { resolve } from 'node:path';
4
+ import { installSkills, availableSkills, packageVersion } from '../src/installer/install.mjs';
5
+ import { resolveTarget, TARGETS } from '../src/installer/targets.mjs';
6
+
7
+ const HELP = `release-agent-skills ${packageVersion()}
8
+
9
+ Usage:
10
+ npx release-agent-skills install [options]
11
+ npx release-agent-skills list
12
+
13
+ Install options:
14
+ --target <name> ${Object.keys(TARGETS).join(' | ')} | all (default: claude)
15
+ --user install into your home directory instead of the current project
16
+ --dir <path> install into this skills directory instead of a target
17
+ --skill <name> install only this skill (repeatable; default: all)
18
+ --force overwrite an existing installation (files are replaced, never deleted)
19
+ --dry-run show what would be installed without writing anything
20
+
21
+ Targets:
22
+ ${Object.entries(TARGETS).map(([k, t]) => ` ${k.padEnd(8)} ${t.label}: ./${t.project} (or ~/${t.user} with --user)`).join('\n')}
23
+ Cursor also reads .claude/skills and .agents/skills, so --target cursor is only
24
+ needed if you use Cursor alone.`;
25
+
26
+ function main(argv) {
27
+ const { values, positionals } = parseArgs({
28
+ args: argv,
29
+ allowPositionals: true,
30
+ options: {
31
+ target: { type: 'string', default: 'claude' }, user: { type: 'boolean', default: false },
32
+ dir: { type: 'string' }, skill: { type: 'string', multiple: true }, force: { type: 'boolean', default: false },
33
+ 'dry-run': { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
34
+ },
35
+ });
36
+ const [command] = positionals;
37
+ if (values.version) return console.log(packageVersion());
38
+ if (values.help || !command) return console.log(HELP);
39
+
40
+ if (command === 'list') {
41
+ for (const s of availableSkills()) console.log(`${s.name}\n ${s.description}\n`);
42
+ return;
43
+ }
44
+ if (command !== 'install') throw new Error(`Unknown command "${command}". Run with --help.`);
45
+
46
+ const destinations = values.dir
47
+ ? [{ label: 'custom directory', dir: resolve(values.dir) }]
48
+ : (values.target === 'all' ? Object.keys(TARGETS) : [values.target]).map((t) => resolveTarget(t, { user: values.user }));
49
+
50
+ for (const dest of destinations) {
51
+ const report = installSkills({ destDir: dest.dir, skills: values.skill, force: values.force, dryRun: values['dry-run'] });
52
+ console.log(`${values['dry-run'] ? '[dry run] ' : ''}${dest.label}: ${dest.dir}`);
53
+ for (const r of report) {
54
+ console.log(r.action === 'skipped'
55
+ ? ` skipped ${r.folder} (${r.reason}; use --force to overwrite)`
56
+ : ` ${r.action.padEnd(10)} ${r.folder} (${r.files} files)`);
57
+ }
58
+ }
59
+ if (!values['dry-run']) {
60
+ console.log('\nNext: open your project in your AI tool and ask, for example:');
61
+ console.log(' "check if my library is ready to publish to Maven Central" (check only, nothing uploaded)');
62
+ console.log(' "publish mylib 1.0.2 to Maven Central"');
63
+ }
64
+ }
65
+
66
+ try {
67
+ main(process.argv.slice(2));
68
+ } catch (err) {
69
+ console.error(`Error: ${err.message}`);
70
+ process.exitCode = 1;
71
+ }
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
2
  "name": "release-agent-skills",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "AI agent skills that publish libraries safely. v0.1: Maven Central through the Sonatype Central Portal, with pre-flight checks, approval gates and manual release by default.",
5
+ "type": "module",
6
+ "bin": {
7
+ "release-agent-skills": "bin/release-agent-skills.mjs"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "skills/",
13
+ "README.md",
14
+ "CHANGELOG.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test test/*.test.mjs",
19
+ "prepublishOnly": "npm test"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "keywords": [
25
+ "agent-skills",
26
+ "claude-code",
27
+ "cursor",
28
+ "codex",
29
+ "maven-central",
30
+ "sonatype",
31
+ "central-portal",
32
+ "gradle",
33
+ "kotlin-multiplatform",
34
+ "release",
35
+ "publishing"
36
+ ],
37
+ "author": "Ahmad Hussain <ahmadhussain0335@gmail.com>",
38
+ "license": "MIT",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/Ahmadhussain0335/release-agent-skills.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/Ahmadhussain0335/release-agent-skills/issues"
45
+ },
46
+ "homepage": "https://github.com/Ahmadhussain0335/release-agent-skills#readme"
47
+ }
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // Drafts a Keep a Changelog entry (Added / Changed / Fixed) from commits since the last tag.
3
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { runCli, check, result, required } from './lib/cli.mjs';
5
+ import { git, lastTag } from './lib/git.mjs';
6
+
7
+ const SECTIONS = ['Added', 'Changed', 'Fixed'];
8
+ const TYPE_SECTION = { feat: 'Added', fix: 'Fixed', perf: 'Changed', refactor: 'Changed', revert: 'Changed', deprecate: 'Changed', security: 'Fixed' };
9
+ const OMIT_TYPES = new Set(['chore', 'docs', 'test', 'tests', 'ci', 'build', 'style']);
10
+
11
+ const HEADER = `# Changelog
12
+
13
+ All notable changes to this project are documented in this file.
14
+
15
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
16
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
17
+
18
+ ## [Unreleased]
19
+ `;
20
+
21
+ export function readCommits(cwd, since) {
22
+ const range = since ? `${since}..HEAD` : 'HEAD';
23
+ const raw = git(['log', range, '--no-merges', '--format=%H%x1f%s%x1f%b%x1e'], cwd);
24
+ if (raw === null) return [];
25
+ return raw.split('\x1e').map((s) => s.trim()).filter(Boolean).map((record) => {
26
+ const [hash, subject, body = ''] = record.split('\x1f');
27
+ return { hash, subject, body };
28
+ });
29
+ }
30
+
31
+ export function classify(commit) {
32
+ const m = commit.subject.match(/^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
33
+ if (!m) return { section: null, text: commit.subject, reason: 'not a conventional commit' };
34
+ const [, rawType, scope, bang, description] = m;
35
+ const type = rawType.toLowerCase();
36
+ const breaking = Boolean(bang) || /^BREAKING[ -]CHANGE:/m.test(commit.body);
37
+ let text = scope ? `${scope}: ${description}` : description;
38
+ if (breaking) text = `Breaking: ${text}`;
39
+ if (type === 'chore' && /^release\b/i.test(scope ?? '')) return { section: null, text, omitted: true, reason: 'release commit' };
40
+ if (breaking && !TYPE_SECTION[type]) return { section: 'Changed', text };
41
+ if (TYPE_SECTION[type]) return { section: TYPE_SECTION[type], text };
42
+ if (OMIT_TYPES.has(type)) return { section: null, text: commit.subject, omitted: true, reason: `${type} commits are not user-facing` };
43
+ return { section: null, text: commit.subject, reason: `unknown type "${type}"` };
44
+ }
45
+
46
+ /** Matches a release heading such as "## [1.0.2] - date", "## 1.0.2 (date)" or "## v1.0.2". */
47
+ export const RELEASE_HEADING = /^## \[?v?\d+\.\d+/;
48
+
49
+ /** Follows the heading style already used in the file; Keep a Changelog otherwise. */
50
+ export function headingFor(existing, version, date) {
51
+ const first = existing?.split('\n').find((l) => RELEASE_HEADING.test(l));
52
+ if (first && !first.startsWith('## [')) {
53
+ const prefix = /^## v/.test(first) ? 'v' : '';
54
+ return /\(\d{4}-\d{2}-\d{2}\)/.test(first) ? `## ${prefix}${version} (${date})` : `## ${prefix}${version}`;
55
+ }
56
+ return `## [${version}] - ${date}`;
57
+ }
58
+
59
+ export function buildEntry(version, commits, date, heading = `## [${version}] - ${date}`) {
60
+ const sections = Object.fromEntries(SECTIONS.map((s) => [s, []]));
61
+ const unclassified = [];
62
+ const omitted = [];
63
+ for (const commit of commits) {
64
+ const c = classify(commit);
65
+ const short = commit.hash.slice(0, 7);
66
+ if (c.section) sections[c.section].push(c.text.charAt(0).toUpperCase() + c.text.slice(1));
67
+ else if (c.omitted) omitted.push({ commit: short, subject: commit.subject, reason: c.reason });
68
+ else unclassified.push({ commit: short, subject: commit.subject, reason: c.reason });
69
+ }
70
+ let entry = `${heading}\n`;
71
+ for (const name of SECTIONS) {
72
+ if (sections[name].length === 0) continue;
73
+ entry += `\n### ${name}\n\n${sections[name].map((t) => `- ${t}`).join('\n')}\n`;
74
+ }
75
+ return { entry, sections, unclassified, omitted };
76
+ }
77
+
78
+ /** Inserts the entry below [Unreleased] (or the header), above the newest release. */
79
+ export function insertEntry(existing, version, entry) {
80
+ if (existing === null) return `${HEADER}\n${entry}`;
81
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
82
+ if (new RegExp(`^## \\[?v?${escaped}(\\]|\\s|$)`, 'm').test(existing)) {
83
+ throw new Error(`CHANGELOG already has a section for ${version}`);
84
+ }
85
+ const lines = existing.split('\n');
86
+ const idx = lines.findIndex((l) => RELEASE_HEADING.test(l));
87
+ if (idx === -1) return `${existing.replace(/\n*$/, '\n')}\n${entry}`;
88
+ lines.splice(idx, 0, ...entry.replace(/\n*$/, '\n').split('\n'));
89
+ return lines.join('\n');
90
+ }
91
+
92
+ export function changelog({ cwd = '.', version, since, date = new Date().toISOString().slice(0, 10), write }) {
93
+ const from = since ?? lastTag(cwd);
94
+ const commits = readCommits(cwd, from);
95
+ const existing = write && existsSync(write) ? readFileSync(write, 'utf8') : null;
96
+ const built = buildEntry(version, commits, date, headingFor(existing, version, date));
97
+ const checks = [];
98
+ const total = Object.values(built.sections).reduce((n, s) => n + s.length, 0);
99
+ checks.push(total > 0
100
+ ? check('changelog.entries', 'pass', `${total} change(s) since ${from ?? 'the first commit'}`)
101
+ : check('changelog.entries', 'warn', `No user-facing changes found since ${from ?? 'the first commit'}`, { fix: 'Ask the user what this release changes and write the entry by hand.' }));
102
+ if (built.unclassified.length) {
103
+ checks.push(check('changelog.unclassified', 'warn', `${built.unclassified.length} commit(s) could not be classified`, { fix: 'Show them to the user and ask which section each belongs in, or whether to drop it.' }));
104
+ }
105
+ if (write) {
106
+ writeFileSync(write, insertEntry(existing, version, built.entry));
107
+ checks.push(check('changelog.write', 'pass', `${existing === null ? 'Created' : 'Updated'} ${write}`));
108
+ }
109
+ return result(checks, { since: from, entry: built.entry, unclassified: built.unclassified, omitted: built.omitted });
110
+ }
111
+
112
+ const usage = 'Usage: changelog.mjs --version <version> [--since <tag>] [--date YYYY-MM-DD] [--write CHANGELOG.md] [--cwd <dir>]';
113
+
114
+ await runCli(import.meta.url, {
115
+ usage,
116
+ options: { version: { type: 'string' }, since: { type: 'string' }, date: { type: 'string' }, write: { type: 'string' }, cwd: { type: 'string', default: '.' } },
117
+ }, (v) => {
118
+ required(v, 'version');
119
+ return changelog({ cwd: v.cwd, version: v.version, since: v.since, date: v.date, write: v.write });
120
+ });
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Git checks before a release: clean tree, expected branch, tag not taken, in sync with upstream.
3
+ import { runCli, check, result, required } from './lib/cli.mjs';
4
+ import { git } from './lib/git.mjs';
5
+
6
+ export function defaultBranch(cwd, remote) {
7
+ const head = git(['symbolic-ref', '--short', `refs/remotes/${remote}/HEAD`], cwd);
8
+ if (head) return head.slice(remote.length + 1);
9
+ for (const name of ['main', 'master']) {
10
+ if (git(['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], cwd) !== null) return name;
11
+ }
12
+ return null;
13
+ }
14
+
15
+ export function tagStyle(cwd) {
16
+ const tags = (git(['tag', '--list'], cwd) ?? '').split('\n').filter(Boolean);
17
+ const prefixed = tags.filter((t) => /^v\d/.test(t)).length;
18
+ const bare = tags.filter((t) => /^\d/.test(t)).length;
19
+ return { prefix: bare > prefixed ? '' : 'v', tagCount: tags.length };
20
+ }
21
+
22
+ export function gitPreflight({ cwd = '.', branch, tag, remote = 'origin', checkRemote = true }) {
23
+ const checks = [];
24
+ if (git(['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') {
25
+ return result([check('git.repo', 'fail', 'Not inside a git repository', { fix: 'Run the release from the library repository root.' })]);
26
+ }
27
+
28
+ const status = git(['status', '--porcelain'], cwd) ?? '';
29
+ const dirty = status.split('\n').filter(Boolean);
30
+ checks.push(dirty.length === 0
31
+ ? check('git.clean', 'pass', 'Working tree is clean')
32
+ : check('git.clean', 'fail', `Working tree has ${dirty.length} uncommitted change(s)`, {
33
+ files: dirty.slice(0, 20).map((l) => l.slice(3)),
34
+ fix: 'Commit or stash these changes first, so the release commit contains only the version bump and changelog.',
35
+ }));
36
+
37
+ const current = git(['symbolic-ref', '--short', 'HEAD'], cwd);
38
+ const expected = branch ?? defaultBranch(cwd, remote);
39
+ if (!current) {
40
+ checks.push(check('git.branch', 'fail', 'HEAD is detached', { fix: `Check out the release branch${expected ? ` (${expected})` : ''}.` }));
41
+ } else if (!expected) {
42
+ checks.push(check('git.branch', 'warn', `On branch ${current}; could not determine the default branch`, { fix: 'Pass --branch with the branch releases are made from.' }));
43
+ } else {
44
+ checks.push(current === expected
45
+ ? check('git.branch', 'pass', `On expected branch ${current}`)
46
+ : check('git.branch', 'fail', `On branch ${current}, expected ${expected}`, { fix: `Check out ${expected}, or confirm with the user that releasing from ${current} is intended and pass --branch ${current}.` }));
47
+ }
48
+
49
+ const upstream = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], cwd);
50
+ if (upstream) {
51
+ const behind = Number(git(['rev-list', '--count', 'HEAD..@{u}'], cwd) ?? 0);
52
+ const ahead = Number(git(['rev-list', '--count', '@{u}..HEAD'], cwd) ?? 0);
53
+ if (behind > 0) checks.push(check('git.upstream', 'fail', `Branch is ${behind} commit(s) behind ${upstream} (as of the last fetch)`, { fix: 'Pull the latest changes before releasing.' }));
54
+ else if (ahead > 0) checks.push(check('git.upstream', 'warn', `Branch has ${ahead} unpushed commit(s) on top of ${upstream}`, { fix: 'Make sure these commits are meant to be part of the release.' }));
55
+ else checks.push(check('git.upstream', 'pass', `In sync with ${upstream} (as of the last fetch)`));
56
+ } else {
57
+ checks.push(check('git.upstream', 'warn', 'Branch has no upstream; cannot compare with the remote'));
58
+ }
59
+
60
+ if (tag) {
61
+ const local = git(['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], cwd);
62
+ checks.push(local === null
63
+ ? check('git.tag.local', 'pass', `Tag ${tag} does not exist locally`)
64
+ : check('git.tag.local', 'fail', `Tag ${tag} already exists locally`, { fix: 'This version was probably released or attempted before. Choose a new version, or ask the user how this tag was created.' }));
65
+ if (checkRemote) {
66
+ const remoteTags = git(['ls-remote', '--tags', remote, `refs/tags/${tag}`], cwd);
67
+ if (remoteTags === null) checks.push(check('git.tag.remote', 'warn', `Could not query remote "${remote}" for tag ${tag}`));
68
+ else checks.push(remoteTags === ''
69
+ ? check('git.tag.remote', 'pass', `Tag ${tag} does not exist on ${remote}`)
70
+ : check('git.tag.remote', 'fail', `Tag ${tag} already exists on ${remote}`, { fix: 'Choose a new version.' }));
71
+ }
72
+ }
73
+
74
+ return result(checks, { branch: current, expectedBranch: expected, ...tagStyle(cwd) });
75
+ }
76
+
77
+ const usage = `Usage: git-preflight.mjs [--branch <name>] [--tag <tag>] [--remote origin] [--cwd <dir>] [--offline]`;
78
+
79
+ await runCli(import.meta.url, {
80
+ usage,
81
+ options: {
82
+ branch: { type: 'string' }, tag: { type: 'string' }, remote: { type: 'string', default: 'origin' },
83
+ cwd: { type: 'string', default: '.' }, offline: { type: 'boolean', default: false },
84
+ },
85
+ }, (v) => {
86
+ if (v.tag !== undefined) required(v, 'tag');
87
+ return gitPreflight({ cwd: v.cwd, branch: v.branch, tag: v.tag, remote: v.remote, checkRemote: !v.offline });
88
+ });
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // Creates a GitHub release from a CHANGELOG section. Without --create it only
3
+ // prints the command, so nothing outward-facing happens by accident.
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { runCli, check, result, required } from './lib/cli.mjs';
8
+
9
+ export function extractSection(changelogText, version) {
10
+ const lines = changelogText.split('\n');
11
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
12
+ const start = lines.findIndex((l) => new RegExp(`^## \\[?v?${escaped}(\\]|\\s|$)`).test(l));
13
+ if (start === -1) return null;
14
+ let end = lines.findIndex((l, i) => i > start && /^## \[?v?\d+\.\d+/.test(l));
15
+ if (end === -1) end = lines.length;
16
+ // Drop link reference definitions that trail the last section.
17
+ return lines.slice(start + 1, end).filter((l) => !/^\[[^\]]+\]:\s*\S+/.test(l)).join('\n').trim();
18
+ }
19
+
20
+ function ghStatus() {
21
+ try {
22
+ execFileSync('gh', ['--version'], { stdio: 'ignore' });
23
+ } catch {
24
+ return 'missing';
25
+ }
26
+ try {
27
+ execFileSync('gh', ['auth', 'status'], { stdio: 'ignore' });
28
+ return 'ready';
29
+ } catch {
30
+ return 'unauthenticated';
31
+ }
32
+ }
33
+
34
+ const quote = (s) => (/^[\w./:@-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
35
+
36
+ export function githubRelease({ cwd = '.', tag, version, changelogPath, title, draft = false, create = false }) {
37
+ const file = join(cwd, changelogPath);
38
+ if (!existsSync(file)) return result([check('release.changelog', 'fail', `${changelogPath} not found`)]);
39
+ const notes = extractSection(readFileSync(file, 'utf8'), version);
40
+ if (!notes) return result([check('release.changelog', 'fail', `No section for ${version} in ${changelogPath}`, { fix: 'Run changelog.mjs first.' })]);
41
+
42
+ const notesDir = join(cwd, 'build', 'release-agent');
43
+ mkdirSync(notesDir, { recursive: true });
44
+ const notesFile = join(notesDir, `release-notes-${version}.md`);
45
+ writeFileSync(notesFile, `${notes}\n`);
46
+
47
+ const args = ['release', 'create', tag, '--title', title ?? version, '--notes-file', notesFile, '--verify-tag'];
48
+ if (draft) args.push('--draft');
49
+ const command = `gh ${args.map(quote).join(' ')}`;
50
+ const data = { notes, notesFile, command };
51
+
52
+ const gh = ghStatus();
53
+ if (!create) return result([check('release.dry-run', 'pass', `Not created (no --create). Command: ${command}`)], { ...data, gh });
54
+ if (gh !== 'ready') {
55
+ const why = gh === 'missing' ? 'gh CLI is not installed' : 'gh CLI is not logged in';
56
+ return result([check('release.gh', 'warn', `${why}; run the command yourself or create the release on github.com`, {
57
+ fix: gh === 'missing' ? 'Install gh (https://cli.github.com) or create the release in the browser with the notes file.' : 'Run `gh auth login`, then retry.',
58
+ })], { ...data, gh });
59
+ }
60
+ try {
61
+ const url = execFileSync('gh', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
62
+ return result([check('release.create', 'pass', `Created GitHub release ${tag}`)], { ...data, url });
63
+ } catch (err) {
64
+ return result([check('release.create', 'fail', `gh failed: ${(err.stderr || err.message).toString().trim()}`, {
65
+ fix: 'If the tag is not on the remote yet, push it first (gh is called with --verify-tag).',
66
+ })], data);
67
+ }
68
+ }
69
+
70
+ const usage = 'Usage: github-release.mjs --tag <tag> --version <version> [--changelog CHANGELOG.md] [--title <title>] [--draft] [--create] [--cwd <dir>]';
71
+
72
+ await runCli(import.meta.url, {
73
+ usage,
74
+ options: {
75
+ tag: { type: 'string' }, version: { type: 'string' }, changelog: { type: 'string', default: 'CHANGELOG.md' },
76
+ title: { type: 'string' }, draft: { type: 'boolean', default: false }, create: { type: 'boolean', default: false },
77
+ cwd: { type: 'string', default: '.' },
78
+ },
79
+ }, (v) => {
80
+ required(v, 'tag', 'version');
81
+ return githubRelease({ cwd: v.cwd, tag: v.tag, version: v.version, changelogPath: v.changelog, title: v.title, draft: v.draft, create: v.create });
82
+ });
@@ -0,0 +1,66 @@
1
+ // Common CLI plumbing: every script prints one JSON object { ok, checks, data? }
2
+ // and exits 0 (all checks passed or warned), 1 (a check failed) or 2 (usage error).
3
+ import { parseArgs } from 'node:util';
4
+ import { realpathSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ export function check(id, status, message, extra = {}) {
8
+ return { id, status, message, ...extra };
9
+ }
10
+
11
+ export function result(checks, data) {
12
+ const ok = checks.every((c) => c.status !== 'fail');
13
+ return data === undefined ? { ok, checks } : { ok, checks, data };
14
+ }
15
+
16
+ export function isMain(metaUrl) {
17
+ if (!process.argv[1]) return false;
18
+ try {
19
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(metaUrl));
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ function emit(obj, exitCode) {
26
+ process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
27
+ process.exitCode = exitCode;
28
+ }
29
+
30
+ export function progress(message) {
31
+ process.stderr.write(`${message}\n`);
32
+ }
33
+
34
+ /**
35
+ * Runs `handler(values, positionals)` when the module is executed directly.
36
+ * The handler returns a result object; thrown errors become { ok: false, error }.
37
+ */
38
+ export async function runCli(metaUrl, { options = {}, usage, allowPositionals = false }, handler) {
39
+ if (!isMain(metaUrl)) return;
40
+ let parsed;
41
+ try {
42
+ parsed = parseArgs({ options: { help: { type: 'boolean' }, ...options }, allowPositionals, strict: true });
43
+ } catch (err) {
44
+ emit({ ok: false, error: err.message, usage }, 2);
45
+ return;
46
+ }
47
+ if (parsed.values.help) {
48
+ process.stdout.write(`${usage}\n`);
49
+ return;
50
+ }
51
+ try {
52
+ const out = await handler(parsed.values, parsed.positionals);
53
+ emit(out, out.ok ? 0 : 1);
54
+ } catch (err) {
55
+ if (err instanceof UsageError) emit({ ok: false, error: err.message, usage }, 2);
56
+ else emit({ ok: false, error: err.message }, 1);
57
+ }
58
+ }
59
+
60
+ export class UsageError extends Error {}
61
+
62
+ export function required(values, ...names) {
63
+ for (const name of names) {
64
+ if (values[name] === undefined || values[name] === '') throw new UsageError(`--${name} is required`);
65
+ }
66
+ }
@@ -0,0 +1,18 @@
1
+ import { execFileSync } from 'node:child_process';
2
+
3
+ /** Runs git and returns trimmed stdout, or null when git exits non-zero. */
4
+ export function git(args, cwd) {
5
+ try {
6
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ export function lastTag(cwd) {
13
+ return git(['describe', '--tags', '--abbrev=0'], cwd);
14
+ }
15
+
16
+ export function isTracked(file, cwd) {
17
+ return git(['ls-files', '--error-unmatch', file], cwd) !== null;
18
+ }