Repository: orhun/git-cliff (language: python)

Issue:
Allow including tag-message in changelog
### Is there an existing issue or pull request for this?

- [X] I have searched the existing issues and pull requests

### Feature description

To have a "headline" for a release, it would be great allow including tag-messages (of release tags) into the changelog. For example, a changelog including this headline could look like this:

```
# Changelog

All notable changes to this project will be documented in this file.

## [1.0.1] - 2021-07-18

This is the release-tag message

### Miscellaneous Tasks
- Add release script

### Refactor

- Expose string functions
```


### Desired solution

Provide variable (such as `tag_message`) to the context, so it can be used as a variable within the changelog template.

### Alternatives considered

N/A

### Additional context

_No response_


Discussion / hints:
Thanks for opening your first issue at **git-cliff**! Be sure to follow the issue template! ⛰️

Pretty good idea! The implementation should be pretty straightforward thanks to [`Tag::message`](https://docs.rs/git2/latest/git2/struct.Tag.html#method.message) method. Marked as good first issue.
This will get into cart vs. horse territory for many projects as the finished changelog is expected inside the tagged commit not later. Perhaps an env var could be used to hold the tag message and then used both for the changelog and subsequent actual tag message.
Or maybe we can add a new argument similar to [`--with-commit`](https://git-cliff.org/docs/usage/adding-commits) such as `--tag-message`?

Fix patch:
diff --git a/git-cliff-core/src/changelog.rs b/git-cliff-core/src/changelog.rs
index 870f633c63..5680d49f76 100644
--- a/git-cliff-core/src/changelog.rs
+++ b/git-cliff-core/src/changelog.rs
@@ -815,6 +815,7 @@ mod test {
 		};
 		let test_release = Release {
 			version: Some(String::from("v1.0.0")),
+			message: None,
 			commits: vec![
 				Commit::new(
 					String::from("coffee"),
@@ -909,6 +910,7 @@ mod test {
 			},
 			Release {
 				version: None,
+				message: None,
 				commits: vec![
 					Commit::new(
 						String::from("abc123"),
diff --git a/git-cliff-core/src/lib.rs b/git-cliff-core/src/lib.rs
index 6f78c91ea4..812e0e777d 100644
--- a/git-cliff-core/src/lib.rs
+++ b/git-cliff-core/src/lib.rs
@@ -33,6 +33,8 @@ pub mod remote;
 /// Git repository.
 #[cfg(feature = "repo")]
 pub mod repo;
+/// Git tag.
+pub mod tag;
 /// Template engine.
 pub mod template;
 
diff --git a/git-cliff-core/src/release.rs b/git-cliff-core/src/release.rs
index 4d3f9308f1..3449f2eac4 100644
--- a/git-cliff-core/src/release.rs
+++ b/git-cliff-core/src/release.rs
@@ -21,6 +21,8 @@ use serde::{
 pub struct Release<'a> {
 	/// Release version, git tag.
 	pub version:   Option<String>,
+	/// git tag's message.
+	pub message:   Option<String>,
 	/// Commits made for the release.
 	pub commits:   Vec<Commit<'a>>,
 	/// Commit ID of the tag.
@@ -159,6 +161,7 @@ mod test {
 		fn build_release<'a>(version: &str, commits: &'a [&str]) -> Release<'a> {
 			Release {
 				version: None,
+				message: None,
 				commits: commits
 					.iter()
 					.map(|v| Commit::from(v.to_string()))
@@ -340,6 +343,7 @@ mod test {
 
 		let mut release = Release {
 			version: None,
+			message: None,
 			commits: vec![
 				Commit::from(String::from(
 					"1d244937ee6ceb8e0314a4a201ba93a7a61f2071 add github \
@@ -625,6 +629,7 @@ mod test {
 
 		let mut release = Release {
 			version: None,
+			message: None,
 			commits: vec![
 				Commit::from(String::from(
 					"1d244937ee6ceb8e0314a4a201ba93a7a61f2071 add github \
@@ -968,6 +973,7 @@ mod test {
 
 		let mut release = Release {
 			version: None,
+			message: None,
 			commits: vec![
 				Commit::from(String::from(
 					"1d244937ee6ceb8e0314a4a201ba93a7a61f2071 add github \
diff --git a/git-cliff-core/src/repo.rs b/git-cliff-core/src/repo.rs
index 8723a640e7..50645c883d 100644
--- a/git-cliff-core/src/repo.rs
+++ b/git-cliff-core/src/repo.rs
@@ -3,6 +3,7 @@ use crate::error::{
 	Error,
 	Result,
 };
+use crate::tag::Tag;
 use git2::{
 	BranchType,
 	Commit,
@@ -13,11 +14,20 @@ use git2::{
 };
 use glob::Pattern;
 use indexmap::IndexMap;
-use regex::Regex;
+use lazy_regex::{
+	lazy_regex,
+	Lazy,
+	Regex,
+};
 use std::io;
 use std::path::PathBuf;
 use url::Url;
 
+/// Regex for replacing the signature part of a tag message.
+static TAG_SIGNATURE_REGEX: Lazy<Regex> = lazy_regex!(
+	r"(?s)-----BEGIN PGP SIGNATURE-----(.*?)-----END PGP SIGNATURE-----"
+);
+
 /// Wrapper for [`Repository`] type from git2.
 ///
 /// [`Repository`]: GitRepository
@@ -95,11 +105,38 @@ impl Repository {
 	/// Returns the current tag.
 	///
 	/// It is the same as running `git describe --tags`
-	pub fn current_tag(&self) -> Option<String> {
+	pub fn current_tag(&self) -> Option<Tag> {
 		self.inner
 			.describe(DescribeOptions::new().describe_tags())
 			.ok()
-			.and_then(|describe| describe.format(None).ok())
+			.and_then(|describe| {
+				describe
+					.format(None)
+					.ok()
+					.map(|name| self.resolve_tag(&name))
+			})
+	}
+
+	/// Returns the tag object of the given name.
+	///
+	/// If given name doesn't exist, it still returns `Tag` with the given name.
+	pub fn resolve_tag(&self, name: &str) -> Tag {
+		match self
+			.inner
+			.resolve_reference_from_short_name(name)
+			.and_then(|r| r.peel_to_tag())
+		{
+			Ok(tag) => Tag {
+				name:    tag.name().unwrap_or_default().to_owned(),
+				message: tag.message().map(|msg| {
+					TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()
+				}),
+			},
+			_ => Tag {
+				name:    name.to_owned(),
+				message: None,
+			},
+		}
 	}
 
 	/// Returns the commit object of the given ID.
@@ -119,8 +156,8 @@ impl Repository {
 		&self,
 		pattern: &Option<Regex>,
 		topo_order: bool,
-	) -> Result<IndexMap<String, String>> {
-		let mut tags: Vec<(Commit, String)> = Vec::new();
+	) -> Result<IndexMap<String, Tag>> {
+		let mut tags: Vec<(Commit, Tag)> = Vec::new();
 		let tag_names = self.inner.tag_names(None)?;
 		for name in tag_names
 			.iter()
@@ -132,14 +169,22 @@ impl Repository {
 		{
 			let obj = self.inner.revparse_single(&name)?;
 			if let Ok(commit) = obj.clone().into_commit() {
-				tags.push((commit, name));
+				tags.push((commit, Tag {
+					name,
+					message: None,
+				}));
 			} else if let Some(tag) = obj.as_tag() {
 				if let Some(commit) = tag
 					.target()
 					.ok()
 					.and_then(|target| target.into_commit().ok())
 				{
-					tags.push((commit, name));
+					tags.push((commit, Tag {
+						name:    tag.name().map(String::from).unwrap_or(name),
+						message: tag.message().map(|msg| {
+							TAG_SIGNATURE_REGEX.replace(msg, "").trim().to_owned()
+						}),
+					}));
 				}
 			}
 		}
@@ -261,7 +306,7 @@ mod test {
 	fn get_latest_tag() -> Result<()> {
 		let repository = get_repository()?;
 		let tags = repository.tags(&None, false)?;
-		assert_eq!(&get_last_tag()?, tags.last().expect("no tags found").1);
+		assert_eq!(get_last_tag()?, tags.last().expect("no tags found").1.name);
 		Ok(())
 	}
 
@@ -270,16 +315,20 @@ mod test {
 		let repository = get_repository()?;
 		let tags = repository.tags(&None, true)?;
 		assert_eq!(
-			tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6").expect(
-				"the commit hash does not exist in the repository (tag v0.1.0)"
-			),
+			tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
+				.expect(
+					"the commit hash does not exist in the repository (tag v0.1.0)"
+				)
+				.name,
 			"v0.1.0"
 		);
 		assert_eq!(
-			tags.get("4ddef08debfff48117586296e49d5caa0800d1b5").expect(
-				"the commit hash does not exist in the repository (tag \
-				 v0.1.0-beta.4)"
-			),
+			tags.get("4ddef08debfff48117586296e49d5caa0800d1b5")
+				.expect(
+					"the commit hash does not exist in the repository (tag \
+					 v0.1.0-beta.4)"
+				)
+				.name,
 			"v0.1.0-beta.4"
 		);
 		let tags = repository.tags(
@@ -290,9 +339,11 @@ mod test {
 			true,
 		)?;
 		assert_eq!(
-			tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6").expect(
-				"the commit hash does not exist in the repository (tag v0.1.0)"
-			),
+			tags.get("2b8b4d3535f29231e05c3572e919634b9af907b6")
+				.expect(
+					"the commit hash does not exist in the repository (tag v0.1.0)"
+				)
+				.name,
 			"v0.1.0"
 		);
 		assert!(!tags.contains_key("4ddef08debfff48117586296e49d5caa0800d1b5"));
@@ -313,4 +364,30 @@ mod test {
 		);
 		Ok(())
 	}
+
+	#[test]
+	fn resolves_existing_tag_with_name_and_message() -> Result<()> {
+		let repository = get_repository()?;
+		let tag = repository.resolve_tag("v0.2.3");
+		assert_eq!(tag.name, "v0.2.3");
+		assert_eq!(
+			tag.message,
+			Some(
+				"Release v0.2.3\n\nBug Fixes\n- Fetch the dependencies before \
+				 copying the file to embed (9e29c95)"
+					.to_string()
+			)
+		);
+
+		Ok(())
+	}
+
+	#[test]
+	fn resolves_tag_when_no_tags_exist() -> Result<()> {
+		let repository = get_repository()?;
+		let tag = repository.resolve_tag("nonexistent-tag");
+		assert_eq!(tag.name, "nonexistent-tag");
+		assert_eq!(tag.message, None);
+		Ok(())
+	}
 }
diff --git a/git-cliff-core/src/tag.rs b/git-cliff-core/src/tag.rs
new file mode 100644
index 0000000000..7fe8ab9cb3
--- /dev/null
+++ b/git-cliff-core/src/tag.rs
@@ -0,0 +1,47 @@
+/// Common tag object that is parsed from a repository.
+///
+/// Lightweight tags will have `None` as message.
+#[derive(Debug)]
+pub struct Tag {
+	/// The name of the tag
+	pub name:    String,
+	/// The message of the tag (only if it was annotated).
+	pub message: Option<String>,
+}
+
+#[cfg(test)]
+mod test {
+	use super::*;
+
+	#[test]
+	fn create_tag_with_name_and_message() {
+		let tag = Tag {
+			name:    String::from("v1.0"),
+			message: Some(String::from("Initial release")),
+		};
+		assert_eq!(tag.name, "v1.0");
+		assert_eq!(tag.message, Some(String::from("Initial release")));
+	}
+
+	#[test]
+	fn create_tag_with_name_and_no_message() {
+		let tag = Tag {
+			name:    String::from("v1.0"),
+			message: None,
+		};
+		assert_eq!(tag.name, "v1.0");
+		assert_eq!(tag.message, None);
+	}
+
+	#[test]
+	fn debug_print_tag_with_message() {
+		let tag = Tag {
+			name:    String::from("v1.0"),
+			message: Some(String::from("Initial release")),
+		};
+		assert_eq!(
+			format!("{:?}", tag),
+			"Tag { name: \"v1.0\", message: Some(\"Initial release\") }"
+		);
+	}
+}
diff --git a/git-cliff-core/src/template.rs b/git-cliff-core/src/template.rs
index d5dbf93012..3fe0e8a3a9 100644
--- a/git-cliff-core/src/template.rs
+++ b/git-cliff-core/src/template.rs
@@ -189,6 +189,7 @@ mod test {
 	fn get_fake_release_data() -> Release<'static> {
 		Release {
 			version: Some(String::from("1.0")),
+			message: None,
 			commits: vec![
 				Commit::new(
 					String::from("123123"),
diff --git a/git-cliff/src/args.rs b/git-cliff/src/args.rs
index 1fcd1c3b3c..4c7698567d 100644
--- a/git-cliff/src/args.rs
+++ b/git-cliff/src/args.rs
@@ -64,7 +64,7 @@ pub struct Opt {
 		help = "Prints help information",
 		help_heading = "FLAGS"
 	)]
-	pub help:            Option<bool>,
+	pub help:             Option<bool>,
 	#[arg(
 		short = 'V',
 		long,
@@ -73,10 +73,10 @@ pub struct Opt {
 		help = "Prints version information",
 		help_heading = "FLAGS"
 	)]
-	pub version:         Option<bool>,
+	pub version:          Option<bool>,
 	/// Increases the logging verbosity.
 	#[arg(short, long, action = ArgAction::Count, alias = "debug", help_heading = Some("FLAGS"))]
-	pub verbose:         u8,
+	pub verbose:          u8,
 	/// Writes the default configuration file to cliff.toml
 	#[arg(
 	    short,
@@ -85,7 +85,7 @@ pub struct Opt {
 	    num_args = 0..=1,
 	    required = false
 	)]
-	pub init:            Option<Option<String>>,
+	pub init:             Option<Option<String>>,
 	/// Sets the configuration file.
 	#[arg(
 	    short,
@@ -95,7 +95,7 @@ pub struct Opt {
 	    default_value = DEFAULT_CONFIG,
 	    value_parser = Opt::parse_dir
 	)]
-	pub config:          PathBuf,
+	pub config:           PathBuf,
 	/// Sets the working directory.
 	#[arg(
 	    short,
@@ -104,7 +104,7 @@ pub struct Opt {
 	    value_name = "PATH",
 	    value_parser = Opt::parse_dir
 	)]
-	pub workdir:         Option<PathBuf>,
+	pub workdir:          Option<PathBuf>,
 	/// Sets the git repository.
 	#[arg(
 		short,
@@ -114,7 +114,7 @@ pub struct Opt {
 		num_args(1..),
 		value_parser = Opt::parse_dir
 	)]
-	pub repository:      Option<Vec<PathBuf>>,
+	pub repository:       Option<Vec<PathBuf>>,
 	/// Sets the path to include related commits.
 	#[arg(
 		long,
@@ -122,7 +122,7 @@ pub struct Opt {
 		value_name = "PATTERN",
 		num_args(1..)
 	)]
-	pub include_path:    Option<Vec<Pattern>>,
+	pub include_path:     Option<Vec<Pattern>>,
 	/// Sets the path to exclude related commits.
 	#[arg(
 		long,
@@ -130,10 +130,10 @@ pub struct Opt {
 		value_name = "PATTERN",
 		num_args(1..)
 	)]
-	pub exclude_path:    Option<Vec<Pattern>>,
+	pub exclude_path:     Option<Vec<Pattern>>,
 	/// Sets the regex for matching git tags.
 	#[arg(long, env = "GIT_CLIFF_TAG_PATTERN", value_name = "PATTERN")]
-	pub tag_pattern:     Option<Regex>,
+	pub tag_pattern:      Option<Regex>,
 	/// Sets custom commit messages to include in the changelog.
 	#[arg(
 		long,
@@ -141,10 +141,18 @@ pub struct Opt {
 		value_name = "MSG",
 		num_args(1..)
 	)]
-	pub with_commit:     Option<Vec<String>>,
+	pub with_commit:      Option<Vec<String>>,
+	/// Sets custom message for the latest release.
+	#[arg(
+		long,
+		env = "GIT_CLIFF_WITH_TAG_MESSAGE",
+		value_name = "MSG",
+		num_args = 0..=1,
+	)]
+	pub with_tag_message: Option<String>,
 	/// Sets the tags to ignore in the changelog.
 	#[arg(long, env = "GIT_CLIFF_IGNORE_TAGS", value_name = "PATTERN")]
-	pub ignore_tags:     Option<Regex>,
+	pub ignore_tags:      Option<Regex>,
 	/// Sets commits that will be skipped in the changelog.
 	#[arg(
 		long,
@@ -152,7 +160,7 @@ pub struct Opt {
 		value_name = "SHA1",
 		num_args(1..)
 	)]
-	pub skip_commit:     Option<Vec<String>>,
+	pub skip_commit:      Option<Vec<String>>,
 	/// Prepends entries to the given changelog file.
 	#[arg(
 	    short,
@@ -161,7 +169,7 @@ pub struct Opt {
 	    value_name = "PATH",
 	    value_parser = Opt::parse_dir
 	)]
-	pub prepend:         Option<PathBuf>,
+	pub prepend:          Option<PathBuf>,
 	/// Writes output to the given file.
 	#[arg(
 	    short,
@@ -172,7 +180,7 @@ pub struct Opt {
 	    num_args = 0..=1,
 	    default_missing_value = DEFAULT_OUTPUT
 	)]
-	pub output:          Option<PathBuf>,
+	pub output:           Option<PathBuf>,
 	/// Sets the tag for the latest version.
 	#[arg(
 		short,
@@ -181,13 +189,13 @@ pub struct Opt {
 		value_name = "TAG",
 		allow_hyphen_values = true
 	)]
-	pub tag:             Option<String>,
+	pub tag:              Option<String>,
 	/// Bumps the version for unreleased changes.
 	#[arg(long, help_heading = Some("FLAGS"))]
-	pub bump:            bool,
+	pub bump:             bool,
 	/// Prints bumped version for unreleased changes.
 	#[arg(long, help_heading = Some("FLAGS"))]
-	pub bumped_version:  bool,
+	pub bumped_version:   bool,
 	/// Sets the template for the changelog body.
 	#[arg(
 		short,
@@ -196,38 +204,38 @@ pub struct Opt {
 		value_name = "TEMPLATE",
 		allow_hyphen_values = true
 	)]
-	pub body:            Option<String>,
+	pub body:             Option<String>,
 	/// Processes the commits starting from the latest tag.
 	#[arg(short, long, help_heading = Some("FLAGS"))]
-	pub latest:          bool,
+	pub latest:           bool,
 	/// Processes the commits that belong to the current tag.
 	#[arg(long, help_heading = Some("FLAGS"))]
-	pub current:         bool,
+	pub current:          bool,
 	/// Processes the commits that do not belong to a tag.
 	#[arg(short, long, help_heading = Some("FLAGS"))]
-	pub unreleased:      bool,
+	pub unreleased:       bool,
 	/// Sorts the tags topologically.
 	#[arg(long, help_heading = Some("FLAGS"))]
-	pub topo_order:      bool,
+	pub topo_order:       bool,
 	/// Disables the external command execution.
 	#[arg(long, help_heading = Some("FLAGS"))]
-	pub no_exec:         bool,
+	pub no_exec:          bool,
 	/// Prints changelog context as JSON.
 	#[arg(short = 'x', long, help_heading = Some("FLAGS"))]
-	pub context:         bool,
+	pub context:          bool,
 	/// Strips the given parts from the changelog.
 	#[arg(short, long, value_name = "PART", value_enum)]
-	pub strip:           Option<Strip>,
+	pub strip:            Option<Strip>,
 	/// Sets sorting of the commits inside sections.
 	#[arg(
 		long,
 		value_enum,
 		default_value_t = Sort::Oldest
 	)]
-	pub sort:            Sort,
+	pub sort:             Sort,
 	/// Sets the commit range to process.
 	#[arg(value_name = "RANGE", help_heading = Some("ARGS"))]
-	pub range:           Option<String>,
+	pub range:            Option<String>,
 	/// Sets the GitHub API token.
 	#[arg(
 		long,
@@ -236,7 +244,7 @@ pub struct Opt {
 		hide_env_values = true,
 		hide = !cfg!(feature = "github"),
 	)]
-	pub github_token:    Option<SecretString>,
+	pub github_token:     Option<SecretString>,
 	/// Sets the GitHub repository.
 	#[arg(
 		long,
@@ -245,7 +253,7 @@ pub struct Opt {
 		value_name = "OWNER/REPO",
 		hide = !cfg!(feature = "github"),
 	)]
-	pub github_repo:     Option<RemoteValue>,
+	pub github_repo:      Option<RemoteValue>,
 	/// Sets the GitLab API token.
 	#[arg(
 		long,
@@ -254,7 +262,7 @@ pub struct Opt {
 		hide_env_values = true,
 		hide = !cfg!(feature = "gitlab"),
 	)]
-	pub gitlab_token:    Option<SecretString>,
+	pub gitlab_token:     Option<SecretString>,
 	/// Sets the GitLab repository.
 	#[arg(
 		long,
@@ -263,7 +271,7 @@ pub struct Opt {
 		value_name = "OWNER/REPO",
 		hide = !cfg!(feature = "gitlab"),
 	)]
-	pub gitlab_repo:     Option<RemoteValue>,
+	pub gitlab_repo:      Option<RemoteValue>,
 	/// Sets the Gitea API token.
 	#[arg(
 		long,
@@ -272,7 +280,7 @@ pub struct Opt {
 		hide_env_values = true,
 		hide = !cfg!(feature = "gitea"),
 	)]
-	pub gitea_token:     Option<SecretString>,
+	pub gitea_token:      Option<SecretString>,
 	/// Sets the GitLab repository.
 	#[arg(
 		long,
@@ -281,7 +289,7 @@ pub struct Opt {
 		value_name = "OWNER/REPO",
 		hide = !cfg!(feature = "gitea"),
 	)]
-	pub gitea_repo:      Option<RemoteValue>,
+	pub gitea_repo:       Option<RemoteValue>,
 	/// Sets the Bitbucket API token.
 	#[arg(
 		long,
@@ -290,7 +298,7 @@ pub struct Opt {
 		hide_env_values = true,
 		hide = !cfg!(feature = "bitbucket"),
 	)]
-	pub bitbucket_token: Option<SecretString>,
+	pub bitbucket_token:  Option<SecretString>,
 	/// Sets the Bitbucket repository.
 	#[arg(
 		long,
@@ -299,7 +307,7 @@ pub struct Opt {
 		value_name = "OWNER/REPO",
 		hide = !cfg!(feature = "bitbucket"),
 	)]
-	pub bitbucket_repo:  Option<RemoteValue>,
+	pub bitbucket_repo:   Option<RemoteValue>,
 }
 
 /// Custom type for the remote value.
diff --git a/git-cliff/src/lib.rs b/git-cliff/src/lib.rs
index a58c5d5695..6fc8e8c458 100644
--- a/git-cliff/src/lib.rs
+++ b/git-cliff/src/lib.rs
@@ -90,7 +90,9 @@ fn process_repository<'a>(
 	let ignore_regex = config.git.ignore_tags.as_ref();
 	tags = tags
 		.into_iter()
-		.filter(|(_, name)| {
+		.filter(|(_, tag)| {
+			let name = &tag.name;
+
 			// Keep skip tags to drop commits in the later stage.
 			let skip = skip_regex.map(|r| r.is_match(name)).unwrap_or_default();
 
@@ -184,7 +186,7 @@ fn process_repository<'a>(
 					repository.current_tag().as_ref().and_then(|tag| {
 						tags.iter()
 							.enumerate()
-							.find(|(_, (_, v))| v == &tag)
+							.find(|(_, (_, v))| v.name == tag.name)
 							.map(|(i, _)| i)
 					}) {
 					match current_tag_index.checked_sub(1) {
@@ -226,10 +228,10 @@ fn process_repository<'a>(
 		if let Some(commit_id) = commits.first().map(|c| c.id().to_string()) {
 			match tags.get(&commit_id) {
 				Some(tag) => {
-					warn!("There is already a tag ({}) for {}", tag, commit_id)
+					warn!("There is already a tag ({:?}) for {}", tag, commit_id)
 				}
 				None => {
-					tags.insert(commit_id, tag.to_string());
+					tags.insert(commit_id, repository.resolve_tag(tag));
 				}
 			}
 		}
@@ -249,9 +251,13 @@ fn process_repository<'a>(
 			releases[release_index].commits.push(commit);
 		}
 		if let Some(tag) = tags.get(&commit_id) {
-			releases[release_index].version = Some(tag.to_string());
+			let tag_name = &tag.name;
+
+			releases[release_index].version = Some(tag_name.clone());
+			releases[release_index].message = tag.message.clone();
 			releases[release_index].commit_id = Some(commit_id);
-			releases[release_index].timestamp = if args.tag.as_deref() == Some(tag) {
+			releases[release_index].timestamp = if args.tag == Some(tag_name.clone())
+			{
 				SystemTime::now()
 					.duration_since(UNIX_EPOCH)?
 					.as_secs()
@@ -286,6 +292,13 @@ fn process_repository<'a>(
 		}
 	}
 
+	// Set custom message for the latest release.
+	if let Some(message) = &args.with_tag_message {
+		if let Some(latest_release) = releases.iter_mut().last() {
+			latest_release.message = Some(message.to_owned());
+		}
+	}
+
 	// Set the previous release if the first release does not have one set.
 	if !releases.is_empty() &&
 		releases
@@ -299,7 +312,7 @@ fn process_repository<'a>(
 			.map(|tag| {
 				tags.iter()
 					.enumerate()
-					.find(|(_, (_, v))| v == &tag)
+					.find(|(_, (_, v))| v.name == tag.name)
 					.and_then(|(i, _)| i.checked_sub(1))
 					.and_then(|i| tags.get_index(i))
 			})
@@ -307,10 +320,10 @@ fn process_repository<'a>(
 			.flatten();
 
 		// Set the previous release if the first tag is found.
-		if let Some((commit_id, version)) = first_tag {
+		if let Some((commit_id, tag)) = first_tag {
 			let previous_release = Release {
 				commit_id: Some(commit_id.to_string()),
-				version: Some(version.to_string()),
+				version: Some(tag.name.clone()),
 				timestamp: repository
 					.find_commit(commit_id.to_string())
 					.map(|v| v.time().seconds())
diff --git a/website/docs/templating/context.md b/website/docs/templating/context.md
index 2afebdf4f5..9d61db8015 100644
--- a/website/docs/templating/context.md
+++ b/website/docs/templating/context.md
@@ -25,6 +25,7 @@ following context is generated to use for templating:
 ```json
 {
   "version": "v0.1.0-rc.21",
+  "message": "The annotated tag message for the release"
   "commits": [
     {
       "id": "e795460c9bb7275294d1fa53a9d73258fb51eb10",
@@ -129,6 +130,7 @@ If [`conventional_commits`](/docs/configuration/git#conventional_commits) is set
 ```json
 {
   "version": "v0.1.0-rc.21",
+  "message": "The annotated tag message for the release"
   "commits": [
     {
       "id": "e795460c9bb7275294d1fa53a9d73258fb51eb10",
diff --git a/website/docs/usage/adding-tag-messages.md b/website/docs/usage/adding-tag-messages.md
new file mode 100644
index 0000000000..7e4f5692d2
--- /dev/null
+++ b/website/docs/usage/adding-tag-messages.md
@@ -0,0 +1,42 @@
+---
+sidebar_position: 9
+---
+
+# Adding version (tag) message
+
+Sometimes, you might want to include a special message or note related to a version of your project.
+This can be used to highlight significant milestones, provide additional context, or share information not captured by individual commit messages.
+
+There are currently 2 ways of doing this, in both ways, the message is available in the context of the template under the name `message`:
+
+```
+{% if message %}
+    {{ message }}
+{% endif %}\
+```
+
+## Using annotated tags
+
+The recommended way of adding a version message is to add the message to the tag:
+
+```bash
+git tag v1.0.0 -m "first release, yay!"
+```
+
+So in the release's context, `message` will be "first release, yay!" (even if it is signed).
+
+## Using `--with-tag-message`
+
+If for some reason you don't want to have the message in the tag (or don't have a tag yet) but want to include it in the generated changelog, you can use the `--with-tag-message` flag:
+
+```bash
+git cliff --bump --unreleased --with-tag-message "some text"
+```
+
+In this case, you can only add a message to the latest release.
+
+:::note
+
+Please note that if you use `--with-tag-message` on a version it will ignore the original tag's message and use the one from the argument.
+
+:::
diff --git a/website/docs/usage/args.md b/website/docs/usage/args.md
index 5219301042..efd9b3ee4b 100644
--- a/website/docs/usage/args.md
+++ b/website/docs/usage/args.md
@@ -20,33 +20,38 @@ git-cliff [FLAGS] [OPTIONS] [--] [RANGE]
     --current         Processes the commits that belong to the current tag
 -u, --unreleased      Processes the commits that do not belong to a tag
     --topo-order      Sorts the tags topologically
--x, --context         Prints changelog context as JSON
     --no-exec         Disables the external command execution
+-x, --context         Prints changelog context as JSON
 ```
 
 ## Options
 
 ```
--i, --init [<CONFIG>]            Writes the default configuration file to cliff.toml
--c, --config <PATH>              Sets the configuration file [env: GIT_CLIFF_CONFIG=] [default: cliff.toml]
--w, --workdir <PATH>             Sets the working directory [env: GIT_CLIFF_WORKDIR=]
--r, --repository <PATH>...       Sets the git repository [env: GIT_CLIFF_REPOSITORY=]
-    --include-path <PATTERN>...  Sets the path to include related commits [env: GIT_CLIFF_INCLUDE_PATH=]
-    --exclude-path <PATTERN>...  Sets the path to exclude related commits [env: GIT_CLIFF_EXCLUDE_PATH=]
-    --tag-pattern <PATTERN>      Sets the regex for matching git tags [env: GIT_CLIFF_TAG_PATTERN=]
-    --with-commit <MSG>...       Sets custom commit messages to include in the changelog [env: GIT_CLIFF_WITH_COMMIT=]
-    --ignore-tags <PATTERN>      Sets the tags to ignore in the changelog [env: GIT_CLIFF_IGNORE_TAGS=]
-    --skip-commit <SHA1>...      Sets commits that will be skipped in the changelog [env: GIT_CLIFF_SKIP_COMMIT=]
--p, --prepend <PATH>             Prepends entries to the given changelog file [env: GIT_CLIFF_PREPEND=]
--o, --output [<PATH>]            Writes output to the given file [env: GIT_CLIFF_OUTPUT=]
--t, --tag <TAG>                  Sets the tag for the latest version [env: GIT_CLIFF_TAG=]
--b, --body <TEMPLATE>            Sets the template for the changelog body [env: GIT_CLIFF_TEMPLATE=]
--s, --strip <PART>               Strips the given parts from the changelog [possible values: header, footer, all]
-    --sort <SORT>                Sets sorting of the commits inside sections [default: oldest] [possible values: oldest, newest]
-    --github-token <TOKEN>       Sets the GitHub API token [env: GITHUB_TOKEN]
-    --github-repo <OWNER/REPO>   Sets the GitHub repository [env: GITHUB_REPO=]
-    --gitlab-token <TOKEN>       Sets the GitLab API token [env: GITLAB_TOKEN]
-    --gitlab-repo <OWNER/REPO>   Sets the GitLab repository [env: GITLAB_REPO=]
+-i, --init [<CONFIG>]              Writes the default configuration file to cliff.toml
+-c, --config <PATH>                Sets the configuration file [env: GIT_CLIFF_CONFIG=] [default: cliff.toml]
+-w, --workdir <PATH>               Sets the working directory [env: GIT_CLIFF_WORKDIR=]
+-r, --repository <PATH>...         Sets the git repository [env: GIT_CLIFF_REPOSITORY=]
+    --include-path <PATTERN>...    Sets the path to include related commits [env: GIT_CLIFF_INCLUDE_PATH=]
+    --exclude-path <PATTERN>...    Sets the path to exclude related commits [env: GIT_CLIFF_EXCLUDE_PATH=]
+    --tag-pattern <PATTERN>        Sets the regex for matching git tags [env: GIT_CLIFF_TAG_PATTERN=]
+    --with-commit <MSG>...         Sets custom commit messages to include in the changelog [env: GIT_CLIFF_WITH_COMMIT=]
+    --with-tag-message [<MSG>]     Sets custom message for the latest release [env: GIT_CLIFF_WITH_TAG_MESSAGE=]
+    --ignore-tags <PATTERN>        Sets the tags to ignore in the changelog [env: GIT_CLIFF_IGNORE_TAGS=]
+    --skip-commit <SHA1>...        Sets commits that will be skipped in the changelog [env: GIT_CLIFF_SKIP_COMMIT=]
+-p, --prepend <PATH>               Prepends entries to the given changelog file [env: GIT_CLIFF_PREPEND=]
+-o, --output [<PATH>]              Writes output to the given file [env: GIT_CLIFF_OUTPUT=]
+-t, --tag <TAG>                    Sets the tag for the latest version [env: GIT_CLIFF_TAG=]
+-b, --body <TEMPLATE>              Sets the template for the changelog body [env: GIT_CLIFF_TEMPLATE=]
+-s, --strip <PART>                 Strips the given parts from the changelog [possible values: header, footer, all]
+    --sort <SORT>                  Sets sorting of the commits inside sections [default: oldest] [possible values: oldest, newest]
+    --github-token <TOKEN>         Sets the GitHub API token [env: GITHUB_TOKEN]
+    --github-repo <OWNER/REPO>     Sets the GitHub repository [env: GITHUB_REPO=]
+    --gitlab-token <TOKEN>         Sets the GitLab API token [env: GITLAB_TOKEN]
+    --gitlab-repo <OWNER/REPO>     Sets the GitLab repository [env: GITLAB_REPO=]
+    --gitea-token <TOKEN>          Sets the Gitea API token [env: GITEA_TOKEN]
+    --gitea-repo <OWNER/REPO>      Sets the GitLab repository [env: GITEA_REPO=]
+    --bitbucket-token <TOKEN>      Sets the Bitbucket API token [env: BITBUCKET_TOKEN]
+    --bitbucket-repo <OWNER/REPO>  Sets the Bitbucket repository [env: BITBUCKET_REPO=]
 ```
 
 ## Args
diff --git a/website/docs/usage/print-context.md b/website/docs/usage/print-context.md
index 833555013b..fc314b9518 100644
--- a/website/docs/usage/print-context.md
+++ b/website/docs/usage/print-context.md
@@ -1,5 +1,5 @@
 ---
-sidebar_position: 9
+sidebar_position: 10
 ---
 
 # Print context


Test patch:
diff --git a/.github/fixtures/test-bump-unreleased-with-tag-message-arg/cliff.toml b/.github/fixtures/test-bump-unreleased-with-tag-message-arg/cliff.toml
new file mode 100644
index 0000000000..9b43af3e3e
--- /dev/null
+++ b/.github/fixtures/test-bump-unreleased-with-tag-message-arg/cliff.toml
@@ -0,0 +1,37 @@
+[changelog]
+# template for the changelog footer
+header = """
+# Changelog\n
+All notable changes to this project will be documented in this file.\n
+"""
+# template for the changelog body
+# https://keats.github.io/tera/docs/#introduction
+body = """
+{% if version %}\
+    ## [{{ version | trim_start_matches(pat="v") }}]
+    {% if message %}
+        {{ message }}
+    {% endif %}\
+{% else %}\
+    ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+    ### {{ group | upper_first }}
+    {% for commit in commits %}
+        - {{ commit.message | upper_first }}\
+    {% endfor %}
+{% endfor %}\n
+"""
+# template for the changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+# remove the leading and trailing whitespace from the templates
+trim = true
+
+[git]
+# regex for parsing and grouping commits
+commit_parsers = [
+    { message = "^feat", group = "Features", default_scope = "app" },
+    { message = "^fix", group = "Bug Fixes", scope = "cli" },
+]
diff --git a/.github/fixtures/test-bump-unreleased-with-tag-message-arg/commit.sh b/.github/fixtures/test-bump-unreleased-with-tag-message-arg/commit.sh
new file mode 100755
index 0000000000..7c6fe32718
--- /dev/null
+++ b/.github/fixtures/test-bump-unreleased-with-tag-message-arg/commit.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -e
+
+GIT_COMMITTER_DATE="2022-04-06 01:25:08" git commit --allow-empty -m "Initial commit"
+GIT_COMMITTER_DATE="2022-04-06 01:25:09" git commit --allow-empty -m "feat: add feature 1"
+GIT_COMMITTER_DATE="2022-04-06 01:25:10" git commit --allow-empty -m "fix: fix feature 1"
+git tag v0.1.0
+GIT_COMMITTER_DATE="2022-04-06 01:25:11" git commit --allow-empty -m "feat(gui): add feature 2"
+GIT_COMMITTER_DATE="2022-04-06 01:25:12" git commit --allow-empty -m "fix(gui): fix feature 2"
+git tag v0.2.0
+GIT_COMMITTER_DATE="2022-04-06 01:25:13" git commit --allow-empty -m "test: add tests"
diff --git a/.github/fixtures/test-bump-unreleased-with-tag-message-arg/expected.md b/.github/fixtures/test-bump-unreleased-with-tag-message-arg/expected.md
new file mode 100644
index 0000000000..bcb74bdacd
--- /dev/null
+++ b/.github/fixtures/test-bump-unreleased-with-tag-message-arg/expected.md
@@ -0,0 +1,13 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [0.2.1]
+
+Some text
+
+### Test
+
+- Add tests
+
+<!-- generated by git-cliff -->
diff --git a/.github/fixtures/test-tag-message/cliff.toml b/.github/fixtures/test-tag-message/cliff.toml
new file mode 100644
index 0000000000..1f2be1c0eb
--- /dev/null
+++ b/.github/fixtures/test-tag-message/cliff.toml
@@ -0,0 +1,37 @@
+[changelog]
+# template for the changelog footer
+header = """
+# Changelog\n
+All notable changes to this project will be documented in this file.\n
+"""
+# template for the changelog body
+# https://keats.github.io/tera/docs/#introduction
+body = """
+{% if version %}\
+    ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+    {% if message %}
+        {{ message }}
+    {% endif %}\
+{% else %}\
+    ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+    ### {{ group | upper_first }}
+    {% for commit in commits %}
+        - {{ commit.message | upper_first }}\
+    {% endfor %}
+{% endfor %}\n
+"""
+# template for the changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+# remove the leading and trailing whitespace from the templates
+trim = true
+
+[git]
+# regex for parsing and grouping commits
+commit_parsers = [
+    { message = "^feat", group = "Features", default_scope = "app" },
+    { message = "^fix", group = "Bug Fixes", scope = "cli" },
+]
diff --git a/.github/fixtures/test-tag-message/commit.sh b/.github/fixtures/test-tag-message/commit.sh
new file mode 100755
index 0000000000..f2ec2b9ca0
--- /dev/null
+++ b/.github/fixtures/test-tag-message/commit.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -e
+
+GIT_COMMITTER_DATE="2022-04-06 01:25:08" git commit --allow-empty -m "Initial commit"
+GIT_COMMITTER_DATE="2022-04-06 01:25:09" git commit --allow-empty -m "feat: add feature 1"
+GIT_COMMITTER_DATE="2022-04-06 01:25:10" git commit --allow-empty -m "fix: fix feature 1"
+git tag v0.1.0 -m "Some text"
+GIT_COMMITTER_DATE="2022-04-06 01:25:11" git commit --allow-empty -m "feat(gui): add feature 2"
+GIT_COMMITTER_DATE="2022-04-06 01:25:12" git commit --allow-empty -m "fix(gui): fix feature 2"
+git tag v0.2.0
+GIT_COMMITTER_DATE="2022-04-06 01:25:13" git commit --allow-empty -m "test: add tests"
diff --git a/.github/fixtures/test-tag-message/expected.md b/.github/fixtures/test-tag-message/expected.md
new file mode 100644
index 0000000000..afcaca4dd9
--- /dev/null
+++ b/.github/fixtures/test-tag-message/expected.md
@@ -0,0 +1,33 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+## [unreleased]
+
+### Test
+
+- Add tests
+
+## [0.2.0] - 2022-04-06
+
+### Bug Fixes
+
+- Fix feature 2
+
+### Features
+
+- Add feature 2
+
+## [0.1.0] - 2022-04-06
+
+Some text
+
+### Bug Fixes
+
+- Fix feature 1
+
+### Features
+
+- Add feature 1
+
+<!-- generated by git-cliff -->
diff --git a/.github/workflows/test-fixtures.yml b/.github/workflows/test-fixtures.yml
index 0afbefd0d9..3a495253e4 100644
--- a/.github/workflows/test-fixtures.yml
+++ b/.github/workflows/test-fixtures.yml
@@ -79,6 +79,9 @@ jobs:
             command: --bump --tag=2.1.1
           - fixtures-name: test-cli-arg-ignore-tags
             command: --ignore-tags ".*beta"
+          - fixtures-name: test-tag-message
+          - fixtures-name: test-bump-unreleased-with-tag-message-arg
+            command: --bump --unreleased --with-tag-message "Some text"
 
     steps:
       - name: Checkout
diff --git a/git-cliff-core/tests/integration_test.rs b/git-cliff-core/tests/integration_test.rs
index 95fd991ab6..3e960fac69 100644
--- a/git-cliff-core/tests/integration_test.rs
+++ b/git-cliff-core/tests/integration_test.rs
@@ -149,6 +149,7 @@ fn generate_changelog() -> Result<()> {
 	let releases = vec![
 		Release {
 			version:   Some(String::from("v2.0.0")),
+			message: None,
 			commits:   vec![
 
 				Commit::new(
@@ -212,6 +213,7 @@ fn generate_changelog() -> Result<()> {
 		},
 		Release {
 			version:   Some(String::from("v1.0.0")),
+			message: None,
 			commits:   vec![
 				Commit::new(
 					String::from("0bc123"),