diff --git a/README.md b/README.md index 3e10bb33..8d97da90 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,8 @@ ferrflow migrate --from semantic-release It maps `tagFormat`, `branches`, and the common plugins (`changelog`, `exec`, `github`/`gitlab`) to their FerrFlow equivalents, and prints a summary of what mapped, what was ignored, and what needs manual review. Anything without a FerrFlow equivalent is surfaced, never guessed. JSON `.releaserc` is supported today; YAML `.releaserc` and JS `release.config.js` are reported as unsupported rather than mis-parsed. +`ferrflow migrate --from release-please` also maps `extra-files` (`generic` markers to a `txt` selector, `toml` on `Cargo.toml` and `json` on `$.version` to their formats), `bump-minor-pre-major` and `bump-patch-for-minor-pre-major` to `preMajor` (`breaking: minor`, `feat: patch`), and writes `autoMergeReleases: false` since release-please never merges its release PR. Lockfile updates are enabled when a versioned manifest has one. Entries it cannot express are listed under "Review manually". Only non-default keys are written. + ### JSON Schema Add `$schema` to get autocompletion and validation in VS Code, WebStorm, and any JSON-aware editor: @@ -391,6 +393,21 @@ versioning = "calver" # override: date-based | `sequential` | `N` | `42` | Simple incrementing build number | | `zerover` | `0.MINOR.PATCH` | `0.15.2` | Permanently unstable, never hits 1.0 | +### Pre-1.0 bump rules + +By default a `semver` project jumps to `1.0.0` on its first breaking change. Set `preMajor` to stay in 0.x: while the current major is `0`, a breaking change bumps what `breaking` says and a `feat` bumps what `feat` says (`minor` or `patch`). `fix` is unchanged. + +```toml +[workspace] +versioning = "semver" + +[workspace.preMajor] +breaking = "minor" +feat = "patch" +``` + +From `0.5.0`: `feat!` gives `0.6.0`, `feat` and `fix` give `0.5.1`. This matches release-please's `bump-minor-pre-major` plus `bump-patch-for-minor-pre-major`. From `1.0.0` on it has no effect, and `ferrflow release --force-version 1.0.0` leaves 0.x. Packages can override it with their own `preMajor`. Unset keeps the standard rules. + ## Tag Template By default, FerrFlow tags single-repo releases as `v1.2.3` and monorepo releases as `api@v1.2.3`. Customize with `tag_template` at the workspace or package level using `{name}` and `{version}` placeholders. diff --git a/docs/site/data/schema/ferrflow-package.json b/docs/site/data/schema/ferrflow-package.json index 73ef0a8e..42e427dc 100644 --- a/docs/site/data/schema/ferrflow-package.json +++ b/docs/site/data/schema/ferrflow-package.json @@ -125,6 +125,52 @@ "zerover" ] }, + "preMajor": { + "type": "object", + "description": "Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, + "pre_major": { + "type": "object", + "description": "Snake_case form of `preMajor`, both spellings are accepted. Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, "versionTemplate": { "type": "string", "description": "Version format built from named variables. Variables: year, short_year, month, padded_month, day, padded_day, week, padded_week, quarter, seq, major, minor, patch. Each must be separated by a literal so the previous version can be read back. The literals cannot carry whitespace, control characters, or the characters git reserves in a ref name (~ ^ : ? * [ backslash), because the version becomes a git tag. Overrides workspace default. Takes precedence over 'versioning'.", diff --git a/docs/site/data/schema/ferrflow.json b/docs/site/data/schema/ferrflow.json index 37a4cc94..4fd7f5f3 100644 --- a/docs/site/data/schema/ferrflow.json +++ b/docs/site/data/schema/ferrflow.json @@ -54,6 +54,52 @@ ], "default": "semver" }, + "preMajor": { + "type": "object", + "description": "While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, + "pre_major": { + "type": "object", + "description": "Snake_case form of `preMajor`, both spellings are accepted. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, "versionTemplate": { "type": "string", "description": "Version format built from named variables. Variables: year, short_year, month, padded_month, day, padded_day, week, padded_week, quarter, seq, major, minor, patch. Each must be separated by a literal so the previous version can be read back. The literals cannot carry whitespace, control characters, or the characters git reserves in a ref name (~ ^ : ? * [ backslash), because the version becomes a git tag. Takes precedence over 'versioning'.", @@ -630,6 +676,52 @@ "zerover" ] }, + "preMajor": { + "type": "object", + "description": "Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, + "pre_major": { + "type": "object", + "description": "Snake_case form of `preMajor`, both spellings are accepted. Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, "versionTemplate": { "type": "string", "description": "Version format built from named variables. Variables: year, short_year, month, padded_month, day, padded_day, week, padded_week, quarter, seq, major, minor, patch. Each must be separated by a literal so the previous version can be read back. The literals cannot carry whitespace, control characters, or the characters git reserves in a ref name (~ ^ : ? * [ backslash), because the version becomes a git tag. Overrides workspace default. Takes precedence over 'versioning'.", diff --git a/docs/site/docs-en/configuration/config-file.md b/docs/site/docs-en/configuration/config-file.md index 37080e2c..e314689a 100644 --- a/docs/site/docs-en/configuration/config-file.md +++ b/docs/site/docs-en/configuration/config-file.md @@ -186,6 +186,7 @@ Global settings that apply to all packages. | `branch` | string | auto-detected | Branch to push to (detected from remote HEAD) | | `tagTemplate` | string | `"v{version}"` or `"{name}@v{version}"` | Tag naming pattern. Uses `{version}` and `{name}` placeholders. Defaults to `v{version}` for single-package repos and `{name}@v{version}` for monorepos. | | `versioning` | string | `"semver"` | Default versioning strategy for all packages | +| `preMajor` | object | unset | Remaps the bump of a `semver` project while its major is 0, see [Pre-1.0 bump rules](#pre-10-bump-rules) | | `releaseCommitMode` | string | `"commit"` | How to handle the release commit: `"commit"`, `"pr"`, or `"none"` | | `releaseCommitScope` | string | `"grouped"` | In a monorepo where several packages are bumped at once, whether to create a single `"grouped"` commit or one commit `"per-package"`. Only matters when multiple packages bump. | | `releaseCommitBody` | string | `"none"` | What goes in the body of the release commit, below the subject. `"none"` keeps the single-line subject. `"summary"` lists one line per released package with its commit count. `"full"` embeds the changelog section written for each package — under `"grouped"` scope each section is headed `## `. | @@ -404,6 +405,39 @@ versioning = "calver" +### Pre-1.0 bump rules + +By default a `semver` project jumps to `1.0.0` on its first breaking change. `preMajor` keeps a 0.x project in initial development instead: while the current major is `0`, a breaking change bumps what `breaking` says and a `feat` bumps what `feat` says (`minor` or `patch`). `fix` is unaffected. Both keys are optional. + +
+

JSON

{
+  "workspace": {
+    "versioning": "semver",
+    "preMajor": { "breaking": "minor", "feat": "patch" }
+  }
+}
+
+
+

TOML

[workspace]
+versioning = "semver"
+
+[workspace.preMajor]
+breaking = "minor"
+feat = "patch"
+
+
+

JSON5

