diff --git a/invenio_rdm_records/notifications/vcs.py b/invenio_rdm_records/notifications/vcs.py
new file mode 100644
index 0000000000..99da2a56a2
--- /dev/null
+++ b/invenio_rdm_records/notifications/vcs.py
@@ -0,0 +1,164 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2026 CERN.
+#
+# Invenio-RDM-Records is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+
+"""
+Notification builders for VCS.
+
+These are kept in a separate file as invenio-vcs is an optional dependency, so imports would fail if the package isn't available.
+"""
+
+from invenio_notifications.models import Notification
+from invenio_notifications.registry import EntityResolverRegistry
+from invenio_notifications.services.builders import NotificationBuilder
+from invenio_notifications.services.generators import EntityResolve, UserEmailBackend
+from invenio_users_resources.notifications.filters import UserPreferencesRecipientFilter
+from invenio_vcs.generic_models import GenericRelease, GenericRepository
+from invenio_vcs.notifications.generators import RepositoryUsersRecipient
+
+
+class RepositoryReleaseNotificationBuilder(NotificationBuilder):
+ """Notification builder for repository release events."""
+
+ type = "repository-release"
+
+ @classmethod
+ def build(
+ cls,
+ provider: str,
+ generic_repository: GenericRepository,
+ generic_release: GenericRelease,
+ ):
+ """Build the notification."""
+ return Notification(
+ type=cls.type,
+ context={
+ "provider": provider,
+ "repository_provider_id": generic_repository.id,
+ "repository_full_name": generic_repository.full_name,
+ "release_tag": generic_release.tag_name,
+ },
+ )
+
+ context = []
+
+ recipients = [RepositoryUsersRecipient("provider", "repository_provider_id")]
+
+ recipient_filters = [UserPreferencesRecipientFilter()]
+
+ recipient_backends = [UserEmailBackend()]
+
+
+class RepositoryReleaseSuccessNotificationBuilder(RepositoryReleaseNotificationBuilder):
+ """Notification builder for successful repository release events."""
+
+ type = f"{RepositoryReleaseNotificationBuilder.type}.success"
+
+ @classmethod
+ def build(
+ cls,
+ provider: str,
+ generic_repository: GenericRepository,
+ generic_release: GenericRelease,
+ record,
+ ):
+ """Build the notification."""
+ notification = super().build(provider, generic_repository, generic_release)
+ notification.context["record"] = EntityResolverRegistry.reference_entity(record)
+ return notification
+
+ context = [EntityResolve(key="record")]
+
+
+class RepositoryReleaseFailureNotificationBuilder(RepositoryReleaseNotificationBuilder):
+ """
+ Notification builder for failed repository release events.
+
+ The failure might occur before or after a draft has been successfully saved, so `draft` is allowed
+ to be `None`. The notification message should include a link to edit the draft if it's available.
+ """
+
+ type = f"{RepositoryReleaseNotificationBuilder.type}.failure"
+
+ @classmethod
+ def build(
+ cls,
+ provider: str,
+ generic_repository: GenericRepository,
+ generic_release: GenericRelease,
+ error_message: str,
+ draft=None,
+ ):
+ """Build the notification."""
+ notification = super().build(provider, generic_repository, generic_release)
+ notification.context["error_message"] = error_message
+ if draft is not None:
+ notification.context["draft"] = EntityResolverRegistry.reference_entity(
+ draft
+ )
+ else:
+ notification.context["draft"] = None
+ return notification
+
+ context = [EntityResolve(key="draft")]
+
+
+class RepositoryReleaseCommunityRequiredNotificationBuilder(
+ RepositoryReleaseNotificationBuilder
+):
+ """
+ Release is saved as a draft but the user needs to add a community.
+
+ Notification builder for when a release is saved as a draft but
+ fails to be published because the user needs to manually select
+ a community for the draft.
+ """
+
+ type = f"{RepositoryReleaseNotificationBuilder.type}.community-required"
+
+ @classmethod
+ def build(
+ cls,
+ provider: str,
+ generic_repository: GenericRepository,
+ generic_release: GenericRelease,
+ draft,
+ ):
+ """Build the notification."""
+ notification = super().build(provider, generic_repository, generic_release)
+ notification.context["draft"] = EntityResolverRegistry.reference_entity(draft)
+ return notification
+
+ context = [EntityResolve(key="draft")]
+
+
+class RepositoryReleaseCommunitySubmittedNotificationBuilder(
+ RepositoryReleaseNotificationBuilder
+):
+ """Notification builder for when a release is submitted for review by a community."""
+
+ type = f"{RepositoryReleaseNotificationBuilder.type}.community-submitted"
+
+ @classmethod
+ def build(
+ cls,
+ provider: str,
+ generic_repository: GenericRepository,
+ generic_release: GenericRelease,
+ request,
+ community,
+ ):
+ """Build the notification."""
+ notification = super().build(provider, generic_repository, generic_release)
+ notification.context["request"] = EntityResolverRegistry.reference_entity(
+ request
+ )
+ notification.context["community"] = EntityResolverRegistry.reference_entity(
+ community
+ )
+ return notification
+
+ context = [EntityResolve(key="request"), EntityResolve(key="community")]
diff --git a/invenio_rdm_records/services/components/vcs.py b/invenio_rdm_records/services/components/vcs.py
new file mode 100644
index 0000000000..c0906c5fe1
--- /dev/null
+++ b/invenio_rdm_records/services/components/vcs.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2026 CERN.
+#
+# Invenio-RDM-Records is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+"""RDM service component for updating VCS release models when drafts are published."""
+
+from invenio_drafts_resources.services.records.components import ServiceComponent
+from invenio_vcs.models import Release, ReleaseStatus
+
+
+class VCSComponent(ServiceComponent):
+ """Service component for VCS."""
+
+ def publish(self, identity, draft=None, record=None):
+ """Publish."""
+ if record is None:
+ return
+
+ record_model_id = record.model.id
+ # See if there's a release that originally failed to publish but was saved in a draft state
+ db_release = Release.get_for_record(record_model_id, only_draft=True)
+ # If this record didn't come from a VCS release or the release originally succeeded, we won't find anything.
+ if db_release is None:
+ return
+ if (
+ db_release.status != ReleaseStatus.FAILED
+ and db_release.status != ReleaseStatus.PUBLISH_PENDING
+ ):
+ return
+
+ # We are now publishing it, so we can correct the release's status
+ db_release.status = ReleaseStatus.PUBLISHED
+ db_release.record_is_draft = False
+ # We can delete the error that originally happened during publish
+ db_release.errors = None
diff --git a/invenio_rdm_records/services/github/__init__.py b/invenio_rdm_records/services/github/__init__.py
index e459e101f4..39f16de010 100644
--- a/invenio_rdm_records/services/github/__init__.py
+++ b/invenio_rdm_records/services/github/__init__.py
@@ -1,7 +1,13 @@
# -*- coding: utf-8 -*-
#
-# Copyright (C) 2023 CERN.
+# Copyright (C) 2023-2026 CERN.
#
# Invenio-RDM-Records is free software; you can redistribute it and/or modify
# it under the terms of the MIT License; see LICENSE file for more details.
-"""RDM records implementation of Github."""
+"""RDM records implementation of the legacy Invenio-GitHub module.
+
+This module is now deprecated. For now, to allow for an optional migration, the RDM bindings for
+Invenio-GitHub have been retained. Please migrate to Invenio-VCS.
+"""
+
+# TODO: add link to migration docs.
diff --git a/invenio_rdm_records/services/vcs/__init__.py b/invenio_rdm_records/services/vcs/__init__.py
new file mode 100644
index 0000000000..a0e93ef70f
--- /dev/null
+++ b/invenio_rdm_records/services/vcs/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2023-2025 CERN.
+#
+# Invenio-RDM-Records is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+"""RDM records implementation of Invenio-VCS."""
diff --git a/invenio_rdm_records/services/vcs/metadata.py b/invenio_rdm_records/services/vcs/metadata.py
new file mode 100644
index 0000000000..ff619c7f16
--- /dev/null
+++ b/invenio_rdm_records/services/vcs/metadata.py
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2023-2025 CERN.
+#
+# Invenio-RDM-Records is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+"""RDM VCS release metadata."""
+
+from datetime import datetime, timezone
+from typing import TYPE_CHECKING
+
+import yaml
+from flask import current_app
+from invenio_i18n import _
+from invenio_vcs.errors import CustomVCSReleaseNoRetryError
+from invenio_vcs.generic_models import GenericContributor
+from marshmallow import Schema, ValidationError
+from mistune import markdown
+
+if TYPE_CHECKING:
+ from invenio_rdm_records.services.vcs.release import RDMVCSRelease
+
+
+class RDMReleaseMetadata(object):
+ """Wraps a realease object to extract its data to meet RDM specific needs."""
+
+ def __init__(self, rdm_vcs_release: "RDMVCSRelease"):
+ """Constructor."""
+ self.rdm_release = rdm_vcs_release
+
+ @property
+ def related_identifiers(self):
+ """Return related identifiers."""
+ repo_name = self.rdm_release.generic_repo.full_name
+ release_tag_name = self.rdm_release.generic_release.tag_name
+ return {
+ "identifier": self.rdm_release.provider.factory.url_for_tag(
+ repo_name, release_tag_name
+ ),
+ "scheme": "url",
+ "relation_type": {"id": "issupplementto"},
+ "resource_type": {"id": "software"},
+ }
+
+ @property
+ def title(self):
+ """Generate a title from a release and its repository name."""
+ repo_name = self.rdm_release.generic_repo.full_name
+ release_name = (
+ self.rdm_release.generic_release.name
+ or self.rdm_release.generic_release.tag_name
+ )
+ return f"{repo_name}: {release_name}"
+
+ @property
+ def description(self):
+ """Extract description from a release.
+
+ If the relesae does not have any body, the repository description is used.
+ Falls back for "No description provided".
+ """
+ if self.rdm_release.generic_release.body:
+ return markdown(self.rdm_release.generic_release.body)
+ elif self.rdm_release.generic_repo.description:
+ return self.rdm_release.generic_repo.description
+ return _("No description provided.")
+
+ @property
+ def default_metadata(self):
+ """Return default metadata for a release."""
+ # Get default right from app config or use cc-by-4.0 if default is not set in app
+ # TODO use the default software license
+ version = self.rdm_release.generic_release.tag_name
+
+ publication_date = self.rdm_release.generic_release.published_at
+ if publication_date is None:
+ publication_date = datetime.now(tz=timezone.utc)
+ publication_date = publication_date.date().isoformat()
+
+ return dict(
+ description=self.description,
+ publication_date=publication_date,
+ related_identifiers=[self.related_identifiers],
+ version=version,
+ title=self.title,
+ resource_type={"id": "software"},
+ creators=self.contributors,
+ publisher=current_app.config.get("APP_RDM_DEPOSIT_FORM_DEFAULTS").get(
+ "publisher", "CERN"
+ ),
+ )
+
+ @property
+ def repo_license(self):
+ """Get license from repository, if any."""
+ return self.rdm_release.generic_repo.license_spdx
+
+ @property
+ def contributors(self):
+ """Serializes contributors retrieved from github.
+
+ .. note::
+
+ `self.rdm_release.contributors` might fail with a `UnexpectedGithubResponse`. This is an error from which the RDM release
+ processing can't recover since `creators` is a mandatory field.
+ """
+
+ def serialize_author(contributor: GenericContributor):
+ """Serializes github contributor data into RDM author."""
+ # Default name to the user's login
+ name = contributor.display_name or contributor.username
+ company = contributor.company
+
+ rdm_contributor: dict = {
+ "person_or_org": {"type": "personal", "family_name": name},
+ }
+ if company:
+ rdm_contributor.update({"affiliations": [{"name": company}]})
+ return rdm_contributor
+
+ contributors = []
+ provider_contributors = self.rdm_release.contributors
+
+ if provider_contributors is not None:
+ # Get contributors from api
+ for c in provider_contributors:
+ rdm_author = serialize_author(c)
+ if rdm_author:
+ contributors.append(rdm_author)
+
+ return contributors
+
+ @property
+ def citation_metadata(self):
+ """Get citation metadata for file in repository."""
+ citation_file_path = current_app.config.get("VCS_CITATION_FILE")
+
+ if not citation_file_path:
+ return {}
+
+ try:
+ # Read raw data from file
+ data = self.load_citation_file(citation_file_path)
+
+ # Load metadata from citation file and serialize it
+ return self.load_citation_metadata(data)
+ except ValidationError as e:
+ # Wrap the error into CustomVCSReleaseNoRetryError() so it can be handled upstream.
+ # This also ensures the release isn't retried without user action.
+ raise CustomVCSReleaseNoRetryError(message=e.messages)
+
+ @property
+ def extra_metadata(self):
+ """Get extra metadata for the release."""
+ return self.load_extra_metadata()
+
+ def load_extra_metadata(self):
+ """Get extra metadata for the release."""
+ return {}
+
+ def load_citation_file(self, citation_file_name):
+ """Returns the citation file data."""
+ if not citation_file_name:
+ return {}
+
+ # Fetch the citation file and load it
+ content = self.rdm_release.provider.retrieve_remote_file(
+ self.rdm_release.generic_repo.id,
+ self.rdm_release.generic_release.tag_name,
+ citation_file_name,
+ )
+
+ data = yaml.safe_load(content.decode("utf-8")) if content is not None else None
+
+ return data
+
+ def load_citation_metadata(self, citation_data):
+ """Get the metadata file."""
+ if not citation_data:
+ return {}
+
+ citation_schema = current_app.config.get("VCS_CITATION_METADATA_SCHEMA")
+
+ assert issubclass(citation_schema, Schema), _(
+ "Citation schema is needed to load citation metadata."
+ )
+
+ data = citation_schema().load(citation_data)
+
+ return data
diff --git a/invenio_rdm_records/services/vcs/release.py b/invenio_rdm_records/services/vcs/release.py
new file mode 100644
index 0000000000..cdcdeeed4f
--- /dev/null
+++ b/invenio_rdm_records/services/vcs/release.py
@@ -0,0 +1,424 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2023-2025 CERN.
+# Copyright (C) 2024 KTH Royal Institute of Technology.
+#
+# Invenio-RDM-Records is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+"""VCS release API implementation."""
+
+from flask import current_app
+from invenio_access.permissions import authenticated_user, system_identity
+from invenio_access.utils import get_identity
+from invenio_db import db
+from invenio_drafts_resources.resources.records.errors import DraftNotCreatedError
+from invenio_i18n import lazy_gettext as _
+from invenio_notifications.services.uow import NotificationOp
+from invenio_records_resources.services.uow import UnitOfWork
+from invenio_vcs.api import VCSRelease
+from invenio_vcs.errors import CustomVCSReleaseNoRetryError
+from invenio_vcs.models import ReleaseStatus
+
+from invenio_rdm_records.notifications.vcs import (
+ RepositoryReleaseCommunityRequiredNotificationBuilder,
+ RepositoryReleaseCommunitySubmittedNotificationBuilder,
+ RepositoryReleaseFailureNotificationBuilder,
+ RepositoryReleaseSuccessNotificationBuilder,
+)
+from invenio_rdm_records.requests.community_submission import CommunitySubmission
+
+from ...proxies import current_rdm_records_service
+from ...resources.serializers.ui import UIJSONSerializer
+from ..errors import CommunityRequiredError, RecordDeletedException
+from .metadata import RDMReleaseMetadata
+from .utils import retrieve_recid_by_uuid
+
+
+def _get_user_identity(user):
+ """Get user identity."""
+ identity = get_identity(user)
+ identity.provides.add(authenticated_user)
+ return identity
+
+
+def _format_error_message(ex):
+ """Format an exception into a user-readable message."""
+ if hasattr(ex, "message"):
+ # Some errors have a 'message' attribute or a 'description' attribute, which is not the value that gets used
+ # when the error is stringified.
+ # Need to stringify the LazyString, otherwise serialisation will fail
+ return str(ex.message)
+ elif hasattr(ex, "description"):
+ return str(ex.description)
+ elif str(ex) != "":
+ return str(ex)
+ else:
+ # Some errors might not have any accessible message, so we use the class name as a last resort.
+ return type(ex).__name__
+
+
+class RDMVCSRelease(VCSRelease):
+ """Implement release API instance for RDM."""
+
+ metadata_cls = RDMReleaseMetadata
+
+ @property
+ def metadata(self):
+ """Extracts metadata to create an RDM draft."""
+ metadata = self.metadata_cls(self)
+ output = metadata.default_metadata
+ output.update(metadata.extra_metadata)
+ output.update(metadata.citation_metadata)
+
+ if not output.get("creators"):
+ # Get owner from Github API
+ owner = self.get_owner()
+ if owner:
+ output.update({"creators": [owner]})
+
+ # Default to "Unkwnown"
+ if not output.get("creators"):
+ output.update(
+ {
+ "creators": [
+ {
+ "person_or_org": {
+ "type": "personal",
+ "family_name": _("Unknown"),
+ },
+ }
+ ]
+ }
+ )
+
+ # Add license if not yet added and available from the repo.
+ if not output.get("rights") and metadata.repo_license:
+ output.update({"rights": [{"id": metadata.repo_license.lower()}]})
+ return output
+
+ def get_custom_fields(self):
+ """Get custom fields."""
+ ret = {}
+ repo_url = self.provider.factory.url_for_repository(self.generic_repo.full_name)
+ ret["code:codeRepository"] = repo_url
+ return ret
+
+ def get_owner(self):
+ """Retrieves repository owner and its affiliation, if any."""
+ # `owner.name` is not required, `owner.login` is.
+ output = None
+ if self.owner:
+ name = getattr(self.owner, "name", self.owner.login)
+ company = getattr(self.owner, "company", None)
+ output = {"person_or_org": {"type": "personal", "family_name": name}}
+ if company:
+ output.update({"affiliations": [{"name": company}]})
+ return output
+
+ def resolve_record(self):
+ """Resolves an RDM record from a release."""
+ if not self.db_release.record_id:
+ return None
+ recid = retrieve_recid_by_uuid(self.db_release.record_id)
+ try:
+ if self.db_release.record_is_draft == True:
+ return current_rdm_records_service.read_draft(
+ system_identity, recid.pid_value
+ )
+ else:
+ return current_rdm_records_service.read(
+ system_identity, recid.pid_value
+ )
+ except RecordDeletedException:
+ return None
+ except DraftNotCreatedError:
+ # This error mostly occurs when we tried to read a draft but the record was published.
+ # It can happen when the VCSComponent for handling the record publish fails to run for
+ # whatever reason. To ensure we can recover and keep the DB consistent, we will update
+ # the release here.
+ published_record = current_rdm_records_service.read(
+ system_identity, recid.pid_value
+ )
+ self.db_release.record_is_draft = False
+ self.release_published()
+ db.session.commit()
+ return published_record
+
+ def _upload_files_to_draft(self, identity, draft, uow):
+ """Upload files to draft."""
+ # Validate the release files are fetchable before initialising the draft files.
+ self.resolve_zipball_url()
+
+ draft_file_service = current_rdm_records_service.draft_files
+
+ draft_file_service.init_files(
+ identity,
+ draft.id,
+ data=[{"key": self.release_file_name}],
+ uow=uow,
+ )
+
+ with self.fetch_zipball_file() as file_stream:
+ draft_file_service.set_file_content(
+ identity,
+ draft.id,
+ self.release_file_name,
+ file_stream,
+ uow=uow,
+ )
+
+ def publish(self):
+ """Publish VCS release as record.
+
+ Drafts and records are created using the current records service.
+ The following steps are run inside a single transaction:
+
+ - Check if a published record corresponding to a successful release exists.
+ - If so, create a new version draft with the same parent. Otherwise, create a new parent/draft.
+ - The draft's ownership is set to the user's id via its parent.
+ - Upload files to the draft.
+ - Publish the draft.
+
+ In case of failure, the transaction is rolled back and the release status set to 'FAILED'
+
+
+ :raises ex: any exception generated by the records service (e.g. invalid metadata)
+ """
+ draft_file_service = current_rdm_records_service.draft_files
+ draft = None
+
+ try:
+ with UnitOfWork(db.session) as uow:
+ data = {
+ "metadata": self.metadata,
+ "access": {"record": "public", "files": "public"},
+ "files": {"enabled": True},
+ "custom_fields": self.get_custom_fields(),
+ }
+ if self.is_first_release():
+ # For the first release, use the repo's owner identity.
+ identity = self.user_identity
+ draft = current_rdm_records_service.create(identity, data, uow=uow)
+ self._upload_files_to_draft(identity, draft, uow)
+
+ if self.db_repo.record_community_id is not None:
+ # Create a review request for the repo's configured community ID if any
+ # If RDM_COMMUNITY_REQUIRED_TO_PUBLISH is true and no ID is provided, the publish will fail
+ # and the user will be sent a notification to manually assign a community.
+ current_rdm_records_service.review.create(
+ identity,
+ data={
+ "receiver": {
+ "community": self.db_repo.record_community_id
+ },
+ "type": CommunitySubmission.type_id,
+ },
+ record=draft._record,
+ uow=uow,
+ )
+ else:
+ # Retrieve latest record id and its recid
+ latest_release = self.db_repo.latest_release()
+ assert latest_release is not None
+ latest_record_uuid = latest_release.record_id
+
+ recid = retrieve_recid_by_uuid(latest_record_uuid)
+
+ # Use the previous record's owner as the new version owner
+ last_record = current_rdm_records_service.read(
+ system_identity, recid.pid_value, include_deleted=True
+ )
+ owner = last_record._record.parent.access.owner.resolve()
+
+ identity = _get_user_identity(owner)
+
+ # Create a new version and update its contents
+ new_version_draft = current_rdm_records_service.new_version(
+ identity, recid.pid_value, uow=uow
+ )
+
+ self._upload_files_to_draft(identity, new_version_draft, uow)
+
+ draft = current_rdm_records_service.update_draft(
+ identity, new_version_draft.id, data, uow=uow
+ )
+
+ draft_file_service.commit_file(
+ identity, draft.id, self.release_file_name, uow=uow
+ )
+
+ # UOW must be committed manually since we're not using the decorator
+ uow.commit()
+ except Exception as ex:
+ # Flag release as FAILED and raise the exception
+ self.release_failed()
+
+ with UnitOfWork(db.session) as uow:
+ # Send a notification of the failed draft save.
+ # This almost always will be because of a problem with the repository's contents or
+ # metadata, and so the user needs to change something and then publish a new release.
+ notification = RepositoryReleaseFailureNotificationBuilder.build(
+ provider=self.provider.factory.id,
+ generic_repository=self.generic_repo,
+ generic_release=self.generic_release,
+ error_message=_format_error_message(ex),
+ )
+ uow.register(NotificationOp(notification))
+ uow.commit()
+
+ # Commit the FAILED state, other changes were already rollbacked by the UOW
+ db.session.commit()
+ raise ex
+
+ # We try to publish the draft in a separate try/except. We want to save the draft even
+ # if the publish fails, but we want to notify the user.
+
+ try:
+ with UnitOfWork(db.session) as uow:
+ if draft._record.parent.review is None:
+ record = current_rdm_records_service.publish(
+ identity, draft.id, uow=uow
+ )
+ # Update release weak reference and set status to PUBLISHED
+ self.db_release.record_id = record._record.model.id
+ self.db_release.record_is_draft = False
+ self.release_published()
+
+ uow.register(
+ NotificationOp(
+ RepositoryReleaseSuccessNotificationBuilder.build(
+ provider=self.provider.factory.id,
+ generic_repository=self.generic_repo,
+ generic_release=self.generic_release,
+ record=record,
+ )
+ )
+ )
+ else:
+ review_request = current_rdm_records_service.review.submit(
+ identity, draft.id, uow=uow
+ )
+
+ self.db_release.record_id = draft._record.model.id
+ self.db_release.record_is_draft = True
+ self.release_pending()
+
+ uow.register(
+ NotificationOp(
+ RepositoryReleaseCommunitySubmittedNotificationBuilder.build(
+ provider=self.provider.factory.id,
+ generic_repository=self.generic_repo,
+ generic_release=self.generic_release,
+ request=review_request._record,
+ community=review_request._record.receiver.resolve(),
+ )
+ )
+ )
+
+ # UOW must be committed manually since we're not using the decorator
+ uow.commit()
+ return None
+ except Exception as ex:
+ # Flag release as FAILED and raise the exception
+ self.release_failed()
+
+ # Store the ID of the draft so we make sure to add a version instead of a whole new record for future releases.
+ self.db_release.record_id = draft._record.model.id
+ self.db_release.record_is_draft = True
+
+ # The release publish can fail for a wide range of reasons, each of which have various inconsistent error types.
+ error_message = _format_error_message(ex)
+
+ if isinstance(ex, CommunityRequiredError):
+ # Use a special case notification for record's without a community (on mandatory-community instances).
+ # This message phrases the error as a "step" the user has to take rather than something they did wrong.
+ notification = (
+ RepositoryReleaseCommunityRequiredNotificationBuilder.build(
+ provider=self.provider.factory.id,
+ generic_repository=self.generic_repo,
+ generic_release=self.generic_release,
+ draft=draft,
+ )
+ )
+ else:
+ notification = RepositoryReleaseFailureNotificationBuilder.build(
+ provider=self.provider.factory.id,
+ generic_repository=self.generic_repo,
+ generic_release=self.generic_release,
+ draft=draft,
+ error_message=error_message,
+ )
+
+ with UnitOfWork(db.session) as uow:
+ uow.register(NotificationOp(notification))
+ uow.commit()
+
+ # Commit the FAILED state, other changes were already rollbacked by the UOW
+ db.session.commit()
+
+ # Wrap the error to ensure Celery does not attempt to retry it (since user action is needed to resolve the problem)
+ raise CustomVCSReleaseNoRetryError(message=error_message)
+
+ def process_release(self):
+ """Processes a VCS release.
+
+ The release might be first validated, in terms of sender, and then published.
+
+ :raises ex: any exception generated by the records service when creating a draft or publishing the release record.
+ """
+ try:
+ record = self.publish()
+ return record
+ except Exception as ex:
+ message = (
+ f"Error while processing VCS release {self.db_release.id}: {str(ex)}"
+ )
+
+ # A CustomVCSReleaseNoRetryError implies that the release failed due to a user error. Therefore, we should not
+ # log this as an exception. This error will be caught upstream by InvenioVCS.
+ if isinstance(ex, CustomVCSReleaseNoRetryError):
+ current_app.logger.info(message)
+ else:
+ current_app.logger.exception(message)
+
+ raise ex
+
+ def serialize_record(self):
+ """Serializes an RDM record."""
+ return UIJSONSerializer().serialize_object(self.record.data)
+
+ @property
+ def record_url(self):
+ """Release self url points to RDM record.
+
+ It points to DataCite URL if the integration is enabled, otherwise it points to the HTML URL.
+ """
+ if self.record is None:
+ return None
+ html_url = self.record.data["links"]["self_html"]
+ doi_url = self.record.data["links"].get("doi")
+ return doi_url or html_url
+
+ @property
+ def badge_title(self):
+ """Returns the badge title."""
+ if current_app.config.get("DATACITE_ENABLED"):
+ return "DOI"
+
+ @property
+ def badge_value(self):
+ """Returns the badge value."""
+ if current_app.config.get("DATACITE_ENABLED"):
+ return self.record.data.get("pids", {}).get("doi", {}).get("identifier")
+
+ def release_published(self):
+ """Mark a release as published."""
+ self.db_release.status = ReleaseStatus.PUBLISHED
+
+ def release_failed(self):
+ """Mark a release as failed."""
+ self.db_release.status = ReleaseStatus.FAILED
+
+ def release_pending(self):
+ """Mark a release as pending (waiting for user action before publishing)."""
+ self.db_release.status = ReleaseStatus.PUBLISH_PENDING
diff --git a/invenio_rdm_records/services/vcs/utils.py b/invenio_rdm_records/services/vcs/utils.py
new file mode 100644
index 0000000000..3d84e45073
--- /dev/null
+++ b/invenio_rdm_records/services/vcs/utils.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2023 CERN.
+#
+# Invenio-RDM-Records is free software; you can redistribute it and/or modify
+# it under the terms of the MIT License; see LICENSE file for more details.
+"""Utility functions."""
+
+from invenio_pidstore.models import PersistentIdentifier
+
+
+def retrieve_recid_by_uuid(rec_uuid):
+ """Retrieves a persistent identifier given its objects uuid.
+
+ Helper function.
+ """
+ recid = PersistentIdentifier.get_by_object(
+ pid_type="recid",
+ object_uuid=rec_uuid,
+ object_type="rec",
+ )
+ return recid
diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-required.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-required.jinja
new file mode 100644
index 0000000000..b4280efd82
--- /dev/null
+++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-required.jinja
@@ -0,0 +1,52 @@
+{% set repository_full_name = notification.context.repository_full_name %}
+{% set release_tag = notification.context.release_tag %}
+{% set draft = notification.context.draft %}
+
+{% set account_settings_link = invenio_url_for("invenio_notifications_settings.index") %}
+{% set draft_link = draft["links"]["self_html"] %}
+
+{%- block subject -%}
+ {{ _("Finish publishing your release from %(repository_name)s", repository_name=repository_full_name) }}
+{%- endblock subject -%}
+
+{%- block html_body -%}
+
+
+ | {{ _("Release %(release_tag)s of %(repository_name)s is not yet published.", release_tag=release_tag, repository_name=repository_full_name )}}
+ |
+
+
+ | {{ _("To continue, you need to specify a community to submit the record to. This only needs to be done for your repository's first release.") }} |
+
+
+ | {{ _("To do so,") }} {{ _("view the unpublished draft record.") }} |
+
+
+ | _ |
+
+
+ | {{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}. |
+
+
+{%- endblock html_body -%}
+
+{%- block plain_body -%}
+{{ _("Release %(release_tag)s of %(repository_name)s is not yet published.", release_tag=release_tag, repository_name=repository_full_name) }}
+
+{{ _("To continue, you need to specify a community to submit the record to. This only needs to be done for your repository's first release.") }}
+
+{{ _("To do so,") }} [{{ _("view the unpublished draft record.") }}]({{ draft_link }})
+
+{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }}
+{%- endblock plain_body -%}
+
+{# Markdown for Slack/Mattermost/chat #}
+{%- block md_body -%}
+{{ _("Release %(release_tag)s of %(repository_name)s is not yet published.", release_tag=release_tag, repository_name=repository_full_name )}}
+
+{{ _("To continue, you need to specify a community to submit the record to. This only needs to be done for your repository's first release.") }}
+
+{{ _("To do so,") }} [{{ _("view the unpublished draft record.") }}]({{ draft_link }})
+
+{{ _("This is an auto-generated message. To manage notifications, visit your account settings")}}
+{%- endblock md_body -%}
diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-submitted.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-submitted.jinja
new file mode 100644
index 0000000000..edf29cf329
--- /dev/null
+++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.community-submitted.jinja
@@ -0,0 +1,52 @@
+{% set repository_full_name = notification.context.repository_full_name %}
+{% set release_tag = notification.context.release_tag %}
+{% set community_title = notification.context.community["metadata"]["title"] %}
+{% set request_link = notification.context.request["links"]["self_html"] %}
+
+{% set account_settings_link = invenio_url_for("invenio_notifications_settings.index") %}
+
+{%- block subject -%}
+ {{ _("Release from %(repository_name)s submitted to %(community_title)s", repository_name=repository_full_name, community_title=community_title) }}
+{%- endblock subject -%}
+
+{%- block html_body -%}
+
+
+ | {{ _("Release %(release_tag)s of %(repository_name)s has been submitted for review to %(community_title)s.", release_tag=release_tag, repository_name=repository_full_name, community_title=community_title )}}
+ |
+
+
+ | {{ _("No further action is needed right now. This only needs to be done for your repository's first release.") }} |
+
+
+ | {{ _("View the review request") }} {{ _("for more details.") }} |
+
+
+ | _ |
+
+
+ | {{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}. |
+
+
+{%- endblock html_body -%}
+
+{%- block plain_body -%}
+{{ _("Release %(release_tag)s of %(repository_name)s has been submitted for review to %(community_title)s.", release_tag=release_tag, repository_name=repository_full_name, community_title=community_title) }}
+
+{{ _("No further action is needed right now. This only needs to be done for your repository's first release.") }}
+
+[{{ _("View the review request") }}]({{ request_link }}) {{ _("for more details.") }}
+
+{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }}
+{%- endblock plain_body -%}
+
+{# Markdown for Slack/Mattermost/chat #}
+{%- block md_body -%}
+{{ _("Release %(release_tag)s of %(repository_name)s has been submitted for review to %(community_title)s.", release_tag=release_tag, repository_name=repository_full_name, community_title=community_title) }}
+
+{{ _("No further action is needed right now. This only needs to be done for your repository's first release.") }}
+
+[{{ _("View the review request") }}]({{ request_link }}) {{ _("for more details.") }}
+
+{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }}
+{%- endblock md_body -%}
diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.failure.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.failure.jinja
new file mode 100644
index 0000000000..b0fb05b74d
--- /dev/null
+++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.failure.jinja
@@ -0,0 +1,65 @@
+{% set repository_full_name = notification.context.repository_full_name %}
+{% set release_tag = notification.context.release_tag %}
+{% set draft = notification.context.draft %}
+{% set error_message = notification.context.error_message %}
+
+{% set account_settings_link = invenio_url_for("invenio_notifications_settings.index") %}
+{% set draft_link = none if draft is none else draft["links"]["self_html"] %}
+
+{%- block subject -%}
+ {{ _("There was a problem publishing a release from %(repository_name)s", repository_name=repository_full_name) }}
+{%- endblock subject -%}
+
+{%- block html_body -%}
+
+
+ | {{ _("Release %(release_tag)s of %(repository_name)s failed to publish.", release_tag=release_tag, repository_name=repository_full_name )}}
+ |
+
+
+ | {{ _("Reason:") }} {{ error_message }} |
+
+
+ {% if draft is none %}
+ | {{ _("Please fix any errors and then create a new release to retry publishing.") }} |
+ {% else %}
+ {{ _("For more details and to fix any errors,") }} {{ _("view the unpublished draft record.") }} |
+ {% endif %}
+
+
+ | _ |
+
+
+ | {{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}. |
+
+
+{%- endblock html_body -%}
+
+{%- block plain_body -%}
+{{ _("Release %(release_tag)s of %(repository_name)s failed to publish.", release_tag=release_tag, repository_name=repository_full_name) }}
+
+{{ _("Reason:") }} {{error_message}}
+
+{% if draft is none %}
+ {{ _("Please fix any errors and then create a new release to retry publishing.") }}
+{% else %}
+ {{ _("For more details and to fix any errors,") }} [{{ _("view the unpublished draft record.") }}]({{ draft_link }})
+{% endif %}
+
+{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }}
+{%- endblock plain_body -%}
+
+{# Markdown for Slack/Mattermost/chat #}
+{%- block md_body -%}
+{{ _("Release %(release_tag)s of %(repository_name)s failed to publish.", release_tag=release_tag, repository_name=repository_full_name )}}
+
+{{ _("Reason:") }} {{error_message}}
+
+{% if draft is none %}
+ {{ _("Please fix any errors and then create a new release to retry publishing.") }}
+{% else %}
+ {{ _("For more details and to fix any errors,") }} [{{ _("view the unpublished draft record.") }}]({{ draft_link }})
+{% endif %}
+
+{{ _("This is an auto-generated message. To manage notifications, visit your account settings")}}
+{%- endblock md_body -%}
diff --git a/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.success.jinja b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.success.jinja
new file mode 100644
index 0000000000..04ef4383ac
--- /dev/null
+++ b/invenio_rdm_records/templates/semantic-ui/invenio_notifications/repository-release.success.jinja
@@ -0,0 +1,45 @@
+{% set repository_full_name = notification.context.repository_full_name %}
+{% set release_tag = notification.context.release_tag %}
+{% set record = notification.context.record %}
+
+{% set account_settings_link = invenio_url_for("invenio_notifications_settings.index") %}
+{% set record_link = record["links"]["self_html"] %}
+
+{%- block subject -%}
+ {{ _("A release from %(repository_name)s was published successfully", repository_name=repository_full_name) }}
+{%- endblock subject -%}
+
+{%- block html_body -%}
+
+
+ | {{ _("Release %(release_tag)s of %(repository_name)s has been received and published successfully.", release_tag=release_tag, repository_name=repository_full_name) }}
+ |
+
+
+ | {{ _("For more details,")}} {{ _("view the published record.") }} |
+
+
+ | _ |
+
+
+ | {{ _("This is an auto-generated message. To manage notifications, visit your") }} {{ _("account settings") }}. |
+
+
+{%- endblock html_body -%}
+
+{%- block plain_body -%}
+{{ _("Release %(release_tag)s of %(repository_name)s has been received and published successfully.", release_tag=release_tag, repository_name=repository_full_name) }}
+
+{{ _("For more details,") }} [{{ _("view the published record.") }}]({{ record_link }})
+
+{{ _("This is an auto-generated message. To manage notifications, visit your account settings") }}
+{%- endblock plain_body -%}
+
+{# Markdown for Slack/Mattermost/chat #}
+{%- block md_body -%}
+{{ _("Release %(release_tag)s of %(repository_name)s has been received and published successfully.", release_tag=release_tag, repository_name=repository_full_name) }}
+
+{{ _("For more details,") }} [{{ _("view the published record.") }}]({{ record_link }})
+
+{{ _("This is an auto-generated message. To manage notifications, visit your account settings")}}
+{%- endblock md_body -%}
diff --git a/setup.cfg b/setup.cfg
index 805df8d6cb..60d7b1ca43 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -43,7 +43,6 @@ install_requires =
invenio-communities>=25.0.0,<26.0.0
invenio-drafts-resources>=8.0.0,<9.0.0
invenio-records-resources>=9.0.0,<10.0.0
- invenio-github>=5.0.0,<6.0.0
invenio-i18n>=3.0.0,<4.0.0
invenio-jobs>=8.0.0,<9.0.0
invenio-oaiserver>=4.0.0,<5.0.0
@@ -66,6 +65,11 @@ tests =
pytest-mock>=1.6.0
sphinx>=4.5.0
tripoli>=2.0.0
+ # invenio-vcs and invenio-github are included for unit tests and local development but are not
+ # otherwise required. They can optionally be depended on at the instance level.
+ # TODO: replace when we publish the module
+ invenio-vcs @ git+https://github.com/inveniosoftware/invenio-vcs@master
+ invenio-github>=5.0.0,<6.0.0
elasticsearch7 =
invenio-search[elasticsearch7]>=3.0.0,<4.0.0
opensearch1 =