@afoures/auto-release 0.2.12 → 0.4.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.
@@ -7,17 +7,17 @@ import { relative } from "node:path";
7
7
 
8
8
  //#region src/lib/commands/tag-release-commit.ts
9
9
  /**
10
- * Get the version of an app at a specific git revision
10
+ * Get the version of a project at a specific git revision
11
11
  */
12
- async function get_app_version_at_revision(app, root, revision) {
12
+ async function get_project_version_at_revision(project, root, revision) {
13
13
  try {
14
- const version = await compute_current_version(app, { get_file_content: (file_path) => {
14
+ const version = await compute_current_version(project, { get_file_content: (file_path) => {
15
15
  const relative_path = relative(root, file_path);
16
16
  return read_file_at_revision(root, revision, relative_path);
17
- } }) ?? app.versioning.initial_version;
18
- if (!app.versioning.validate({ version })) return {
17
+ } }) ?? project.versioning.initial_version;
18
+ if (!project.versioning.validate({ version })) return {
19
19
  ok: false,
20
- error: `Invalid version format for ${app.name} at revision ${revision}: ${version}`
20
+ error: `Invalid version format for ${project.name} at revision ${revision}: ${version}`
21
21
  };
22
22
  return {
23
23
  ok: true,
@@ -26,7 +26,7 @@ async function get_app_version_at_revision(app, root, revision) {
26
26
  } catch (error) {
27
27
  return {
28
28
  ok: false,
29
- error: `Failed to get version for ${app.name} at revision ${revision}: ${error.message}`
29
+ error: `Failed to get version for ${project.name} at revision ${revision}: ${error.message}`
30
30
  };
31
31
  }
32
32
  }
@@ -60,34 +60,37 @@ const tag_release_commit = create_command({
60
60
  status: "success",
61
61
  message: "HEAD has no parent commit - nothing to tag"
62
62
  };
63
- const changed_apps = [];
64
- for (const app of config.managed_applications) {
65
- const head_result = await get_app_version_at_revision(app, root, head_sha);
63
+ const changed_projects = [];
64
+ for (const project of config.managed_projects) {
65
+ const head_result = await get_project_version_at_revision(project, root, head_sha);
66
66
  if (!head_result.ok) return {
67
67
  status: "error",
68
- error: `Failed to get HEAD version for ${app.name}: ${head_result.error}`
68
+ error: `Failed to get HEAD version for ${project.name}: ${head_result.error}`
69
69
  };
70
- const base_result = await get_app_version_at_revision(app, root, base_sha);
70
+ const base_result = await get_project_version_at_revision(project, root, base_sha);
71
71
  if (!base_result.ok) return {
72
72
  status: "error",
73
- error: `Failed to get base version for ${app.name}: ${base_result.error}`
73
+ error: `Failed to get base version for ${project.name}: ${base_result.error}`
74
74
  };
75
- if (head_result.version !== base_result.version) changed_apps.push({
76
- app,
75
+ if (head_result.version !== base_result.version) changed_projects.push({
76
+ project,
77
77
  head_version: head_result.version,
78
78
  base_version: base_result.version
79
79
  });
80
80
  }
81
- if (changed_apps.length === 0) return {
81
+ if (changed_projects.length === 0) return {
82
82
  status: "success",
83
83
  message: "No version changes detected"
84
84
  };
85
- logger.info(`Detected version changes in ${changed_apps.length} app(s):`);
86
- for (const { app, head_version, base_version } of changed_apps) logger.info(` ${app.name}: ${base_version} → ${head_version}`);
85
+ logger.info(`Detected version changes in ${changed_projects.length} project(s):`);
86
+ for (const { project, head_version, base_version } of changed_projects) logger.info(` ${project.name}: ${base_version} → ${head_version}`);
87
87
  if (dry_run) {
88
88
  logger.info("\nDry run - would create tags and releases:");
89
- for (const { app, head_version } of changed_apps) {
90
- const tag = `${app.name}@${head_version}`;
89
+ for (const { project, head_version } of changed_projects) {
90
+ const tag = config.git.tag_generator({
91
+ project: { name: project.name },
92
+ version: head_version
93
+ });
91
94
  logger.info(` - Tag: ${tag}`);
92
95
  logger.info(` - Release: ${tag}`);
93
96
  }
@@ -96,15 +99,18 @@ const tag_release_commit = create_command({
96
99
  message: "Dry run completed - no changes were made"
97
100
  };
98
101
  }
99
- const tagged_apps = [];
102
+ const tagged_projects = [];
100
103
  const errors = [];
101
- for (const { app, head_version } of changed_apps) {
102
- const tag = `${app.name}@${head_version}`;
104
+ for (const { project, head_version } of changed_projects) {
105
+ const tag = config.git.tag_generator({
106
+ project: { name: project.name },
107
+ version: head_version
108
+ });
103
109
  try {
104
110
  const existing_tag = await config.git.platform.get_tag({ tag });
105
111
  if (existing_tag !== null) if (existing_tag.commit_sha === head_sha) {
106
112
  logger.info(`Tag ${tag} already exists on commit ${head_sha} - skipping`);
107
- tagged_apps.push(tag);
113
+ tagged_projects.push(tag);
108
114
  continue;
109
115
  } else {
110
116
  errors.push(`Tag ${tag} already exists but points to different commit (${existing_tag.commit_sha} vs ${head_sha})`);
@@ -119,16 +125,16 @@ const tag_release_commit = create_command({
119
125
  tag,
120
126
  release: {
121
127
  name: tag,
122
- body: app.versioning.formatter.generate_release_notes({
123
- app: {
124
- name: app.name,
125
- changelog: app.changelog
128
+ body: project.versioning.formatter.generate_release_notes({
129
+ project: {
130
+ name: project.name,
131
+ changelog: project.changelog
126
132
  },
127
133
  version: head_version
128
134
  })
129
135
  }
130
136
  });
131
- tagged_apps.push(tag);
137
+ tagged_projects.push(tag);
132
138
  logger.success(`Tagged and released ${tag}`);
133
139
  } catch (error) {
134
140
  errors.push(`Failed to tag/release ${tag}: ${error.message}`);
@@ -140,7 +146,7 @@ const tag_release_commit = create_command({
140
146
  };
141
147
  return {
142
148
  status: "success",
143
- message: tagged_apps.length === 1 ? `Tagged 1 app: ${tagged_apps[0]}` : `Tagged ${tagged_apps.length} apps: ${tagged_apps.join(", ")}`
149
+ message: tagged_projects.length === 1 ? `Tagged 1 project: ${tagged_projects[0]}` : `Tagged ${tagged_projects.length} projects: ${tagged_projects.join(", ")}`
144
150
  };
145
151
  }
146
152
  });
@@ -1,5 +1,5 @@
1
1
  import { GitPlatformClient } from "./platforms/types.mjs";
2
- import { AutoReleaseConfig, ManagedApplication } from "./types.mjs";
2
+ import { AutoReleaseConfig, ManagedProject } from "./types.mjs";
3
3
 
4
4
  //#region src/lib/config.d.ts
5
5
  declare function define_config<const config extends AutoReleaseConfig>(config: config): InternalConfig;
@@ -14,8 +14,14 @@ declare class InternalConfig {
14
14
  platform: GitPlatformClient;
15
15
  target_branch: string;
16
16
  default_release_branch_prefix: string;
17
+ tag_generator: (args: {
18
+ project: {
19
+ name: string;
20
+ };
21
+ version: string;
22
+ }) => string;
17
23
  };
18
- get managed_applications(): Array<ManagedApplication>;
24
+ get managed_projects(): Array<ManagedProject>;
19
25
  }
20
26
  //#endregion
21
27
  export { define_config };
@@ -33,16 +33,19 @@ var InternalConfig = class {
33
33
  return {
34
34
  platform: this.#config.git.platform,
35
35
  target_branch: this.#config.git.target_branch || "main",
36
- default_release_branch_prefix: this.#config.git.default_release_branch_prefix || "release"
36
+ default_release_branch_prefix: this.#config.git.default_release_branch_prefix || "release",
37
+ tag_generator: this.#config.git.tag_generator || (({ project, version }) => `${project.name}@${version}`)
37
38
  };
38
39
  }
39
- get managed_applications() {
40
- return Object.entries(this.#config.apps).map(([name, definition]) => {
40
+ get managed_projects() {
41
+ return Object.entries(this.#config.projects).map(([name, definition]) => {
41
42
  const components = definition.components.map((component) => component(this.folder));
42
43
  return {
43
44
  name,
44
45
  ...definition,
45
- components
46
+ components,
47
+ release_group: definition.release_group || this.#config.default_project_config?.release_group || name,
48
+ options: { skip_release_if_no_change_file: definition.options?.skip_release_if_no_change_file || this.#config.default_project_config?.options?.skip_release_if_no_change_file || false }
46
49
  };
47
50
  });
48
51
  }
@@ -130,16 +133,15 @@ async function find_nearest_config(options) {
130
133
  */
131
134
  function validate_config(config) {
132
135
  if (!config.git) throw new Error("Auto-release config must have a \"git\" platform. Use github() or gitlab() from \"auto-release/providers\"");
133
- if (!config.apps || typeof config.apps !== "object" || Array.isArray(config.apps)) throw new Error("Auto-release config must have an \"apps\" record (object keyed by app name)");
134
- const app_entries = Object.entries(config.apps);
135
- if (app_entries.length === 0) throw new Error("Auto-release config must have at least one app");
136
- for (const [name, app] of app_entries) {
137
- if (!app.components || !Array.isArray(app.components)) throw new Error(`App "${name}" must have a "components" array`);
138
- if (app.components.length === 0) throw new Error(`App "${name}" must have at least one component`);
139
- if (!app.versioning) throw new Error(`App "${name}" must have a "versioning" config`);
140
- if (typeof app.versioning.bump !== "function") throw new Error(`App "${name}" versioning must have a "bump" function. Did you forget to call the strategy function?`);
141
- if (!app.versioning.allowed_changes || !Array.isArray(app.versioning.allowed_changes)) throw new Error(`App "${name}" versioning must have an "allowed_changes" array`);
142
- if (!app.changelog || typeof app.changelog !== "string") throw new Error(`App "${name}" must have a changelog path (string)`);
136
+ if (!config.projects || typeof config.projects !== "object" || Array.isArray(config.projects)) throw new Error("Auto-release config must have a \"projects\" record (object keyed by project name)");
137
+ const project_entries = Object.entries(config.projects);
138
+ for (const [name, project] of project_entries) {
139
+ if (!project.components || !Array.isArray(project.components)) throw new Error(`Project "${name}" must have a "components" array`);
140
+ if (project.components.length === 0) throw new Error(`Project "${name}" must have at least one component`);
141
+ if (!project.versioning) throw new Error(`Project "${name}" must have a "versioning" config`);
142
+ if (typeof project.versioning.bump !== "function") throw new Error(`Project "${name}" versioning must have a "bump" function. Did you forget to call the strategy function?`);
143
+ if (!project.versioning.allowed_changes || !Array.isArray(project.versioning.allowed_changes)) throw new Error(`Project "${name}" versioning must have an "allowed_changes" array`);
144
+ if (!project.changelog || typeof project.changelog !== "string") throw new Error(`Project "${name}" must have a changelog path (string)`);
143
145
  }
144
146
  }
145
147
 
@@ -88,12 +88,10 @@ function default_formatter({ allowed_changes, display_map }) {
88
88
  },
89
89
  format_changelog(changelog, context) {
90
90
  const lines = [];
91
- if (changelog.root.title) lines.push(changelog.root.title);
92
- else lines.push(`# \`${context.app.name}\` changelog`);
93
- if (changelog.root.description.length > 0) {
94
- console.log(changelog.root.description);
95
- lines.push(changelog.root.description.join("\n"));
96
- } else lines.push(`This is the changelog for \`${context.app.name}\`.`);
91
+ if (changelog.root.title) lines.push(changelog.root.title, "");
92
+ else lines.push(`# \`${context.project.name}\` changelog`, "");
93
+ if (changelog.root.description.length > 0) lines.push(changelog.root.description.join("\n"), "");
94
+ else lines.push(`This is the changelog for \`${context.project.name}\`.`, "");
97
95
  for (const release of changelog.releases) {
98
96
  const release_lines = [`## ${release.version}`, ""];
99
97
  for (const change_kind of allowed_changes) {
@@ -107,17 +105,19 @@ function default_formatter({ allowed_changes, display_map }) {
107
105
  if (release.changes.length === 0) release_lines.push("No changes in this release.", "");
108
106
  lines.push(release_lines.join("\n"));
109
107
  }
110
- return lines.join("\n\n");
108
+ return lines.join("\n");
111
109
  },
112
- generate_release_notes({ app, version }) {
110
+ generate_release_notes({ project, version }) {
113
111
  const hash = version.replaceAll(".", "");
114
- const file = `${app.changelog}#${hash}`;
115
- return `[See the changelog for ${app.name}@${version} release notes](${file})`;
112
+ const file = `${project.changelog}#${hash}`;
113
+ return `[See the changelog for ${project.name}@${version} release notes](${file})`;
116
114
  },
117
- generate_pr_body({ app, current_version, next_version, changes }) {
115
+ generate_pr_body({ project, current_version, next_version, changes }) {
118
116
  const lines = [];
119
- lines.push(`# Release ${app.name} ${next_version}`);
120
- lines.push(`Version: ${current_version} → ${next_version}`);
117
+ lines.push(`# Automated release for \`${project.name}\``);
118
+ lines.push(`Version: \`${current_version}\`\`${next_version}\``);
119
+ lines.push("");
120
+ lines.push("## Changelog");
121
121
  const grouped = /* @__PURE__ */ new Map();
122
122
  for (const change of changes) {
123
123
  const group = grouped.get(change.kind) ?? [];
@@ -130,7 +130,7 @@ function default_formatter({ allowed_changes, display_map }) {
130
130
  const labels = resolved_display_map[kind];
131
131
  const heading = labels?.plural ?? labels?.singular ?? kind;
132
132
  lines.push("");
133
- lines.push(`## ${heading}`);
133
+ lines.push(`### ${heading}`);
134
134
  for (const change of items) {
135
135
  lines.push(change.summary);
136
136
  lines.push("");
@@ -13,22 +13,42 @@ interface AutoReleaseConfig {
13
13
  platform: GitPlatformClient;
14
14
  target_branch?: string;
15
15
  default_release_branch_prefix?: string;
16
+ tag_generator?: (args: {
17
+ project: {
18
+ name: string;
19
+ };
20
+ version: string;
21
+ }) => string;
16
22
  };
17
- apps: Record<string, AppDefinition>;
23
+ default_project_config?: {
24
+ release_group?: string;
25
+ options?: {
26
+ skip_release_if_no_change_file?: boolean;
27
+ };
28
+ };
29
+ projects: Record<string, ProjectDefinition>;
18
30
  }
19
31
  /**
20
- * App definition
32
+ * Project definition
21
33
  */
22
- interface AppDefinition {
34
+ interface ProjectDefinition {
23
35
  components: Array<Component>;
24
36
  versioning: VersionManager;
25
37
  changelog: string;
38
+ release_group?: string;
39
+ options?: {
40
+ skip_release_if_no_change_file?: boolean;
41
+ };
26
42
  }
27
- type ManagedApplication = {
43
+ type ManagedProject = {
28
44
  name: string;
29
45
  components: Array<ResolvedComponent>;
30
46
  versioning: VersionManager;
31
47
  changelog: string;
48
+ release_group: string;
49
+ options: {
50
+ skip_release_if_no_change_file: boolean;
51
+ };
32
52
  };
33
53
  //#endregion
34
- export { AutoReleaseConfig, ManagedApplication };
54
+ export { AutoReleaseConfig, ManagedProject };
@@ -1,6 +1,3 @@
1
- import "./git.mjs";
2
- import { cancel, confirm, isCancel, log } from "@clack/prompts";
3
-
4
1
  //#region src/lib/utils/branch-protection.ts
5
2
  /**
6
3
  * Detect if we're running in a CI environment
@@ -0,0 +1,19 @@
1
+ //#region src/lib/utils/group.ts
2
+ function group_projects(projects) {
3
+ const groups = /* @__PURE__ */ new Map();
4
+ for (const project of projects) {
5
+ const existing = groups.get(project.release_group) ?? [];
6
+ existing.push(project);
7
+ groups.set(project.release_group, existing);
8
+ }
9
+ return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, group_projects$1]) => ({
10
+ name,
11
+ projects: group_projects$1.sort((a, b) => a.name.localeCompare(b.name))
12
+ }));
13
+ }
14
+ function is_multi_project_group(group) {
15
+ return group.projects.length > 1;
16
+ }
17
+
18
+ //#endregion
19
+ export { group_projects, is_multi_project_group };
@@ -1,14 +1,14 @@
1
1
  //#region src/lib/utils/version.ts
2
- async function compute_current_version(app, { get_file_content }) {
2
+ async function compute_current_version(project, { get_file_content }) {
3
3
  const versions = /* @__PURE__ */ new Set();
4
- for (const component of app.components) for (const part of component.parts) {
4
+ for (const component of project.components) for (const part of component.parts) {
5
5
  const file_content = await get_file_content(part.file);
6
6
  if (file_content === null) continue;
7
7
  const version = part.get_current_version(file_content);
8
8
  versions.add(version);
9
9
  }
10
10
  if (versions.size === 0) return null;
11
- return Array.from(versions).sort((a, b) => app.versioning.compare(a, b)).at(-1) ?? null;
11
+ return Array.from(versions).sort((a, b) => project.versioning.compare(a, b)).at(-1) ?? null;
12
12
  }
13
13
 
14
14
  //#endregion
@@ -25,7 +25,7 @@ type Formatter<change_kinds extends string = string, parsed_changelog extends {
25
25
  * @returns Markdown string that will be written to the changelog file
26
26
  */
27
27
  format_changelog(changelog: NoInfer<parsed_changelog>, context: {
28
- app: {
28
+ project: {
29
29
  name: string;
30
30
  };
31
31
  }): string;
@@ -35,7 +35,7 @@ type Formatter<change_kinds extends string = string, parsed_changelog extends {
35
35
  * @returns Markdown string for PR body
36
36
  */
37
37
  generate_pr_body(options: {
38
- app: {
38
+ project: {
39
39
  name: string;
40
40
  };
41
41
  current_version: string;
@@ -43,12 +43,12 @@ type Formatter<change_kinds extends string = string, parsed_changelog extends {
43
43
  changes: ChangeFile<change_kinds>[];
44
44
  }): string;
45
45
  /**
46
- * Generate release notes for a given app to use in GitHub/GitLab release bodies.
46
+ * Generate release notes for a given project to use in GitHub/GitLab release bodies.
47
47
  * @param options - The options for generating release notes
48
48
  * @returns Markdown string for release body
49
49
  */
50
50
  generate_release_notes(options: {
51
- app: {
51
+ project: {
52
52
  name: string;
53
53
  changelog: string;
54
54
  };
@@ -0,0 +1,31 @@
1
+ # Change Files
2
+
3
+ Change files are stored in `.changes/<project-name>/` with format:
4
+
5
+ ```
6
+ <type>.<slug>.md
7
+ ```
8
+
9
+ Examples:
10
+
11
+ - `.changes/my-app/major.add-authentication.md`
12
+ - `.changes/my-app/patch.fix-login-bug.md`
13
+
14
+ The change files folder can be customized.
15
+
16
+ ## Format
17
+
18
+ **Simple** (title only):
19
+
20
+ ```markdown
21
+ Fix authentication bug in login flow
22
+ ```
23
+
24
+ **Detailed** (with description):
25
+
26
+ ```markdown
27
+ This adds a comprehensive user profile page with:
28
+ - Avatar upload
29
+ - Bio and social links
30
+ - Privacy settings
31
+ ```
@@ -0,0 +1,109 @@
1
+ # Commands
2
+
3
+ ## `init`
4
+
5
+ Set up auto-release in your repository:
6
+
7
+ ```bash
8
+ auto-release init
9
+ ```
10
+
11
+ Interactively configures projects, versioning strategies, and git platform.
12
+
13
+ ## `check`
14
+
15
+ Validate configuration, change files, and release groups:
16
+
17
+ ```bash
18
+ auto-release check
19
+ ```
20
+
21
+ **Validations:**
22
+
23
+ - Component version consistency
24
+ - Change file content
25
+ - Group name conflicts (group names cannot match project names)
26
+ - Similar group names (case-insensitive)
27
+ - Group name special characters
28
+
29
+ Use in CI to ensure everything is valid before merging.
30
+
31
+ ## `record-change`
32
+
33
+ Create a new change file:
34
+
35
+ ```bash
36
+ # Interactive
37
+ auto-release record-change
38
+
39
+ # Non-interactive
40
+ auto-release record-change --project my-app --type minor
41
+ ```
42
+
43
+ ## `list`
44
+
45
+ List all projects managed by `auto-release` with their current versions, grouped by `release_group`:
46
+
47
+ ```bash
48
+ auto-release list
49
+ ```
50
+
51
+ **Output example:**
52
+
53
+ ```
54
+ found 5 projects in 3 groups:
55
+
56
+ Group: frontend (2 projects)
57
+ web-app (1.2.3)
58
+ ./apps/web/package.json
59
+ mobile-app (2.0.1)
60
+ ./apps/mobile/package.json
61
+
62
+ Group: api-service (1 project)
63
+ api-service (0.5.0)
64
+ ./services/api/Cargo.toml
65
+ ```
66
+
67
+ ## `generate-release-pr`
68
+
69
+ Create or update release PRs based on change files:
70
+
71
+ ```bash
72
+ # Preview changes
73
+ auto-release generate-release-pr --dry-run
74
+
75
+ # Create/update PRs
76
+ auto-release generate-release-pr
77
+ ```
78
+
79
+ Projects are grouped by `release_group` in the configuration. Projects in the same group are released together in a single PR.
80
+
81
+ **PR Structure:**
82
+
83
+ - **Branch**: `release/<group-name>` (e.g., `release/frontend`)
84
+ - **Title**: `release: project-a@1.0.0, project-b@2.0.0` (lists all projects with versions)
85
+ - **Body**: Contains sections for each project's changelog
86
+
87
+ ## `tag-release-commit`
88
+
89
+ Create git tags and releases for version changes:
90
+
91
+ ```bash
92
+ # Preview what would be tagged
93
+ auto-release tag-release-commit --dry-run
94
+
95
+ # Create tags and releases
96
+ auto-release tag-release-commit
97
+ ```
98
+
99
+ Compares HEAD with HEAD^1 to detect version changes. Creates tags in format `project-name@version`.
100
+
101
+ ## `manual-release`
102
+
103
+ Create a manual release using existing change files:
104
+
105
+ ```bash
106
+ auto-release manual-release
107
+ ```
108
+
109
+ Useful for local testing or emergency releases.