{
+  workspace: {
+    versioning: "semver",
+    preMajor: { breaking: "minor", feat: "patch" },
+  },
+}
+
+
+
+ +From `0.5.0`: `feat!` gives `0.6.0`, `feat` and `fix` give `0.5.1`. This is release-please's `bump-minor-pre-major` plus `bump-patch-for-minor-pre-major`. From `1.0.0` on the setting has no effect, and `ferrflow release --force-version 1.0.0` leaves 0.x. It applies to the `semver` strategy only, and a package can override it with its own `preMajor`. Unset keeps the standard rules. + ### Pre-release channels The `branches` array maps branch names (or glob patterns) to pre-release channels. When FerrFlow runs on a branch matching an entry, it releases on that channel — e.g. `1.4.0-beta.1` instead of `1.4.0`. The same mapping is what `--channel` on `ferrflow check` and `ferrflow release` overrides ad-hoc. @@ -578,6 +612,7 @@ Defines a package to version. You can have one or many. | `sharedPaths` | no | `[]` | Paths that trigger this package when changed | | `dependsOn` | no | `[]` | Packages this package depends on. When a dependency is bumped, this package is bumped too — with the same bump type by default. Each entry is a package name, or `{ "name": "core", "propagate": "patch" }` to choose the policy (`same`, `major-on-major`, `patch`, `none`). | | `versioning` | no | inherited from workspace | Override versioning strategy for this package | +| `preMajor` | no | inherited from workspace | Override the pre-1.0 bump rules for this package | | `tagTemplate` | no | inherited from workspace | Override tag template for this package | | `floatingTags` | no | inherited from workspace | Override floating tags for this package | | `latestTag` | no | inherited from workspace | Override the floating alias tag for this package | diff --git a/docs/site/docs-fr/configuration/config-file.md b/docs/site/docs-fr/configuration/config-file.md index b30747b3..eb0215a9 100644 --- a/docs/site/docs-fr/configuration/config-file.md +++ b/docs/site/docs-fr/configuration/config-file.md @@ -186,6 +186,7 @@ Paramètres globaux qui s'appliquent à tous les packages. | `tagTemplate` | string | `"v{version}"` ou `"{name}@v{version}"` | Modèle de nommage des tags. Utilise les placeholders `{version}` et `{name}`. Par défaut `v{version}` pour les repos mono-package et `{name}@v{version}` pour les monorepos. | | `latestTag` | string | aucun | Modèle d'un tag alias flottant qui pointe toujours vers la dernière version non-préversion du package, par exemple `"latest"` ou `"{name}@latest"`. Absent par défaut. Volontairement **non** dérivé de `tagTemplate` : l'alias est un nom, pas une version, donc un `tagTemplate` en `v{version}` donne `latest`, jamais `vlatest`. En monorepo le modèle doit contenir `{name}`, sinon chaque package écrase le même ref et le dernier publié gagne. Les préversions ne le déplacent jamais, pas plus qu'un hotfix sur une ligne plus ancienne : publier 1.2.4 après 2.0.0 le laisse sur 2.0.0, sauf avec `--force`. | | `versioning` | string | `"semver"` | Stratégie de versionnage par défaut pour tous les packages | +| `preMajor` | object | unset | Remappe le bump d'un projet `semver` tant que sa majeure vaut 0, voir [Règles de bump avant 1.0](#regles-de-bump-avant-10) | | `releaseCommitMode` | string | `"commit"` | Gestion du commit de release : `"commit"`, `"pr"` ou `"none"` | | `releaseCommitScope` | string | `"grouped"` | Dans un monorepo où plusieurs packages sont bumpés en même temps, créer un seul commit `"grouped"` ou un commit `"per-package"`. N'a d'effet que quand plusieurs packages bumpent. | | `releaseCommitBody` | string | `"none"` | Ce que contient le corps du commit de release, sous la ligne de sujet. `"none"` conserve le sujet sur une seule ligne. `"summary"` liste une ligne par package publié avec son nombre de commits. `"full"` intègre la section de changelog écrite pour chaque package — en scope `"grouped"`, chaque section est titrée `## `. | @@ -328,6 +329,39 @@ versioning = "calver" +### Règles de bump avant 1.0 + +Par défaut, un projet `semver` passe à `1.0.0` dès son premier changement cassant. `preMajor` garde un projet 0.x en développement initial : tant que la majeure courante est `0`, un changement cassant bumpe selon `breaking` et un `feat` selon `feat` (`minor` ou `patch`). `fix` n'est pas touché. Les deux clés sont optionnelles. + +
+

JSON

{
+  "workspace": {
+    "versioning": "semver",
+    "preMajor": { "breaking": "minor", "feat": "patch" }
+  }
+}
+
+
+

TOML

[workspace]
+versioning = "semver"
+
+[workspace.preMajor]
+breaking = "minor"
+feat = "patch"
+
+
+

JSON5

{
+  workspace: {
+    versioning: "semver",
+    preMajor: { breaking: "minor", feat: "patch" },
+  },
+}
+
+
+
+ +Depuis `0.5.0` : `feat!` donne `0.6.0`, `feat` et `fix` donnent `0.5.1`. C'est l'équivalent de `bump-minor-pre-major` et `bump-patch-for-minor-pre-major` de release-please. À partir de `1.0.0` le réglage n'a plus d'effet, et `ferrflow release --force-version 1.0.0` sort de 0.x. Il ne s'applique qu'à la stratégie `semver`, et un package peut le surcharger avec son propre `preMajor`. Non défini, les règles standard s'appliquent. + ### Canaux de pré-release Le tableau `branches` associe des noms de branches (ou des motifs glob) à des canaux de pré-release. Quand FerrFlow s'exécute sur une branche correspondant à une entrée, il release sur ce canal — par exemple `1.4.0-beta.1` au lieu de `1.4.0`. C'est cette même association que l'option `--channel` de `ferrflow check` et `ferrflow release` surcharge ponctuellement. @@ -417,6 +451,7 @@ Définit un package à versionner. Vous pouvez en avoir un ou plusieurs. | `changelog` | non | `{path}/CHANGELOG.md` | Chemin vers le fichier changelog | | `sharedPaths` | non | `[]` | Chemins qui déclenchent ce package lorsqu'ils sont modifiés | | `versioning` | non | hérité du workspace | Surcharger la stratégie de versionnage pour ce package | +| `preMajor` | non | hérité du workspace | Surcharger les règles de bump avant 1.0 pour ce package | | `tagTemplate` | non | hérité du workspace | Surcharger le modèle de tag pour ce package | | `latestTag` | non | hérité du workspace | Surcharger le tag alias flottant pour ce package | | `buildMetadata` | non | hérité du workspace | Surcharger la commande de métadonnées, ou `false` pour une version nue | diff --git a/npm/types/config.d.ts b/npm/types/config.d.ts index d8b094af..4173dcc9 100644 --- a/npm/types/config.d.ts +++ b/npm/types/config.d.ts @@ -133,6 +133,32 @@ export interface FerrFlowConfiguration { * Default versioning strategy for all packages. */ versioning?: 'semver' | 'calver' | 'calver-short' | 'calver-seq' | 'calver-short-seq' | 'sequential' | 'zerover'; + /** + * While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{"breaking": "minor", "feat": "patch"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules. + */ + preMajor?: { + /** + * Bump a breaking change takes while the current major is 0. + */ + breaking?: 'minor' | 'patch'; + /** + * Bump a `feat` commit takes while the current major is 0. + */ + feat?: 'minor' | 'patch'; + }; + /** + * Snake_case form of `preMajor`, both spellings are accepted. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{"breaking": "minor", "feat": "patch"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules. + */ + pre_major?: { + /** + * Bump a breaking change takes while the current major is 0. + */ + breaking?: 'minor' | 'patch'; + /** + * Bump a `feat` commit takes while the current major is 0. + */ + feat?: 'minor' | 'patch'; + }; /** * Version format built from named variables. Variables: year, short_year, month, padded_month, day, padded_day, week, padded_week, quarter, seq, major, minor, patch. Each must be separated by a literal so the previous version can be read back. The literals cannot carry whitespace, control characters, or the characters git reserves in a ref name (~ ^ : ? * [ backslash), because the version becomes a git tag. Takes precedence over 'versioning'. */ @@ -454,6 +480,32 @@ export interface FerrFlowConfiguration { * Versioning strategy for this package. Overrides workspace default. */ versioning?: 'semver' | 'calver' | 'calver-short' | 'calver-seq' | 'calver-short-seq' | 'sequential' | 'zerover'; + /** + * Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{"breaking": "minor", "feat": "patch"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules. + */ + preMajor?: { + /** + * Bump a breaking change takes while the current major is 0. + */ + breaking?: 'minor' | 'patch'; + /** + * Bump a `feat` commit takes while the current major is 0. + */ + feat?: 'minor' | 'patch'; + }; + /** + * Snake_case form of `preMajor`, both spellings are accepted. Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{"breaking": "minor", "feat": "patch"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules. + */ + pre_major?: { + /** + * Bump a breaking change takes while the current major is 0. + */ + breaking?: 'minor' | 'patch'; + /** + * Bump a `feat` commit takes while the current major is 0. + */ + feat?: 'minor' | 'patch'; + }; /** * Version format built from named variables. Variables: year, short_year, month, padded_month, day, padded_day, week, padded_week, quarter, seq, major, minor, patch. Each must be separated by a literal so the previous version can be read back. The literals cannot carry whitespace, control characters, or the characters git reserves in a ref name (~ ^ : ? * [ backslash), because the version becomes a git tag. Overrides workspace default. Takes precedence over 'versioning'. */ diff --git a/schema/ferrflow-package.json b/schema/ferrflow-package.json index 73ef0a8e..42e427dc 100644 --- a/schema/ferrflow-package.json +++ b/schema/ferrflow-package.json @@ -125,6 +125,52 @@ "zerover" ] }, + "preMajor": { + "type": "object", + "description": "Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, + "pre_major": { + "type": "object", + "description": "Snake_case form of `preMajor`, both spellings are accepted. Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, "versionTemplate": { "type": "string", "description": "Version format built from named variables. Variables: year, short_year, month, padded_month, day, padded_day, week, padded_week, quarter, seq, major, minor, patch. Each must be separated by a literal so the previous version can be read back. The literals cannot carry whitespace, control characters, or the characters git reserves in a ref name (~ ^ : ? * [ backslash), because the version becomes a git tag. Overrides workspace default. Takes precedence over 'versioning'.", diff --git a/schema/ferrflow.json b/schema/ferrflow.json index 37a4cc94..4fd7f5f3 100644 --- a/schema/ferrflow.json +++ b/schema/ferrflow.json @@ -54,6 +54,52 @@ ], "default": "semver" }, + "preMajor": { + "type": "object", + "description": "While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, + "pre_major": { + "type": "object", + "description": "Snake_case form of `preMajor`, both spellings are accepted. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, "versionTemplate": { "type": "string", "description": "Version format built from named variables. Variables: year, short_year, month, padded_month, day, padded_day, week, padded_week, quarter, seq, major, minor, patch. Each must be separated by a literal so the previous version can be read back. The literals cannot carry whitespace, control characters, or the characters git reserves in a ref name (~ ^ : ? * [ backslash), because the version becomes a git tag. Takes precedence over 'versioning'.", @@ -630,6 +676,52 @@ "zerover" ] }, + "preMajor": { + "type": "object", + "description": "Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, + "pre_major": { + "type": "object", + "description": "Snake_case form of `preMajor`, both spellings are accepted. Per-package override for workspace.preMajor. While the current major is 0 on a `semver` project, remaps the bump: `breaking` replaces what a breaking change bumps (normally major) and `feat` replaces what a `feat` bumps (normally minor). `{\"breaking\": \"minor\", \"feat\": \"patch\"}` matches release-please's bump-minor-pre-major and bump-patch-for-minor-pre-major. No effect from 1.0.0 on, leave 0.x with `ferrflow release --force-version 1.0.0`. Omit for the standard semver rules.", + "properties": { + "breaking": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a breaking change takes while the current major is 0." + }, + "feat": { + "type": "string", + "enum": [ + "minor", + "patch" + ], + "description": "Bump a `feat` commit takes while the current major is 0." + } + }, + "additionalProperties": false + }, "versionTemplate": { "type": "string", "description": "Version format built from named variables. Variables: year, short_year, month, padded_month, day, padded_day, week, padded_week, quarter, seq, major, minor, patch. Each must be separated by a literal so the previous version can be read back. The literals cannot carry whitespace, control characters, or the characters git reserves in a ref name (~ ^ : ? * [ backslash), because the version becomes a git tag. Overrides workspace default. Takes precedence over 'versioning'.", diff --git a/src/config/format.rs b/src/config/format.rs index dedf0287..ff634cb1 100644 --- a/src/config/format.rs +++ b/src/config/format.rs @@ -43,6 +43,7 @@ pub(crate) fn snake_to_camel(s: &str) -> String { const CAMEL_CASE_KEYS: &[&str] = &[ "tag_template", + "pre_major", "versioned_files", "shared_paths", "nested_packages", diff --git a/src/config/migrate.rs b/src/config/migrate.rs index 7ba2236a..69cee346 100644 --- a/src/config/migrate.rs +++ b/src/config/migrate.rs @@ -14,6 +14,7 @@ use super::types::{ use super::workspace::WorkspaceConfig; mod changesets; +mod prune; mod release_please; mod standard_version; @@ -96,9 +97,10 @@ pub fn migrate(from: Option, dry_run: bool) -> Result<()> { } fn emit(migration: &Migration, dir: &Path, dry_run: bool) -> Result<(String, String)> { - let handler = format_handler(ConfigFileFormat::Json); - let content = handler.serialize(&migration.config)?; - let filename = handler.filename().to_string(); + let filename = format_handler(ConfigFileFormat::Json) + .filename() + .to_string(); + let content = prune::to_pretty_json(&migration.config)?; if !dry_run { std::fs::write(dir.join(&filename), &content)?; } @@ -377,9 +379,17 @@ pub(super) fn default_package_name() -> String { .ok() .and_then(|s| serde_json::from_str::(&s).ok()) .and_then(|v| v.get("name").and_then(|n| n.as_str()).map(str::to_string)) + .or_else(current_dir_name) .unwrap_or_else(|| "app".to_string()) } +fn current_dir_name() -> Option { + std::env::current_dir() + .ok()? + .file_name() + .map(|n| n.to_string_lossy().into_owned()) +} + fn convert_tag_format(tag_format: &str) -> String { tag_format.replace("${version}", "{version}") } diff --git a/src/config/migrate/prune.rs b/src/config/migrate/prune.rs new file mode 100644 index 00000000..cd20906f --- /dev/null +++ b/src/config/migrate/prune.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use serde_json::{Map, Value}; + +use crate::config::Config; +use crate::config::format::to_camel_case_keys; +use crate::config::package::PackageConfig; +use crate::config::workspace::WorkspaceConfig; + +const ALWAYS_WRITTEN_PACKAGE_KEYS: &[&str] = &["name", "path"]; + +pub(super) fn to_pretty_json(config: &Config) -> Result { + let mut root = serde_json::to_value(config)?; + if let Value::Object(map) = &mut root { + strip_defaults(map, &serde_json::to_value(Config::default())?, &[]); + if let Some(Value::Object(workspace)) = map.get_mut("workspace") { + strip_defaults( + workspace, + &serde_json::to_value(WorkspaceConfig::default())?, + &[], + ); + } + map.retain(|_, v| v.as_object().is_none_or(|o| !o.is_empty())); + if let Some(Value::Array(packages)) = map.get_mut("package") { + let defaults = serde_json::to_value(PackageConfig::default())?; + for package in packages.iter_mut().filter_map(Value::as_object_mut) { + strip_defaults(package, &defaults, ALWAYS_WRITTEN_PACKAGE_KEYS); + } + } + } + let mut out = serde_json::to_string_pretty(&to_camel_case_keys(root))?; + out.push('\n'); + Ok(out) +} + +fn strip_defaults(map: &mut Map, defaults: &Value, keep: &[&str]) { + map.retain(|key, value| keep.contains(&key.as_str()) || defaults.get(key) != Some(value)); +} diff --git a/src/config/migrate/release_please.rs b/src/config/migrate/release_please.rs index 8988aa61..a892ce9c 100644 --- a/src/config/migrate/release_please.rs +++ b/src/config/migrate/release_please.rs @@ -5,12 +5,16 @@ use std::path::PathBuf; use crate::config::Config; use crate::config::package::{FileFormat, PackageConfig, VersionedFile}; -use crate::config::types::ReleaseCommitMode; +use crate::config::types::{PreMajorBump, PreMajorRules, ReleaseCommitMode}; use crate::config::workspace::WorkspaceConfig; use crate::error_code::{self, ErrorCodeExt}; +use crate::formats::lockfiles::Manifest; use super::{Migration, MigrationReport, Source}; +mod extra_files; +use extra_files::Mapped; + pub(super) const CONFIG_FILE: &str = "release-please-config.json"; pub(super) fn detect() -> Option { @@ -38,6 +42,10 @@ struct ReleasePleaseConfig { tag_separator: Option, #[serde(default, rename = "separate-pull-requests")] separate_pull_requests: Option, + #[serde(default, rename = "bump-minor-pre-major")] + bump_minor_pre_major: Option, + #[serde(default, rename = "bump-patch-for-minor-pre-major")] + bump_patch_for_minor_pre_major: Option, #[serde(default)] plugins: Vec, } @@ -54,6 +62,12 @@ struct PackageEntry { changelog_path: Option, #[serde(default, rename = "version-file")] version_file: Option, + #[serde(default, rename = "extra-files")] + extra_files: Vec, + #[serde(default, rename = "bump-minor-pre-major")] + bump_minor_pre_major: Option, + #[serde(default, rename = "bump-patch-for-minor-pre-major")] + bump_patch_for_minor_pre_major: Option, } #[derive(Debug, Deserialize)] @@ -81,6 +95,10 @@ pub(super) fn build(raw: &str) -> Result<(Config, MigrationReport)> { report .mapped .push("release-please PR flow → releaseCommitMode: pr".to_string()); + workspace.auto_merge_releases = false; + report + .mapped + .push("release-please never merges its release PR → autoMergeReleases: false".to_string()); if rp.include_component_in_tag.unwrap_or(false) { let sep = rp.tag_separator.as_deref().unwrap_or("-"); @@ -104,24 +122,26 @@ pub(super) fn build(raw: &str) -> Result<(Config, MigrationReport)> { } let default_type = rp.release_type.as_deref(); - let mut packages = Vec::new(); - for (path, entry) in &rp.packages { - packages.push(build_package(path, entry, default_type, &mut report)); - } - - if packages.is_empty() { - packages.push(build_package( - ".", - &PackageEntry::default(), - default_type, - &mut report, - )); + let fallback = PackageEntry::default(); + let entries: Vec<(&str, &PackageEntry)> = if rp.packages.is_empty() { report.warnings.push( - "no `packages` in the config — scaffolded a single root package. Add entries if this \ + "no `packages` in the config. Scaffolded a single root package. Add entries if this \ is a monorepo." .to_string(), ); + vec![(".", &fallback)] + } else { + rp.packages.iter().map(|(p, e)| (p.as_str(), e)).collect() + }; + + let mut packages = Vec::new(); + let mut pre_major = Vec::new(); + for (path, entry) in entries { + packages.push(build_package(path, entry, default_type, &mut report)); + pre_major.push(pre_major_rules(path, entry, &rp, &mut report)); } + apply_pre_major(&mut packages, &pre_major, &mut workspace); + enable_lockfile_updates(&packages, &mut workspace, &mut report); Ok(( Config { @@ -177,17 +197,22 @@ fn build_package( )); vec![vf] } - None => { - report.warnings.push(format!( - "package {path}: release-type {} has no direct ferrflow file mapping — set \ - `versionedFiles` by hand.", - rt.unwrap_or("(none)") - )); - Vec::new() - } + None => Vec::new(), } }; + let mut versioned_files = versioned_files; + for extra in &entry.extra_files { + add_extra_file(path, extra, &mut versioned_files, report); + } + if versioned_files.is_empty() && entry.version_file.is_none() { + report.warnings.push(format!( + "package {path}: release-type {} has no direct ferrflow file mapping — set \ + `versionedFiles` by hand.", + rt.unwrap_or("(none)") + )); + } + PackageConfig { name, path: path.to_string(), @@ -202,6 +227,112 @@ fn build_package( } } +fn add_extra_file( + pkg_path: &str, + extra: &serde_json::Value, + versioned_files: &mut Vec, + report: &mut MigrationReport, +) { + let (vf, generic) = match extra_files::map(pkg_path, extra) { + Mapped::Generic(vf) => (vf, true), + Mapped::Direct(vf) => (vf, false), + Mapped::Manual(msg) => { + report.warnings.push(msg); + return; + } + }; + if versioned_files.contains(&vf) { + report.mapped.push(format!( + "package {pkg_path}: extra-files {} already covered", + vf.path + )); + return; + } + if generic { + report.mapped.push(format!( + "package {pkg_path}: extra-files {} (generic) → txt selector on `x-release-please-version`", + vf.path + )); + report.warnings.push(format!( + "package {pkg_path}: {} is matched on single lines carrying `x-release-please-version`. \ + The `x-release-please-start-version` ... `x-release-please-end` block form is not \ + supported, review manually if the file uses it.", + vf.path + )); + } else { + report.mapped.push(format!( + "package {pkg_path}: extra-files {} → {:?}", + vf.path, vf.format + )); + } + versioned_files.push(vf); +} + +fn pre_major_rules( + path: &str, + entry: &PackageEntry, + rp: &ReleasePleaseConfig, + report: &mut MigrationReport, +) -> Option { + let minor = entry + .bump_minor_pre_major + .or(rp.bump_minor_pre_major) + .unwrap_or(false); + let patch = entry + .bump_patch_for_minor_pre_major + .or(rp.bump_patch_for_minor_pre_major) + .unwrap_or(false); + if minor { + report.mapped.push(format!( + "package {path}: bump-minor-pre-major → preMajor.breaking: minor" + )); + } + if patch { + report.mapped.push(format!( + "package {path}: bump-patch-for-minor-pre-major → preMajor.feat: patch" + )); + } + let rules = PreMajorRules { + breaking: minor.then_some(PreMajorBump::Minor), + feat: patch.then_some(PreMajorBump::Patch), + }; + (rules != PreMajorRules::default()).then_some(rules) +} + +fn apply_pre_major( + packages: &mut [PackageConfig], + rules: &[Option], + workspace: &mut WorkspaceConfig, +) { + if let Some(first) = rules.first().copied().flatten() + && rules.iter().all(|r| *r == Some(first)) + { + workspace.pre_major = Some(first); + return; + } + for (package, rule) in packages.iter_mut().zip(rules) { + package.pre_major = *rule; + } +} + +fn enable_lockfile_updates( + packages: &[PackageConfig], + workspace: &mut WorkspaceConfig, + report: &mut MigrationReport, +) { + let has_lockfile = packages + .iter() + .flat_map(|p| &p.versioned_files) + .filter_map(|vf| vf.path.rsplit('/').next()) + .any(|name| Manifest::from_manifest_filename(name).is_some()); + if has_lockfile { + workspace.update_lockfiles = true; + report + .mapped + .push("versioned manifest with a lockfile → updateLockfiles: true".to_string()); + } +} + fn name_from_path(path: &str) -> String { if path == "." || path.is_empty() { super::default_package_name() @@ -298,6 +429,7 @@ fn apply_plugin(plugin: &Plugin, workspace: &mut WorkspaceConfig, report: &mut M #[cfg(test)] mod tests { use super::*; + use crate::config::types::{PreMajorBump, PreMajorRules}; fn build_ok(raw: &str) -> (Config, MigrationReport) { build(raw).expect("valid release-please config") @@ -388,6 +520,20 @@ mod tests { assert!(report.warnings.iter().any(|w| w.contains("go"))); } + #[test] + fn unmappable_release_type_does_not_warn_when_extra_files_fill_versioned_files() { + let (cfg, report) = build_ok( + r#"{"packages": {".": {"release-type": "go", "extra-files": ["README.md"]}}}"#, + ); + assert_eq!(cfg.packages[0].versioned_files.len(), 1); + assert!( + !report + .warnings + .iter() + .any(|w| w.contains("no direct ferrflow file mapping")) + ); + } + #[test] fn separate_pull_requests_warns() { let (_, report) = build_ok( @@ -504,4 +650,196 @@ mod tests { ); assert!(report.ignored.iter().any(|i| i.contains("node-workspace"))); } + + #[test] + fn extra_files_map_alongside_the_release_type_file() { + let (cfg, report) = build_ok( + r#"{"packages": {".": {"release-type": "rust", "extra-files": [ + {"type": "generic", "path": "README.md"}, + {"type": "json", "path": "manifest.json", "jsonpath": "$.version"} + ]}}}"#, + ); + let files = &cfg.packages[0].versioned_files; + assert_eq!(files.len(), 3); + assert_eq!(files[1].path, "README.md"); + assert!(matches!(files[1].format, FileFormat::Txt)); + assert!(files[1].selector.is_some()); + assert_eq!(files[2].path, "manifest.json"); + assert!(matches!(files[2].format, FileFormat::Json)); + assert!(report.mapped.iter().any(|m| m.contains("README.md"))); + } + + #[test] + fn generic_extra_file_warns_about_the_block_form() { + let (_, report) = build_ok( + r#"{"packages": {".": {"release-type": "simple", "extra-files": ["README.md"]}}}"#, + ); + assert!( + report + .warnings + .iter() + .any(|w| w.contains("x-release-please-start-version") && w.contains("README.md")) + ); + } + + #[test] + fn extra_file_duplicating_the_release_type_file_is_not_added_twice() { + let (cfg, _) = build_ok( + r#"{"packages": {".": {"release-type": "rust", "extra-files": [ + {"type": "toml", "path": "Cargo.toml", "jsonpath": "$.package.version"} + ]}}}"#, + ); + assert_eq!(cfg.packages[0].versioned_files.len(), 1); + } + + #[test] + fn unsupported_extra_files_are_reported_with_the_exact_entry() { + let (cfg, report) = build_ok( + r#"{"packages": {"svc": {"release-type": "simple", "extra-files": [ + {"type": "yaml", "path": "chart.yaml", "jsonpath": "$.version"} + ]}}}"#, + ); + assert_eq!(cfg.packages[0].versioned_files.len(), 1); + assert!(report.warnings.iter().any(|w| { + w.contains(r#"{"type":"yaml","path":"chart.yaml","jsonpath":"$.version"}"#) + })); + } + + fn rules(breaking: Option, feat: Option) -> Option { + Some(PreMajorRules { breaking, feat }) + } + + #[test] + fn bump_minor_pre_major_alone_sets_workspace_breaking_minor() { + let (cfg, report) = build_ok( + r#"{"packages": {".": {"release-type": "node", "bump-minor-pre-major": true}}}"#, + ); + assert_eq!( + cfg.workspace.pre_major, + rules(Some(PreMajorBump::Minor), None) + ); + assert_eq!(cfg.workspace.versioning, None); + assert_eq!(cfg.packages[0].pre_major, None); + assert!( + report + .mapped + .iter() + .any(|m| m.contains("preMajor.breaking: minor")) + ); + assert!(report.warnings.iter().all(|w| !w.contains("zerover"))); + } + + #[test] + fn bump_patch_for_minor_pre_major_alone_sets_workspace_feat_patch() { + let (cfg, report) = build_ok( + r#"{"packages": {".": {"release-type": "node", "bump-patch-for-minor-pre-major": true}}}"#, + ); + assert_eq!( + cfg.workspace.pre_major, + rules(None, Some(PreMajorBump::Patch)) + ); + assert!( + report + .mapped + .iter() + .any(|m| m.contains("preMajor.feat: patch")) + ); + assert!(report.warnings.iter().all(|w| !w.contains("#1376"))); + } + + #[test] + fn both_flags_set_both_rules() { + let (cfg, _) = build_ok( + r#"{"packages": {".": {"release-type": "node", "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true}}}"#, + ); + assert_eq!( + cfg.workspace.pre_major, + rules(Some(PreMajorBump::Minor), Some(PreMajorBump::Patch)) + ); + } + + #[test] + fn no_flags_leave_pre_major_unset() { + let (cfg, _) = build_ok(r#"{"packages": {".": {"release-type": "node"}}}"#); + assert_eq!(cfg.workspace.pre_major, None); + assert_eq!(cfg.packages[0].pre_major, None); + } + + #[test] + fn flags_on_some_packages_are_placed_per_package() { + let (cfg, _) = build_ok( + r#"{"packages": { + "a": {"release-type": "node", "bump-minor-pre-major": true}, + "b": {"release-type": "node", "bump-patch-for-minor-pre-major": true}, + "c": {"release-type": "node"} + }}"#, + ); + assert_eq!(cfg.workspace.pre_major, None); + assert_eq!( + cfg.packages[0].pre_major, + rules(Some(PreMajorBump::Minor), None) + ); + assert_eq!( + cfg.packages[1].pre_major, + rules(None, Some(PreMajorBump::Patch)) + ); + assert_eq!(cfg.packages[2].pre_major, None); + } + + #[test] + fn top_level_flags_apply_to_every_package() { + let (cfg, _) = build_ok( + r#"{"bump-minor-pre-major": true, "packages": {"a": {"release-type": "node"}, "b": {"release-type": "node"}}}"#, + ); + assert_eq!( + cfg.workspace.pre_major, + rules(Some(PreMajorBump::Minor), None) + ); + } + + #[test] + fn package_flag_false_overrides_the_top_level_flag() { + let (cfg, _) = build_ok( + r#"{"bump-minor-pre-major": true, "packages": {"a": {"release-type": "node", "bump-minor-pre-major": false}}}"#, + ); + assert_eq!(cfg.workspace.pre_major, None); + assert_eq!(cfg.packages[0].pre_major, None); + } + + #[test] + fn release_please_disables_auto_merge() { + let (cfg, report) = build_ok(r#"{"packages": {".": {"release-type": "node"}}}"#); + assert!(!cfg.workspace.auto_merge_releases); + assert!( + report + .mapped + .iter() + .any(|m| m.contains("autoMergeReleases")) + ); + } + + #[test] + fn manifests_with_a_lockfile_enable_lockfile_updates() { + let (cfg, _) = build_ok(r#"{"packages": {".": {"release-type": "rust"}}}"#); + assert!(cfg.workspace.update_lockfiles); + } + + #[test] + fn lockfile_updates_stay_off_without_a_lockfile_manifest() { + let (cfg, _) = build_ok( + r#"{"packages": {".": {"release-type": "simple", "extra-files": ["README.md"]}}}"#, + ); + assert!(!cfg.workspace.update_lockfiles); + } + + #[test] + fn package_name_wins_over_component_and_path() { + let (cfg, _) = build_ok( + r#"{"packages": {"x": {"release-type": "node", "package-name": "pn", "component": "c"}}}"#, + ); + assert_eq!(cfg.packages[0].name, "pn"); + let (cfg, _) = + build_ok(r#"{"packages": {"x": {"release-type": "node", "component": "c"}}}"#); + assert_eq!(cfg.packages[0].name, "c"); + } } diff --git a/src/config/migrate/release_please/extra_files.rs b/src/config/migrate/release_please/extra_files.rs new file mode 100644 index 00000000..1c958058 --- /dev/null +++ b/src/config/migrate/release_please/extra_files.rs @@ -0,0 +1,240 @@ +use serde_json::Value; + +use crate::config::package::{FileFormat, VersionedFile}; + +use super::join_path; + +const GENERIC_MARKER_SELECTOR: &str = + r"v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z][0-9A-Za-z.+-]*)?)[^\n]*x-release-please-version"; + +const CARGO_VERSION_PATHS: &[&str] = &["$.package.version", "$.workspace.package.version"]; + +#[derive(Debug, PartialEq)] +pub(super) enum Mapped { + Generic(VersionedFile), + Direct(VersionedFile), + Manual(String), +} + +pub(super) fn map(pkg_path: &str, entry: &Value) -> Mapped { + let (kind, path, jsonpath) = match entry { + Value::String(path) => ("generic", path.as_str(), None), + Value::Object(map) => { + let Some(path) = map.get("path").and_then(Value::as_str) else { + return Mapped::Manual(manual(pkg_path, entry, "entry has no `path`")); + }; + let kind = map.get("type").and_then(Value::as_str).unwrap_or("generic"); + (kind, path, map.get("jsonpath").and_then(Value::as_str)) + } + _ => return Mapped::Manual(manual(pkg_path, entry, "entry is not a string or object")), + }; + let resolved = resolve(pkg_path, path); + + match (kind, jsonpath) { + ("generic", _) => Mapped::Generic(VersionedFile { + path: resolved, + format: FileFormat::Txt, + selector: Some(GENERIC_MARKER_SELECTOR.to_string()), + }), + ("toml", Some(jp)) if is_cargo_manifest(&resolved) && CARGO_VERSION_PATHS.contains(&jp) => { + Mapped::Direct(VersionedFile { + path: resolved, + format: FileFormat::Toml, + selector: None, + }) + } + ("json", Some("$.version")) => Mapped::Direct(VersionedFile { + path: resolved, + format: FileFormat::Json, + selector: None, + }), + _ => Mapped::Manual(manual( + pkg_path, + entry, + "no ferrflow versionedFiles equivalent", + )), + } +} + +fn resolve(pkg_path: &str, path: &str) -> String { + match path.strip_prefix('/') { + Some(from_root) => from_root.to_string(), + None => join_path(pkg_path, path), + } +} + +fn is_cargo_manifest(path: &str) -> bool { + path.rsplit('/').next() == Some("Cargo.toml") +} + +fn manual(pkg_path: &str, entry: &Value, reason: &str) -> String { + format!( + "package {pkg_path}: extra-files entry {entry} ({reason}), add it to `versionedFiles` by hand." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::formats::VersionFile; + use crate::formats::txt::TxtVersionFile; + use serde_json::json; + use std::io::Write; + use tempfile::NamedTempFile; + + const LUA_LINE: &str = "return \"0.5.0\" -- x-release-please-version\n"; + const README_LINE: &str = + " version='v0.5.0' -- use the latest tagged releases x-release-please-version\n"; + + fn generic_selector() -> String { + match map(".", &json!({"type": "generic", "path": "f"})) { + Mapped::Generic(vf) => vf.selector.expect("selector"), + other => panic!("expected generic, got {other:?}"), + } + } + + fn file_with(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f + } + + #[test] + fn selector_reads_a_quoted_version_on_the_marker_line() { + let f = file_with(LUA_LINE); + let v = TxtVersionFile + .read_version_with_selector(f.path(), Some(&generic_selector())) + .unwrap(); + assert_eq!(v, "0.5.0"); + } + + #[test] + fn selector_leaves_a_leading_v_outside_the_capture() { + let f = file_with(README_LINE); + let v = TxtVersionFile + .read_version_with_selector(f.path(), Some(&generic_selector())) + .unwrap(); + assert_eq!(v, "0.5.0"); + } + + #[test] + fn selector_write_replaces_only_the_version() { + let f = file_with(README_LINE); + TxtVersionFile + .write_version_with_selector(f.path(), "0.6.0", Some(&generic_selector())) + .unwrap(); + assert_eq!( + std::fs::read_to_string(f.path()).unwrap(), + " version='v0.6.0' -- use the latest tagged releases x-release-please-version\n" + ); + } + + #[test] + fn selector_ignores_versions_on_lines_without_the_marker() { + let f = file_with(&format!("local lua = \"5.1.0\"\n{LUA_LINE}")); + TxtVersionFile + .write_version_with_selector(f.path(), "0.6.0", Some(&generic_selector())) + .unwrap(); + assert_eq!( + std::fs::read_to_string(f.path()).unwrap(), + "local lua = \"5.1.0\"\nreturn \"0.6.0\" -- x-release-please-version\n" + ); + } + + #[test] + fn selector_keeps_a_prerelease_suffix_in_the_capture() { + let f = file_with("v = \"1.0.0-rc.1\" # x-release-please-version\n"); + let v = TxtVersionFile + .read_version_with_selector(f.path(), Some(&generic_selector())) + .unwrap(); + assert_eq!(v, "1.0.0-rc.1"); + } + + #[test] + fn selector_does_not_match_a_file_without_the_marker() { + let f = file_with("return \"0.5.0\"\n"); + assert!( + TxtVersionFile + .read_version_with_selector(f.path(), Some(&generic_selector())) + .is_err() + ); + } + + #[test] + fn plain_string_entry_is_a_generic_file() { + assert!(matches!( + map("pkg", &json!("docs/VERSION.md")), + Mapped::Generic(vf) if vf.path == "pkg/docs/VERSION.md" + )); + } + + #[test] + fn leading_slash_anchors_the_path_at_the_repo_root() { + assert!(matches!( + map("pkg", &json!({"type": "generic", "path": "/README.md"})), + Mapped::Generic(vf) if vf.path == "README.md" + )); + } + + #[test] + fn toml_workspace_package_version_on_cargo_toml_maps_to_toml() { + let mapped = map( + ".", + &json!({"type": "toml", "path": "Cargo.toml", "jsonpath": "$.workspace.package.version"}), + ); + assert_eq!( + mapped, + Mapped::Direct(VersionedFile { + path: "Cargo.toml".to_string(), + format: FileFormat::Toml, + selector: None, + }) + ); + } + + #[test] + fn toml_package_version_on_a_nested_cargo_toml_maps_to_toml() { + assert!(matches!( + map( + "crates/core", + &json!({"type": "toml", "path": "Cargo.toml", "jsonpath": "$.package.version"}) + ), + Mapped::Direct(vf) if vf.path == "crates/core/Cargo.toml" + )); + } + + #[test] + fn json_version_maps_to_json() { + assert!(matches!( + map(".", &json!({"type": "json", "path": "manifest.json", "jsonpath": "$.version"})), + Mapped::Direct(vf) if matches!(vf.format, FileFormat::Json) && vf.path == "manifest.json" + )); + } + + #[test] + fn other_jsonpaths_need_manual_review_with_the_exact_entry() { + for entry in [ + json!({"type": "json", "path": "package.json", "jsonpath": "$.metadata.version"}), + json!({"type": "toml", "path": "pyproject.toml", "jsonpath": "$.project.version"}), + json!({"type": "toml", "path": "Cargo.toml", "jsonpath": "$.dependencies.foo.version"}), + json!({"type": "toml", "path": "Cargo.toml"}), + json!({"type": "yaml", "path": "chart.yaml", "jsonpath": "$.version"}), + json!({"type": "xml", "path": "pom.xml", "xpath": "//project/version"}), + json!({"type": "pom", "path": "pom.xml"}), + ] { + match map(".", &entry) { + Mapped::Manual(msg) => assert!(msg.contains(&entry.to_string()), "{msg}"), + other => panic!("{entry} should need review, got {other:?}"), + } + } + } + + #[test] + fn malformed_entries_need_manual_review() { + assert!(matches!(map(".", &json!(42)), Mapped::Manual(_))); + assert!(matches!( + map(".", &json!({"type": "generic"})), + Mapped::Manual(_) + )); + } +} diff --git a/src/config/migrate/tests.rs b/src/config/migrate/tests.rs index ae4cadaa..79475fca 100644 --- a/src/config/migrate/tests.rs +++ b/src/config/migrate/tests.rs @@ -482,3 +482,190 @@ mod end_to_end { assert!(!dir.path().join("ferrflow.json").exists()); } } + +const AVANTE_CONFIG: &str = r#"{"packages":{".":{"prerelease":false,"bump-patch-for-minor-pre-major":true,"bump-minor-pre-major":true,"release-type":"go","include-component-in-tag":false,"extra-files":[{"type":"generic","path":"lua/avante/version.lua"},{"type":"generic","path":"README.md"},{"type":"toml","path":"Cargo.toml","jsonpath":"$.workspace.package.version"}]}}}"#; + +mod release_please_avante { + use super::super::{Migration, Source, emit, release_please}; + use crate::formats::VersionFile; + use crate::formats::txt::TxtVersionFile; + use std::path::PathBuf; + + use super::AVANTE_CONFIG as AVANTE; + + fn migrate_avante(dir: &std::path::Path) -> (serde_json::Value, super::MigrationReport) { + let _guard = crate::test_utils::CWD_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let saved = std::env::current_dir().unwrap(); + std::env::set_current_dir(dir).unwrap(); + let built = release_please::build(AVANTE); + std::env::set_current_dir(saved).unwrap(); + let (config, report) = built.unwrap(); + let migration = Migration::new( + Source::ReleasePlease, + PathBuf::from("release-please-config.json"), + config, + report, + ); + let (_, content) = emit(&migration, dir, true).unwrap(); + (serde_json::from_str(&content).unwrap(), migration.report) + } + + #[test] + fn avante_config_produces_the_expected_ferrflow_json() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("avante.nvim"); + std::fs::create_dir(&dir).unwrap(); + let (json, report) = migrate_avante(&dir); + + assert_eq!( + json["workspace"]["preMajor"], + serde_json::json!({"breaking": "minor", "feat": "patch"}) + ); + assert!(json["workspace"].get("versioning").is_none()); + assert!(json["package"][0].get("preMajor").is_none()); + assert_eq!(json["workspace"]["releaseCommitMode"], "pr"); + assert_eq!(json["workspace"]["autoMergeReleases"], false); + assert_eq!(json["workspace"]["update_lockfiles"], true); + assert!(json["workspace"].get("commitFormats").is_none()); + assert_eq!(json["package"][0]["name"], "avante.nvim"); + assert_eq!(json["package"][0]["path"], "."); + assert!(json["package"][0].get("hooks").is_none()); + + let files = json["package"][0]["versionedFiles"].as_array().unwrap(); + assert_eq!(files.len(), 3); + assert_eq!(files[0]["path"], "lua/avante/version.lua"); + assert_eq!(files[0]["format"], "txt"); + assert_eq!(files[1]["path"], "README.md"); + assert_eq!(files[2]["path"], "Cargo.toml"); + assert_eq!(files[2]["format"], "toml"); + assert!(files[2].get("selector").is_none()); + + assert!( + report + .mapped + .iter() + .any(|m| m.contains("preMajor.breaking")) + ); + assert!(report.mapped.iter().any(|m| m.contains("preMajor.feat"))); + assert!(report.warnings.iter().all(|w| !w.contains("zerover"))); + } + + #[test] + fn avante_generated_selector_reads_and_rewrites_both_marker_lines() { + let root = tempfile::tempdir().unwrap(); + let (json, _) = migrate_avante(root.path()); + let selector = json["package"][0]["versionedFiles"][0]["selector"] + .as_str() + .unwrap() + .to_string(); + + let lua = root.path().join("version.lua"); + std::fs::write(&lua, "return \"0.5.0\" -- x-release-please-version\n").unwrap(); + let readme = root.path().join("README.md"); + std::fs::write( + &readme, + "```lua\n {\n version='v0.5.0' -- use the latest tagged releases x-release-please-version\n }\n```\n", + ) + .unwrap(); + + for file in [&lua, &readme] { + assert_eq!( + TxtVersionFile + .read_version_with_selector(file, Some(&selector)) + .unwrap(), + "0.5.0" + ); + TxtVersionFile + .write_version_with_selector(file, "0.6.0", Some(&selector)) + .unwrap(); + } + assert_eq!( + std::fs::read_to_string(&lua).unwrap(), + "return \"0.6.0\" -- x-release-please-version\n" + ); + assert_eq!( + std::fs::read_to_string(&readme).unwrap(), + "```lua\n {\n version='v0.6.0' -- use the latest tagged releases x-release-please-version\n }\n```\n" + ); + } +} + +mod round_trip { + use super::super::{Migration, Source, changesets, emit, release_please, standard_version}; + use super::{AVANTE_CONFIG, build}; + use crate::config::Config; + use crate::config::format::{ConfigFileFormat, format_handler}; + use std::path::PathBuf; + + fn assert_round_trips(label: &str, config: Config, report: super::MigrationReport) { + let migration = Migration::new(Source::ReleasePlease, PathBuf::from("src"), config, report); + let dir = tempfile::tempdir().unwrap(); + let (_, content) = emit(&migration, dir.path(), true).unwrap(); + let reloaded = format_handler(ConfigFileFormat::Json) + .parse(&content) + .unwrap_or_else(|e| panic!("{label}: emitted config does not load: {e:#}")); + assert_eq!( + serde_json::to_value(&reloaded).unwrap(), + serde_json::to_value(&migration.config).unwrap(), + "{label}: reloaded config differs from the migrated one\n{content}" + ); + } + + #[test] + fn every_migrator_output_reloads_to_the_same_config() { + let changesets_root = tempfile::tempdir().unwrap(); + + let mut cases: Vec<(&str, Config, super::MigrationReport)> = Vec::new(); + for (label, raw) in [ + ("release-please avante", AVANTE_CONFIG), + ( + "release-please plain", + r#"{"packages": {".": {"release-type": "node"}}}"#, + ), + ( + "release-please monorepo", + r#"{"include-component-in-tag": true, "bump-minor-pre-major": true, + "plugins": [{"type": "linked-versions", "groupName": "g", "components": ["a", "b"]}], + "packages": {"a": {"release-type": "rust"}, "b": {"release-type": "node", "changelog-path": "X.md"}}}"#, + ), + ("release-please empty", r#"{}"#), + ] { + let (c, r) = release_please::build(raw).unwrap(); + cases.push((label, c, r)); + } + for (label, raw) in [ + ("semantic-release minimal", "{}"), + ( + "semantic-release full", + r#"{"tagFormat": "v${version}", "branches": ["main", {"name": "beta", "prerelease": true}], + "plugins": [["@semantic-release/exec", {"prepareCmd": "x", "successCmd": "y"}], "@semantic-release/gitlab"]}"#, + ), + ] { + let (c, r) = build(raw); + cases.push((label, c, r)); + } + for (label, raw) in [ + ("changesets minimal", "{}"), + ("changesets base branch", r#"{"baseBranch": "develop"}"#), + ] { + let (c, r) = changesets::build(raw, changesets_root.path()).unwrap(); + cases.push((label, c, r)); + } + for (label, raw) in [ + ("standard-version minimal", "{}"), + ( + "standard-version bump files", + r#"{"tagPrefix": "release-", "bumpFiles": [{"filename": "package.json", "type": "json"}, {"filename": "VERSION", "type": "plain-text"}]}"#, + ), + ] { + let (c, r) = standard_version::build(raw).unwrap(); + cases.push((label, c, r)); + } + + for (label, config, report) in cases { + assert_round_trips(label, config, report); + } + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 9b77d717..3dc40200 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -39,9 +39,9 @@ pub use package::{ #[allow(unused_imports)] pub use types::{ BranchChannelConfig, BuildMetadata, ChannelValue, DockerSign, ForgeKind, GENERIC_TOKEN_ENV_VAR, - HooksConfig, NestedPackages, OnFailure, OrphanedTagStrategy, PrereleaseIdentifier, - PublisherConfig, RegistryConfig, ReleaseCommitBody, ReleaseCommitMode, ReleaseCommitScope, - VersionSourcePolicy, all_token_env_vars, + HooksConfig, NestedPackages, OnFailure, OrphanedTagStrategy, PreMajorBump, PreMajorRules, + PrereleaseIdentifier, PublisherConfig, RegistryConfig, ReleaseCommitBody, ReleaseCommitMode, + ReleaseCommitScope, VersionSourcePolicy, all_token_env_vars, }; #[allow(unused_imports)] pub use workspace::{ diff --git a/src/config/package.rs b/src/config/package.rs index 04986ff7..01563ef6 100644 --- a/src/config/package.rs +++ b/src/config/package.rs @@ -2,7 +2,9 @@ use serde::{Deserialize, Serialize}; use crate::conventional_commits::BumpType; -use super::types::{BuildMetadata, HooksConfig, PublisherConfig, VersionSourcePolicy}; +use super::types::{ + BuildMetadata, HooksConfig, PreMajorRules, PublisherConfig, VersionSourcePolicy, +}; use super::workspace::WorkspaceConfig; #[derive(Debug, Deserialize, Serialize, Clone, Default)] @@ -18,6 +20,8 @@ pub struct PackageConfig { #[serde(default, alias = "dependsOn")] pub depends_on: Vec, pub versioning: Option, + #[serde(default, alias = "preMajor")] + pub pre_major: Option, #[serde(alias = "tagTemplate")] pub tag_template: Option, #[serde(alias = "versionTemplate")] @@ -202,6 +206,28 @@ impl PackageConfig { .unwrap_or_default() } + pub fn effective_pre_major(&self, workspace: &WorkspaceConfig) -> Option { + self.pre_major.or(workspace.pre_major) + } + + pub fn next_version( + &self, + workspace: &WorkspaceConfig, + strategy: VersioningStrategy, + current: &str, + bump: BumpType, + ) -> anyhow::Result<(BumpType, String)> { + let bump = crate::versioning::apply_pre_major( + self.effective_pre_major(workspace), + strategy, + current, + bump, + ); + let template = self.effective_version_template(workspace); + let version = crate::versioning::compute_next_version(current, bump, strategy, template)?; + Ok((bump, version)) + } + pub fn effective_version_template<'a>( &'a self, workspace: &'a WorkspaceConfig, @@ -280,7 +306,7 @@ impl PackageConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct VersionedFile { pub path: String, pub format: FileFormat, diff --git a/src/config/tests.rs b/src/config/tests.rs index cda4f60d..4a5fbe07 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -1800,3 +1800,89 @@ fn exclusive_mode_only_excludes_packages_inside_the_outer_path() { assert!(nested_of(&config, "web").is_empty()); assert!(nested_of(&config, "plugin").is_empty()); } + +fn pre_major(breaking: PreMajorBump, feat: PreMajorBump) -> PreMajorRules { + PreMajorRules { + breaking: Some(breaking), + feat: Some(feat), + } +} + +#[test] +fn parse_pre_major_json_camel_and_snake_case() { + for key in ["preMajor", "pre_major"] { + let json = format!( + r#"{{"workspace": {{"{key}": {{"breaking": "minor", "feat": "patch"}}}}, + "package": [{{"name": "a", "path": ".", "{key}": {{"feat": "patch"}}}}]}}"# + ); + let config: Config = serde_json::from_str(&json).unwrap(); + assert_eq!( + config.workspace.pre_major, + Some(pre_major(PreMajorBump::Minor, PreMajorBump::Patch)) + ); + assert_eq!( + config.packages[0].pre_major, + Some(PreMajorRules { + breaking: None, + feat: Some(PreMajorBump::Patch) + }) + ); + } +} + +#[test] +fn parse_pre_major_toml_and_json5() { + let toml = r#" +[workspace.preMajor] +breaking = "minor" +feat = "patch" +"#; + let config: Config = toml_edit::de::from_str(toml).unwrap(); + assert_eq!( + config.workspace.pre_major, + Some(pre_major(PreMajorBump::Minor, PreMajorBump::Patch)) + ); + + let json5 = r#"{ workspace: { preMajor: { breaking: "patch" } } }"#; + let config: Config = json5::from_str(json5).unwrap(); + assert_eq!( + config.workspace.pre_major, + Some(PreMajorRules { + breaking: Some(PreMajorBump::Patch), + feat: None + }) + ); +} + +#[test] +fn pre_major_is_unset_by_default() { + let config: Config = + serde_json::from_str(r#"{"package": [{"name": "a", "path": "."}]}"#).unwrap(); + assert_eq!(config.workspace.pre_major, None); + assert_eq!(config.packages[0].pre_major, None); +} + +#[test] +fn pre_major_rejects_a_level_other_than_minor_or_patch() { + let json = r#"{"workspace": {"preMajor": {"breaking": "major"}}}"#; + assert!(serde_json::from_str::(json).is_err()); +} + +#[test] +fn effective_pre_major_package_overrides_workspace() { + let ws = WorkspaceConfig { + pre_major: Some(pre_major(PreMajorBump::Minor, PreMajorBump::Patch)), + ..WorkspaceConfig::default() + }; + let inheriting = PackageConfig { + name: "a".into(), + path: ".".into(), + ..Default::default() + }; + let overriding = PackageConfig { + pre_major: Some(pre_major(PreMajorBump::Patch, PreMajorBump::Minor)), + ..inheriting.clone() + }; + assert_eq!(inheriting.effective_pre_major(&ws), ws.pre_major); + assert_eq!(overriding.effective_pre_major(&ws), overriding.pre_major); +} diff --git a/src/config/types.rs b/src/config/types.rs index efb17457..3bdaeccf 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -86,6 +86,21 @@ pub enum VersionSourcePolicy { File, } +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum PreMajorBump { + Minor, + Patch, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default)] +pub struct PreMajorRules { + #[serde(default)] + pub breaking: Option, + #[serde(default)] + pub feat: Option, +} + #[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Default)] #[serde(rename_all = "camelCase")] pub enum OrphanedTagStrategy { diff --git a/src/config/unknown_keys.rs b/src/config/unknown_keys.rs index 8484a426..439cdbb8 100644 --- a/src/config/unknown_keys.rs +++ b/src/config/unknown_keys.rs @@ -276,6 +276,28 @@ mod tests { ); } + #[test] + fn pre_major_is_not_reported_in_either_spelling_but_its_typos_are() { + assert!( + reported(json!({ + "workspace": { + "preMajor": { "breaking": "minor", "feat": "patch" } + }, + "package": [{ + "name": "core", + "path": ".", + "pre_major": { "feat": "patch" } + }] + })) + .is_empty() + ); + let found = reported(json!({ + "workspace": { "preMajor": { "breakin": "minor" } } + })); + assert_eq!(found.len(), 1); + assert_eq!(found[0].0, "workspace.preMajor.breakin"); + } + #[test] fn included_fragments_are_checked_against_the_package_schema() { let found = find_unknown_keys::( diff --git a/src/config/workspace.rs b/src/config/workspace.rs index a135fef1..1b1ceaf2 100644 --- a/src/config/workspace.rs +++ b/src/config/workspace.rs @@ -4,7 +4,8 @@ use super::commit_formats::CommitFormats; use super::package::{FloatingTagLevel, VersioningStrategy}; use super::types::{ BranchChannelConfig, ForgeKind, HooksConfig, NestedPackages, OrphanedTagStrategy, - RegistryConfig, ReleaseCommitBody, ReleaseCommitMode, ReleaseCommitScope, VersionSourcePolicy, + PreMajorRules, RegistryConfig, ReleaseCommitBody, ReleaseCommitMode, ReleaseCommitScope, + VersionSourcePolicy, }; use std::collections::BTreeMap; @@ -16,6 +17,8 @@ pub struct WorkspaceConfig { pub branch: String, #[serde(default)] pub versioning: Option, + #[serde(default, alias = "preMajor")] + pub pre_major: Option, #[serde(alias = "tagTemplate")] pub tag_template: Option, #[serde(alias = "versionTemplate")] @@ -89,6 +92,7 @@ impl Default for WorkspaceConfig { remote: default_remote(), branch: default_branch(), versioning: Default::default(), + pre_major: None, tag_template: Default::default(), version_template: Default::default(), build_metadata: Default::default(), diff --git a/src/monorepo/impact.rs b/src/monorepo/impact.rs index 4d3cf735..d976e512 100644 --- a/src/monorepo/impact.rs +++ b/src/monorepo/impact.rs @@ -6,7 +6,6 @@ use std::path::Path; use crate::config::{Config, PackageConfig}; use crate::conventional_commits::BumpType; use crate::formats::read_version; -use crate::versioning::compute_next_version; use super::run::graph::cascade_round; use super::util::tags_for_package; @@ -64,8 +63,9 @@ impl Versions<'_> { let strategy = pkg.effective_versioning(&self.config.workspace, || { tags_for_package(self.all_tags, &prefix) }); - let template = pkg.effective_version_template(&self.config.workspace); - compute_next_version(¤t, bump, strategy, template).ok() + pkg.next_version(&self.config.workspace, strategy, ¤t, bump) + .ok() + .map(|(_, version)| version) } } @@ -275,6 +275,43 @@ mod tests { ] }"#; + #[test] + fn a_breaking_seed_keeps_a_pre_major_dependent_below_one_zero() { + let dir = tempfile::tempdir().unwrap(); + for (name, version) in [("engine", "1.2.0"), ("viewer", "0.3.0")] { + std::fs::create_dir_all(dir.path().join(name)).unwrap(); + std::fs::write( + dir.path().join(name).join("package.json"), + format!(r#"{{ "name": "{name}", "version": "{version}" }}"#), + ) + .unwrap(); + } + let config: Config = serde_json::from_str( + r#"{ + "workspace": { "preMajor": { "breaking": "minor" } }, + "package": [ + { "name": "engine", "path": "engine", + "versionedFiles": [{ "path": "engine/package.json", "format": "json" }] }, + { "name": "viewer", "path": "viewer", "dependsOn": ["engine"], + "versionedFiles": [{ "path": "viewer/package.json", "format": "json" }] } + ] + }"#, + ) + .expect("valid config"); + + let report = + build_report(&config, dir.path(), &[], "engine", BumpType::Major).expect("impact"); + + assert_eq!( + entry(&report, "engine").next_version.as_deref(), + Some("2.0.0") + ); + assert_eq!( + entry(&report, "viewer").next_version.as_deref(), + Some("0.4.0") + ); + } + #[test] fn a_transitive_dependent_is_reported_further_away_than_a_direct_one() { let report = report(CHAIN, "shared", BumpType::Minor); diff --git a/src/monorepo/run/cascade.rs b/src/monorepo/run/cascade.rs index bb388fb9..35b35c35 100644 --- a/src/monorepo/run/cascade.rs +++ b/src/monorepo/run/cascade.rs @@ -7,7 +7,6 @@ use crate::config::{Config, PackageConfig, PropagatePolicy, VersionedFile}; use crate::conventional_commits::BumpType; use crate::formats::dependents::{plan_dependency_update, supports_dependency_updates}; use crate::formats::{get_handler, read_version, write_version}; -use crate::versioning::compute_next_version; use super::super::types::CheckPackage; use super::super::util::tags_for_package; @@ -102,9 +101,9 @@ fn resolve_cascaded<'a>( let strategy = pkg.effective_versioning(&config.workspace, || { tags_for_package(all_tags, &pkg_tag_prefix) }); - let version_template = pkg.effective_version_template(&config.workspace); - let base_version = - compute_next_version(¤t_version, bump, strategy, version_template).ok()?; + let (bump, base_version) = pkg + .next_version(&config.workspace, strategy, ¤t_version, bump) + .ok()?; let (new_version, is_prerelease) = with_prerelease_suffix( pkg, config, @@ -551,6 +550,79 @@ mod tests { dir } + #[test] + fn a_cascaded_breaking_bump_follows_pre_major_below_one_zero() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + for (name, version) in [("engine", "1.2.0"), ("viewer", "0.3.0")] { + std::fs::create_dir(root.join(name)).unwrap(); + std::fs::write( + root.join(format!("{name}/package.json")), + format!("{{\n \"name\": \"{name}\",\n \"version\": \"{version}\"\n}}\n"), + ) + .unwrap(); + } + std::fs::write( + root.join("ferrflow.json"), + r#"{ + "workspace": { "preMajor": { "breaking": "minor" } }, + "package": [ + { "name": "engine", "path": "engine", + "versionedFiles": [{ "path": "engine/package.json", "format": "json" }] }, + { "name": "viewer", "path": "viewer", "dependsOn": [{ "name": "engine", "propagate": "same" }], + "versionedFiles": [{ "path": "viewer/package.json", "format": "json" }] } + ] +} +"#, + ) + .unwrap(); + let config = Config::load(root, None).unwrap(); + + let mut any_bumped = false; + let mut json_packages = Vec::new(); + let mut released = Vec::new(); + let mut files_to_commit = Vec::new(); + let mut files_per_package = HashMap::new(); + let mut tags_to_create = Vec::new(); + let mut pkg_outputs = Vec::new(); + let mut bumped = HashMap::from([("engine".to_string(), BumpType::Major)]); + let mut bumped_versions = HashMap::from([("engine".to_string(), "2.0.0".to_string())]); + let mut sink = CascadeSink { + any_bumped: &mut any_bumped, + json_packages: &mut json_packages, + released: &mut released, + files_to_commit: &mut files_to_commit, + files_per_package: &mut files_per_package, + tags_to_create: &mut tags_to_create, + pkg_outputs: &mut pkg_outputs, + bumped: &mut bumped, + bumped_versions: &mut bumped_versions, + }; + + run_dependency_cascade( + &Pass { + config: &config, + root, + all_tags: &[], + prerelease_ctx: &PrereleaseContext::resolve(None, "main", None).unwrap(), + short_hash: "deadbee", + dry_run: false, + run_commands: false, + }, + false, + false, + &mut sink, + &mut std::collections::HashMap::new(), + ) + .unwrap(); + + let written = std::fs::read_to_string(root.join("viewer/package.json")).unwrap(); + assert!( + written.contains("\"version\": \"0.4.0\""), + "a 0.x dependent left 0.x through the cascade: {written}" + ); + } + #[test] fn a_cascaded_package_is_stamped_but_its_tag_is_not() { let dir = metadata_workspace(); diff --git a/src/monorepo/run/plan.rs b/src/monorepo/run/plan.rs index a62525fe..ea0d0b30 100644 --- a/src/monorepo/run/plan.rs +++ b/src/monorepo/run/plan.rs @@ -11,7 +11,6 @@ use crate::git::{ get_commits_since_last_tag, }; use crate::prerelease::PrereleaseContext; -use crate::versioning::compute_next_version; use gix::ObjectId; use rayon::prelude::*; use std::collections::{HashMap, HashSet}; @@ -397,18 +396,18 @@ pub(super) fn compute_plan( .map(|c| determine_bump(&c.message, &config.workspace.commit_formats)) .max() .unwrap_or(BumpType::None); - - let version_template = pkg.effective_version_template(&config.workspace); - - if bump == BumpType::None && version_template.is_none() && !is_date_or_seq(pkg_strategy) { + if bump == BumpType::None + && pkg.effective_version_template(&config.workspace).is_none() + && !is_date_or_seq(pkg_strategy) + { return Ok(PackagePlan::Skipped { reason: SkipReason::NoReleasableCommits, recovered, }); } - let base_version = - compute_next_version(¤t_version, bump, pkg_strategy, version_template)?; + let (bump, base_version) = + pkg.next_version(&config.workspace, pkg_strategy, ¤t_version, bump)?; let (new_version, is_prerelease) = with_prerelease_suffix( pkg, inputs.config, @@ -754,6 +753,74 @@ mod tests { ); } + #[test] + fn pre_major_remaps_the_bump_below_one_zero_and_leaves_later_majors_alone() { + let (dir, repo) = init_repo(); + let root = dir.path().to_path_buf(); + write_pkg(&root, "young", "0.5.0"); + write_pkg(&root, "mature", "1.0.0"); + std::fs::write( + root.join(".ferrflow"), + r#"{"workspace":{"preMajor":{"breaking":"minor","feat":"patch"}},"package":[ + {"name":"young","path":"young","versionedFiles":[{"path":"young/Cargo.toml","format":"toml"}]}, + {"name":"mature","path":"mature","versionedFiles":[{"path":"mature/Cargo.toml","format":"toml"}]} + ]}"#, + ) + .unwrap(); + git(&root, &["add", "-A"]); + commit_file(&root, "seed.txt", "x", "chore: seed", 1_950_000_000); + git(&root, &["tag", "young@v0.5.0"]); + git(&root, &["tag", "mature@v1.0.0"]); + std::fs::create_dir_all(root.join("mature")).unwrap(); + std::fs::write(root.join("mature/a.rs"), "x").unwrap(); + git(&root, &["add", "--", "mature/a.rs"]); + commit_file(&root, "young/a.rs", "x", "feat!: break both", 1_950_000_100); + + let config = Config::load(&root, Some(&root.join(".ferrflow"))).unwrap(); + let commit_walk = + crate::git::CommitWalkCache::new(config.workspace.effective_commit_skip_markers()); + let fx = Fixture { + _dir: dir, + repo, + config, + root: root.clone(), + cache: ChangedFilesCache::default(), + commit_files: CommitFilesCache::default(), + commit_walk, + }; + let all_tags = collect_all_tags(&fx.repo); + let head_ancestors = build_head_ancestors(&fx.repo).ok(); + let tag_index = TagIndex::build(&fx.repo).ok(); + let prerelease_ctx = PrereleaseContext::resolve(None, "main", None).unwrap(); + let forced: Vec> = Vec::new(); + let changed_files = get_changed_files(&fx.repo).unwrap(); + let inputs = build_inputs( + &fx, + &tag_index, + &head_ancestors, + &all_tags, + &prerelease_ctx, + &forced, + &changed_files, + ); + + let PackagePlan::Bump(young) = plan_for(&fx, &inputs, "young") else { + panic!( + "young should bump: {:?}", + plan_for(&fx, &inputs, "young").summary() + ); + }; + assert_eq!(young.new_version, "0.6.0"); + assert_eq!(young.bump, BumpType::Minor); + assert_eq!(young.strategy_label, "minor"); + + let PackagePlan::Bump(mature) = plan_for(&fx, &inputs, "mature") else { + panic!("mature should bump"); + }; + assert_eq!(mature.new_version, "2.0.0"); + assert_eq!(mature.bump, BumpType::Major); + } + fn plan_for(fx: &Fixture, inputs: &PlanInputs<'_>, name: &str) -> PackagePlan { let pkg = fx .config diff --git a/src/monorepo/run/upgrade.rs b/src/monorepo/run/upgrade.rs index 9f55a620..c72f2089 100644 --- a/src/monorepo/run/upgrade.rs +++ b/src/monorepo/run/upgrade.rs @@ -2,7 +2,6 @@ use std::collections::{HashMap, HashSet}; use crate::config::Config; use crate::conventional_commits::BumpType; -use crate::versioning::compute_next_version; use super::super::util::tags_for_package; use super::plan::{PackageBump, PackagePlan}; @@ -75,14 +74,24 @@ fn settle_raises( // A package with no plan of its own is left to the cascade proper, // which runs after the release loop. It still has to enter the // walk, or what depends on it never learns that it moved. + let mut bump = bump; if matches!(plans[idx], Some(PackagePlan::Bump(_))) { - let Some(version) = raised_version(config, all_tags, plans, idx, bump) else { + let Some((effective, version)) = raised_version(config, all_tags, plans, idx, bump) + else { // The raise cannot be applied, so it must not be recorded // either: a dependent settling on it would cascade off a // version this package never takes. blocked.insert(idx); continue; }; + let held = state + .get(&config.packages[idx].name) + .copied() + .unwrap_or(BumpType::None); + if effective <= held { + continue; + } + bump = effective; raised.insert(idx, (bump, version)); } state.insert(config.packages[idx].name.clone(), bump); @@ -120,7 +129,7 @@ fn raised_version( plans: &[Option], idx: usize, bump: BumpType, -) -> Option { +) -> Option<(BumpType, String)> { let pkg = &config.packages[idx]; let Some(PackagePlan::Bump(plan)) = plans.get(idx)? else { return None; @@ -128,8 +137,8 @@ fn raised_version( let prefix = pkg.tag_prefix(&config.workspace, true); let strategy = pkg.effective_versioning(&config.workspace, || tags_for_package(all_tags, &prefix)); - let template = pkg.effective_version_template(&config.workspace); - compute_next_version(&plan.current_version, bump, strategy, template).ok() + pkg.next_version(&config.workspace, strategy, &plan.current_version, bump) + .ok() } #[cfg(test)] @@ -171,6 +180,54 @@ mod tests { ] }"#; + #[test] + fn a_raise_from_a_breaking_dependency_follows_pre_major_below_one_zero() { + let config: Config = serde_json::from_str( + r#"{ + "workspace": { "preMajor": { "breaking": "minor" } }, + "package": [ + { "name": "engine", "path": "engine" }, + { "name": "viewer", "path": "viewer", "dependsOn": ["engine"] } + ] + }"#, + ) + .expect("valid config"); + let mut plans = vec![ + plan_for("1.2.0", "2.0.0", BumpType::Major), + plan_for("0.3.0", "0.3.1", BumpType::Patch), + ]; + + apply_cascade_upgrades(&config, &[], &mut plans); + + let (bump, version, _) = bump_of(&plans, 1).expect("viewer still has a plan"); + assert_eq!(bump, BumpType::Minor); + assert_eq!(version, "0.4.0"); + } + + #[test] + fn a_raise_that_pre_major_flattens_to_the_held_bump_changes_nothing() { + let config: Config = serde_json::from_str( + r#"{ + "workspace": { "preMajor": { "breaking": "minor" } }, + "package": [ + { "name": "engine", "path": "engine" }, + { "name": "viewer", "path": "viewer", "dependsOn": ["engine"] } + ] + }"#, + ) + .expect("valid config"); + let mut plans = vec![ + plan_for("1.2.0", "2.0.0", BumpType::Major), + plan_for("0.3.0", "0.4.0", BumpType::Minor), + ]; + + apply_cascade_upgrades(&config, &[], &mut plans); + + let (bump, version, _) = bump_of(&plans, 1).expect("viewer still has a plan"); + assert_eq!(bump, BumpType::Minor); + assert_eq!(version, "0.4.0"); + } + #[test] fn a_packages_own_patch_is_raised_by_a_dependency_taking_a_minor() { let config: Config = serde_json::from_str(CHAIN).expect("valid config"); diff --git a/src/monorepo/run/why/mod.rs b/src/monorepo/run/why/mod.rs index 45c4df8f..3db4eafd 100644 --- a/src/monorepo/run/why/mod.rs +++ b/src/monorepo/run/why/mod.rs @@ -7,7 +7,6 @@ use crate::conventional_commits::{BumpType, determine_bump}; use crate::formats::read_version; use crate::git::{collect_all_tags, get_changed_files, get_repo_root, open_repo}; use crate::prerelease::PrereleaseContext; -use crate::versioning::compute_next_version; use super::super::util::tags_for_package; use super::super::version_source::VersionSource; @@ -355,9 +354,8 @@ fn bump_leaves_version_unchanged( let strategy = pkg.effective_versioning(&config.workspace, || { tags_for_package(inputs.all_tags, &pkg.tag_prefix(&config.workspace, true)) }); - let version_template = pkg.effective_version_template(&config.workspace); - compute_next_version(¤t, bump, strategy, version_template) - .is_ok_and(|next| next == current) + pkg.next_version(&config.workspace, strategy, ¤t, bump) + .is_ok_and(|(_, next)| next == current) }) } @@ -438,14 +436,9 @@ fn decide( if let Some(bump) = cascade.get(&pkg.name).copied() && bump != BumpType::None - && let Some(next) = read_version_for_cascade(pkg, root).and_then(|current| { - compute_next_version( - ¤t, - bump, - strategy, - pkg.effective_version_template(&config.workspace), - ) - .ok() + && let Some((bump, next)) = read_version_for_cascade(pkg, root).and_then(|current| { + pkg.next_version(&config.workspace, strategy, ¤t, bump) + .ok() }) && next != current_version { diff --git a/src/monorepo/run/why/tests.rs b/src/monorepo/run/why/tests.rs index 04191731..81f64cc6 100644 --- a/src/monorepo/run/why/tests.rs +++ b/src/monorepo/run/why/tests.rs @@ -200,6 +200,61 @@ fn a_cascaded_package_reports_the_dependency_as_the_trigger() { } } +#[test] +fn a_cascaded_breaking_bump_follows_pre_major_below_one_zero() { + let (dir, repo) = init_repo(); + let root = dir.path().to_path_buf(); + for (name, version) in [("engine", "1.2.0"), ("viewer", "0.3.0")] { + std::fs::create_dir_all(root.join(name)).unwrap(); + std::fs::write( + root.join(name).join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n"), + ) + .unwrap(); + } + std::fs::write( + root.join("ferrflow.json"), + r#"{ + "workspace": { "preMajor": { "breaking": "minor" } }, + "package": [ + { "name": "engine", "path": "engine", "versionedFiles": [{ "path": "engine/Cargo.toml", "format": "toml" }] }, + { "name": "viewer", "path": "viewer", "dependsOn": ["engine"], + "versionedFiles": [{ "path": "viewer/Cargo.toml", "format": "toml" }] } + ] +} +"#, + ) + .unwrap(); + git(&root, &["add", "-A"]); + commit_file(&root, "seed.txt", "x", "chore: seed", next_ts()); + let config = Config::load(&root, Some(&root.join("ferrflow.json"))).unwrap(); + let fx = Fixture { + _dir: dir, + repo, + config, + root, + }; + fx.tag("engine@v1.2.0"); + fx.tag("viewer@v0.3.0"); + fx.commit("engine/src/lib.rs", "feat!: rewrite engine"); + + match bump_of(&fx.explain("viewer")) { + Decision::Bump { + bump, + to, + triggered_by, + .. + } => { + assert_eq!((bump.as_str(), to.as_deref()), ("minor", Some("0.4.0"))); + assert!(matches!(triggered_by, Trigger::Dependency)); + } + other => panic!( + "expected a cascade bump, got {:?}", + serde_json::to_string(other) + ), + } +} + #[test] fn a_chore_only_history_still_lists_the_commits_it_rejected() { let fx = monorepo(); diff --git a/src/versioning/mod.rs b/src/versioning/mod.rs index 9ca3cfec..5cde2909 100644 --- a/src/versioning/mod.rs +++ b/src/versioning/mod.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use crate::config::{FloatingTagLevel, VersioningStrategy}; +use crate::config::{FloatingTagLevel, PreMajorBump, PreMajorRules, VersioningStrategy}; use crate::conventional_commits::BumpType; mod detect; @@ -41,6 +41,32 @@ pub fn compute_next_version( } } +pub fn apply_pre_major( + rules: Option, + strategy: VersioningStrategy, + current: &str, + bump: BumpType, +) -> BumpType { + let Some(rules) = rules else { + return bump; + }; + let pre_major = strategy == VersioningStrategy::Semver + && semver::Version::parse(current.trim_start_matches('v')).is_ok_and(|v| v.major == 0); + if !pre_major { + return bump; + } + let level = match bump { + BumpType::Major => rules.breaking, + BumpType::Minor => rules.feat, + BumpType::Patch | BumpType::None => None, + }; + match level { + Some(PreMajorBump::Minor) => BumpType::Minor, + Some(PreMajorBump::Patch) => BumpType::Patch, + None => bump, + } +} + pub fn bump_version(current: &str, bump: BumpType) -> Result { strategies::bump_semver(current, bump) } diff --git a/src/versioning/tests.rs b/src/versioning/tests.rs index 953c0253..a17c0e3f 100644 --- a/src/versioning/tests.rs +++ b/src/versioning/tests.rs @@ -670,3 +670,175 @@ fn calver_short_seq_is_reachable_through_compute_next_version() { assert_eq!(v, format!("{}.{}.1", now.format("%y"), now.format("%-m"))); } + +fn pre_major_rules() -> Option { + Some(PreMajorRules { + breaking: Some(PreMajorBump::Minor), + feat: Some(PreMajorBump::Patch), + }) +} + +fn next_with_pre_major( + rules: Option, + strategy: VersioningStrategy, + current: &str, + bump: BumpType, +) -> String { + let bump = apply_pre_major(rules, strategy, current, bump); + compute_next_version(current, bump, strategy, None).unwrap() +} + +#[test] +fn pre_major_below_one_remaps_breaking_to_minor() { + assert_eq!( + next_with_pre_major( + pre_major_rules(), + VersioningStrategy::Semver, + "0.5.0", + BumpType::Major + ), + "0.6.0" + ); +} + +#[test] +fn pre_major_below_one_remaps_feat_to_patch() { + assert_eq!( + next_with_pre_major( + pre_major_rules(), + VersioningStrategy::Semver, + "0.5.0", + BumpType::Minor + ), + "0.5.1" + ); +} + +#[test] +fn pre_major_leaves_fix_as_patch() { + assert_eq!( + next_with_pre_major( + pre_major_rules(), + VersioningStrategy::Semver, + "0.5.0", + BumpType::Patch + ), + "0.5.1" + ); +} + +#[test] +fn pre_major_keeps_no_bump_unchanged() { + assert_eq!( + apply_pre_major( + pre_major_rules(), + VersioningStrategy::Semver, + "0.5.0", + BumpType::None + ), + BumpType::None + ); +} + +#[test] +fn pre_major_has_no_effect_from_one_zero_zero() { + for (bump, expected) in [ + (BumpType::Major, "2.0.0"), + (BumpType::Minor, "1.1.0"), + (BumpType::Patch, "1.0.1"), + ] { + assert_eq!( + next_with_pre_major(pre_major_rules(), VersioningStrategy::Semver, "1.0.0", bump), + expected + ); + } +} + +#[test] +fn unset_pre_major_keeps_the_standard_rules() { + assert_eq!( + next_with_pre_major(None, VersioningStrategy::Semver, "0.5.0", BumpType::Major), + "1.0.0" + ); + assert_eq!( + next_with_pre_major(None, VersioningStrategy::Semver, "0.5.0", BumpType::Minor), + "0.6.0" + ); +} + +#[test] +fn pre_major_with_a_single_key_only_remaps_that_key() { + let breaking_only = Some(PreMajorRules { + breaking: Some(PreMajorBump::Minor), + feat: None, + }); + assert_eq!( + next_with_pre_major( + breaking_only, + VersioningStrategy::Semver, + "0.5.0", + BumpType::Major + ), + "0.6.0" + ); + assert_eq!( + next_with_pre_major( + breaking_only, + VersioningStrategy::Semver, + "0.5.0", + BumpType::Minor + ), + "0.6.0" + ); +} + +#[test] +fn pre_major_can_remap_breaking_to_patch() { + let rules = Some(PreMajorRules { + breaking: Some(PreMajorBump::Patch), + feat: None, + }); + assert_eq!( + next_with_pre_major(rules, VersioningStrategy::Semver, "0.5.0", BumpType::Major), + "0.5.1" + ); +} + +#[test] +fn pre_major_only_applies_to_the_semver_strategy() { + assert_eq!( + next_with_pre_major( + pre_major_rules(), + VersioningStrategy::Zerover, + "0.5.0", + BumpType::Minor + ), + "0.6.0" + ); +} + +#[test] +fn pre_major_accepts_a_v_prefixed_current_version() { + assert_eq!( + apply_pre_major( + pre_major_rules(), + VersioningStrategy::Semver, + "v0.5.0", + BumpType::Major + ), + BumpType::Minor + ); +} + +#[test] +fn pre_major_ignores_an_unparseable_current_version() { + assert_eq!( + apply_pre_major( + pre_major_rules(), + VersioningStrategy::Semver, + "garbage", + BumpType::Major + ), + BumpType::Major + ); +}