Skip to main content

monochange_github/
lib.rs

1#![forbid(clippy::indexing_slicing)]
2
3//! # `monochange_github`
4//!
5//! `monochange_github` turns `monochange` release manifests into GitHub automation requests.
6//!
7//! Reach for this crate when you want to preview or publish GitHub releases and release pull requests using the same structured release data that powers changelog files and release manifests.
8//!
9//! ## Why use it?
10//!
11//! - derive GitHub release payloads and release-PR bodies from `monochange`'s structured release manifest
12//! - keep GitHub automation aligned with changelog rendering and release targets
13//! - reuse one publishing path for dry-run previews and real repository updates
14//!
15//! ## Best for
16//!
17//! - building GitHub release automation on top of `monochange release`
18//! - previewing would-be GitHub releases and release PRs in CI before publishing
19//! - converting grouped or package release targets into repository automation payloads
20//!
21//! ## Public entry points
22//!
23//! - `build_release_requests(config, manifest)` converts a release manifest into GitHub release requests
24//! - `publish_release_requests(requests)` publishes requests through the GitHub API via `octocrab`
25//! - `build_release_pull_request_request(config, manifest)` converts a release manifest into a GitHub release-PR request
26//! - `publish_release_pull_request(root, request, tracked_paths)` creates or updates a release PR through `git` and the GitHub API
27//!
28//! ## Example
29//!
30//! ```rust
31//! use monochange_core::ProviderMergeRequestSettings;
32//! use monochange_core::ProviderReleaseSettings;
33//! use monochange_core::SourceConfiguration;
34//! use monochange_core::SourceProvider;
35//! use monochange_core::ReleaseManifest;
36//! use monochange_core::ReleaseManifestPlan;
37//! use monochange_core::ReleaseManifestTarget;
38//! use monochange_core::ReleaseOwnerKind;
39//! use monochange_core::VersionFormat;
40//! use monochange_github::build_release_requests;
41//!
42//! let manifest = ReleaseManifest {
43//!     command: "release".to_string(),
44//!     dry_run: true,
45//!     version: Some("1.2.0".to_string()),
46//!     group_version: Some("1.2.0".to_string()),
47//!     release_targets: vec![ReleaseManifestTarget {
48//!         id: "sdk".to_string(),
49//!         kind: ReleaseOwnerKind::Group,
50//!         version: "1.2.0".to_string(),
51//!         tag: true,
52//!         release: true,
53//!         version_format: VersionFormat::Primary,
54//!         tag_name: "v1.2.0".to_string(),
55//!         members: vec!["core".to_string(), "app".to_string()],
56//!         rendered_title: "1.2.0 (2026-04-06)".to_string(),
57//!         rendered_changelog_title: "[1.2.0](https://example.com) (2026-04-06)".to_string(),
58//!     }],
59//!     released_packages: vec!["workflow-core".to_string(), "workflow-app".to_string()],
60//!     package_publications: Vec::new(),
61//!     changed_files: Vec::new(),
62//!     changesets: Vec::new(),
63//!     changelogs: Vec::new(),
64//!     deleted_changesets: Vec::new(),
65//!     plan: ReleaseManifestPlan {
66//!         workspace_root: std::path::PathBuf::from("."),
67//!         decisions: Vec::new(),
68//!         groups: Vec::new(),
69//!         warnings: Vec::new(),
70//!         unresolved_items: Vec::new(),
71//!         compatibility_evidence: Vec::new(),
72//!     },
73//! };
74//! let github = SourceConfiguration {
75//!     provider: SourceProvider::GitHub,
76//!     owner: "monochange".to_string(),
77//!     repo: "monochange".to_string(),
78//!     host: None,
79//!     api_url: None,
80//!     releases: ProviderReleaseSettings::default(),
81//!     pull_requests: ProviderMergeRequestSettings::default(),
82//! };
83//!
84//! let requests = build_release_requests(&github, &manifest);
85//!
86//! assert_eq!(requests.len(), 1);
87//! assert_eq!(requests[0].tag_name, "v1.2.0");
88//! assert_eq!(requests[0].repository, "monochange/monochange");
89//! ```
90use std::env;
91use std::fmt::Write as _;
92use std::path::Path;
93use std::path::PathBuf;
94use std::sync::OnceLock;
95
96use monochange_core::CommitMessage;
97use monochange_core::DiscoveryPathFilter;
98use monochange_core::HostedActorRef;
99use monochange_core::HostedActorSourceKind;
100use monochange_core::HostedIssueCommentOperation;
101use monochange_core::HostedIssueCommentOutcome;
102use monochange_core::HostedIssueCommentPlan;
103use monochange_core::HostedIssueRef;
104use monochange_core::HostedIssueRelationshipKind;
105use monochange_core::HostedReviewRequestKind;
106use monochange_core::HostedReviewRequestRef;
107use monochange_core::HostedSourceAdapter;
108use monochange_core::HostedSourceFeatures;
109use monochange_core::HostingCapabilities;
110use monochange_core::HostingProviderKind;
111use monochange_core::MonochangeError;
112use monochange_core::MonochangeResult;
113use monochange_core::PreparedChangeset;
114use monochange_core::ProviderReleaseNotesSource;
115use monochange_core::ReleaseManifest;
116use monochange_core::ReleaseManifestChangelog;
117use monochange_core::ReleaseManifestTarget;
118use monochange_core::ReleaseOwnerKind;
119use monochange_core::RetargetOperation;
120use monochange_core::RetargetProviderOperation;
121use monochange_core::RetargetProviderResult;
122use monochange_core::RetargetTagResult;
123use monochange_core::SourceCapabilities;
124use monochange_core::SourceChangeRequest;
125use monochange_core::SourceChangeRequestOperation;
126use monochange_core::SourceChangeRequestOutcome;
127use monochange_core::SourceConfiguration;
128use monochange_core::SourceProvider;
129use monochange_core::SourceReleaseOperation;
130use monochange_core::SourceReleaseOutcome;
131use monochange_core::SourceReleaseRequest;
132use monochange_core::git::git_checkout_branch_command;
133use monochange_core::git::git_command_output;
134use monochange_core::git::git_current_branch;
135use monochange_core::git::git_error_detail;
136use monochange_core::git::git_head_commit;
137use monochange_core::git::git_push_branch_command;
138use monochange_core::git::git_stage_all_command;
139use monochange_core::git::git_stage_paths_command;
140use monochange_core::git::run_command;
141use monochange_core::git::run_git_commit_message;
142use monochange_hosting::ensure_rustls_provider;
143use octocrab::Octocrab;
144use regex::Regex;
145use serde::Deserialize;
146use serde::Serialize;
147use serde::de::DeserializeOwned;
148use serde_json::json;
149use urlencoding::encode;
150
151pub type GitHubReleaseRequest = SourceReleaseRequest;
152pub type GitHubReleaseOperation = SourceReleaseOperation;
153pub type GitHubReleaseOutcome = SourceReleaseOutcome;
154pub type GitHubPullRequestRequest = SourceChangeRequest;
155pub type GitHubPullRequestOperation = SourceChangeRequestOperation;
156pub type GitHubPullRequestOutcome = SourceChangeRequestOutcome;
157
158type GitHubVerifiedCommitAttempt = Result<String, String>;
159
160/// Return the hosted-source capabilities supported by the GitHub provider.
161#[must_use]
162pub const fn source_capabilities() -> SourceCapabilities {
163	SourceCapabilities {
164		draft_releases: true,
165		prereleases: true,
166		generated_release_notes: true,
167		auto_merge_change_requests: true,
168		released_issue_comments: true,
169		requires_host: false,
170	}
171}
172
173/// Validate that a source configuration is compatible with the GitHub provider.
174#[must_use = "the validation result must be checked"]
175pub fn validate_source_configuration(source: &SourceConfiguration) -> MonochangeResult<()> {
176	if source.releases.generate_notes
177		&& matches!(
178			source.releases.source,
179			ProviderReleaseNotesSource::Monochange
180		) {
181		return Err(MonochangeError::Config(
182			"[source.releases].generate_notes cannot be true when `source = \"monochange\"`; choose one release-note source"
183				.to_string(),
184		));
185	}
186
187	Ok(())
188}
189
190/// Shared issue-comment planning type for GitHub issue release comments.
191pub type GitHubIssueCommentPlan = HostedIssueCommentPlan;
192/// Shared issue-comment operation type for GitHub issue release comments.
193pub type GitHubIssueCommentOperation = HostedIssueCommentOperation;
194/// Shared issue-comment outcome type for GitHub issue release comments.
195pub type GitHubIssueCommentOutcome = HostedIssueCommentOutcome;
196
197/// Shared GitHub hosted-source adapter instance used by the workspace.
198pub static HOSTED_SOURCE_ADAPTER: GitHubHostedSourceAdapter = GitHubHostedSourceAdapter;
199
200/// Hosted-source adapter for GitHub repositories.
201pub struct GitHubHostedSourceAdapter;
202
203#[async_trait::async_trait]
204impl HostedSourceAdapter for GitHubHostedSourceAdapter {
205	fn provider(&self) -> SourceProvider {
206		SourceProvider::GitHub
207	}
208
209	fn tag_url(&self, source: &SourceConfiguration, tag_name: &str) -> String {
210		tag_url(source, tag_name)
211	}
212
213	fn compare_url(
214		&self,
215		source: &SourceConfiguration,
216		previous_tag: &str,
217		current_tag: &str,
218	) -> String {
219		compare_url(source, previous_tag, current_tag)
220	}
221
222	fn build_release_requests(
223		&self,
224		source: &SourceConfiguration,
225		manifest: &ReleaseManifest,
226	) -> Vec<SourceReleaseRequest> {
227		build_release_requests(source, manifest)
228	}
229
230	fn build_release_pull_request_request(
231		&self,
232		source: &SourceConfiguration,
233		manifest: &ReleaseManifest,
234	) -> SourceChangeRequest {
235		build_release_pull_request_request(source, manifest)
236	}
237
238	fn features(&self) -> HostedSourceFeatures {
239		HostedSourceFeatures {
240			batched_changeset_context_lookup: true,
241			released_issue_comments: true,
242			release_retarget_sync: true,
243		}
244	}
245
246	fn annotate_changeset_context(
247		&self,
248		source: &SourceConfiguration,
249		changesets: &mut [PreparedChangeset],
250	) {
251		annotate_changeset_context(source, changesets);
252	}
253
254	async fn enrich_changeset_context(
255		&self,
256		source: &SourceConfiguration,
257		changesets: &mut [PreparedChangeset],
258	) {
259		enrich_changeset_context(source, changesets).await;
260	}
261
262	fn plan_released_issue_comments(
263		&self,
264		source: &SourceConfiguration,
265		manifest: &ReleaseManifest,
266	) -> Vec<HostedIssueCommentPlan> {
267		plan_released_issue_comments(source, manifest)
268	}
269
270	async fn comment_released_issues(
271		&self,
272		source: &SourceConfiguration,
273		manifest: &ReleaseManifest,
274	) -> MonochangeResult<Vec<HostedIssueCommentOutcome>> {
275		comment_released_issues(source, manifest).await
276	}
277
278	async fn sync_retargeted_releases(
279		&self,
280		source: &SourceConfiguration,
281		tag_results: &[RetargetTagResult],
282		dry_run: bool,
283	) -> MonochangeResult<Vec<RetargetProviderResult>> {
284		sync_retargeted_releases(source, tag_results, dry_run).await
285	}
286}
287
288#[derive(Debug, Clone, Eq, PartialEq)]
289struct GitHubRelatedReviewRequest {
290	review_request: HostedReviewRequestRef,
291	issues: Vec<HostedIssueRef>,
292}
293
294#[derive(Debug, Serialize)]
295struct GitHubReleasePayload<'a> {
296	tag_name: &'a str,
297	name: &'a str,
298	body: Option<&'a str>,
299	draft: bool,
300	prerelease: bool,
301	generate_release_notes: bool,
302}
303
304#[derive(Debug, Serialize)]
305struct GitHubPullRequestPayload<'a> {
306	title: &'a str,
307	head: &'a str,
308	base: &'a str,
309	body: &'a str,
310	draft: bool,
311}
312
313#[derive(Debug, Serialize)]
314struct GitHubPullRequestUpdatePayload<'a> {
315	title: &'a str,
316	body: &'a str,
317	base: &'a str,
318}
319
320#[derive(Debug, Serialize)]
321struct GitHubLabelsPayload<'a> {
322	labels: &'a [String],
323}
324
325#[derive(Debug, Serialize)]
326struct GitHubCreateCommitPayload {
327	message: String,
328	tree: String,
329	parents: Vec<String>,
330}
331
332#[derive(Debug, Serialize)]
333struct GitHubUpdateRefPayload<'a> {
334	sha: &'a str,
335	force: bool,
336}
337
338#[derive(Debug, Deserialize)]
339struct GitHubGitCommitResponse {
340	sha: String,
341	message: String,
342	tree: GitHubGitCommitTree,
343	parents: Vec<GitHubGitCommitParent>,
344	verification: GitHubGitCommitVerification,
345}
346
347#[derive(Debug, Deserialize)]
348struct GitHubGitCommitTree {
349	sha: String,
350}
351
352#[derive(Debug, Deserialize)]
353struct GitHubGitCommitParent {
354	sha: String,
355}
356
357#[derive(Debug, Deserialize)]
358struct GitHubGitCommitVerification {
359	verified: bool,
360	reason: Option<String>,
361}
362
363#[derive(Debug, Deserialize)]
364struct GitHubGitRefResponse {
365	object: GitHubGitRefObject,
366}
367
368#[derive(Debug, Deserialize)]
369struct GitHubGitRefObject {
370	sha: String,
371}
372
373#[derive(Debug, Serialize)]
374struct GitHubCreateBlobPayload {
375	content: String,
376	encoding: &'static str,
377}
378
379#[derive(Debug, Deserialize)]
380struct GitHubCreateBlobResponse {
381	sha: String,
382}
383
384#[derive(Debug, Serialize)]
385struct GitHubCreateTreePayload<'a> {
386	#[serde(skip_serializing_if = "Option::is_none")]
387	base_tree: Option<&'a str>,
388	tree: Vec<GitHubCreateTreeEntry>,
389}
390
391#[derive(Debug, Serialize)]
392struct GitHubCreateTreeEntry {
393	path: String,
394	mode: &'static str,
395	#[serde(rename = "type")]
396	entry_type: &'static str,
397	#[serde(skip_serializing_if = "Option::is_none")]
398	sha: Option<String>,
399}
400
401#[derive(Debug, Deserialize)]
402struct GitHubCreateTreeResponse {
403	sha: String,
404}
405
406#[derive(Debug, Deserialize)]
407struct GitHubExistingPullRequestLabel {
408	name: String,
409}
410
411#[derive(Debug, Deserialize)]
412struct GitHubExistingPullRequestBase {
413	#[serde(rename = "ref")]
414	ref_name: String,
415}
416
417#[derive(Debug, Deserialize)]
418struct GitHubExistingPullRequestHead {
419	sha: Option<String>,
420}
421
422#[derive(Debug, Deserialize)]
423struct GitHubExistingPullRequest {
424	number: u64,
425	html_url: Option<String>,
426	node_id: String,
427	title: String,
428	body: Option<String>,
429	base: GitHubExistingPullRequestBase,
430	head: GitHubExistingPullRequestHead,
431	#[serde(default)]
432	labels: Vec<GitHubExistingPullRequestLabel>,
433}
434
435#[derive(Debug, Deserialize)]
436struct GitHubExistingRelease {
437	id: u64,
438	html_url: Option<String>,
439	target_commitish: Option<String>,
440}
441
442#[derive(Debug, Deserialize)]
443struct GitHubReleaseResponse {
444	html_url: Option<String>,
445}
446
447#[derive(Debug, Serialize)]
448struct GitHubReleaseRetargetPayload<'a> {
449	target_commitish: &'a str,
450}
451
452#[derive(Debug, Deserialize)]
453struct GitHubPullRequestResponse {
454	number: u64,
455	html_url: Option<String>,
456	node_id: String,
457}
458
459#[derive(Debug, Deserialize)]
460#[serde(rename_all = "camelCase")]
461struct GraphqlEnableAutoMergeResponse {
462	enable_pull_request_auto_merge: Option<GraphqlPullRequestMutation>,
463}
464
465#[derive(Debug, Deserialize)]
466#[serde(rename_all = "camelCase")]
467struct GraphqlPullRequestMutation {
468	pull_request: Option<GraphqlPullRequestNode>,
469}
470
471#[derive(Debug, Deserialize)]
472struct GraphqlPullRequestNode {
473	#[serde(rename = "number")]
474	_number: u64,
475}
476
477#[derive(Debug, Deserialize)]
478struct GitHubIssueCommentResponse {
479	html_url: Option<String>,
480	body: Option<String>,
481}
482
483/// Return the hosting metadata features available from GitHub changeset context.
484#[must_use]
485pub fn github_hosting_capabilities() -> HostingCapabilities {
486	HostingCapabilities {
487		commit_web_urls: true,
488		actor_profiles: true,
489		review_request_lookup: true,
490		related_issues: true,
491		issue_comments: true,
492	}
493}
494
495/// Return the GitHub web base URL for building browser links.
496#[must_use]
497pub fn github_web_base_url() -> String {
498	env::var("GITHUB_SERVER_URL").unwrap_or_else(|_| "https://github.com".to_string())
499}
500
501/// Extract the host name used for rendered GitHub links.
502#[must_use]
503pub fn github_host() -> Option<String> {
504	let base_url = github_web_base_url();
505	let without_scheme = base_url
506		.trim_start_matches("https://")
507		.trim_start_matches("http://");
508	let host = without_scheme.split('/').next().unwrap_or_default().trim();
509	if host.is_empty() {
510		None
511	} else {
512		Some(host.to_string())
513	}
514}
515
516/// Build a web URL for a commit on the configured GitHub repository.
517#[must_use]
518pub fn github_commit_url(source: &SourceConfiguration, sha: &str) -> String {
519	format!(
520		"{}/{}/{}/commit/{}",
521		github_web_base_url().trim_end_matches('/'),
522		source.owner,
523		source.repo,
524		sha
525	)
526}
527
528/// Build a web URL for a pull request on the configured GitHub repository.
529#[must_use]
530pub fn github_pull_request_url(source: &SourceConfiguration, number: u64) -> String {
531	format!(
532		"{}/{}/{}/pull/{}",
533		github_web_base_url().trim_end_matches('/'),
534		source.owner,
535		source.repo,
536		number
537	)
538}
539
540/// Build a web URL for an issue on the configured GitHub repository.
541#[must_use]
542pub fn github_issue_url(source: &SourceConfiguration, number: u64) -> String {
543	format!(
544		"{}/{}/{}/issues/{}",
545		github_web_base_url().trim_end_matches('/'),
546		source.owner,
547		source.repo,
548		number
549	)
550}
551
552/// URL to a specific tag on the GitHub repository.
553#[must_use]
554pub fn tag_url(source: &SourceConfiguration, tag_name: &str) -> String {
555	let base = github_web_base_url();
556	let base = source.host.as_deref().unwrap_or(base.trim_end_matches('/'));
557	format!(
558		"{}/{}/{}/releases/tag/{tag_name}",
559		base.trim_end_matches('/'),
560		source.owner,
561		source.repo
562	)
563}
564
565/// URL comparing two tags on the GitHub repository.
566#[must_use]
567pub fn compare_url(source: &SourceConfiguration, previous_tag: &str, current_tag: &str) -> String {
568	let base = github_web_base_url();
569	let base = source.host.as_deref().unwrap_or(base.trim_end_matches('/'));
570	format!(
571		"{}/{}/{}/compare/{previous_tag}...{current_tag}",
572		base.trim_end_matches('/'),
573		source.owner,
574		source.repo
575	)
576}
577
578fn apply_github_changeset_annotations(
579	source: &SourceConfiguration,
580	changesets: &mut [PreparedChangeset],
581) {
582	let host = github_host();
583	let capabilities = github_hosting_capabilities();
584	for changeset in changesets.iter_mut() {
585		let Some(context) = changeset.context.as_mut() else {
586			continue;
587		};
588		context.provider = HostingProviderKind::GitHub;
589		context.host.clone_from(&host);
590		context.capabilities = capabilities.clone();
591		for revision in [&mut context.introduced, &mut context.last_updated] {
592			let Some(revision) = revision.as_mut() else {
593				continue;
594			};
595			if let Some(commit) = revision.commit.as_mut() {
596				commit.provider = HostingProviderKind::GitHub;
597				commit.host.clone_from(&host);
598				commit.url = Some(github_commit_url(source, &commit.sha));
599			}
600			if let Some(actor) = revision.actor.as_mut() {
601				actor.provider = HostingProviderKind::GitHub;
602				actor.host.clone_from(&host);
603			}
604		}
605	}
606}
607
608/// Apply GitHub URLs and provider metadata without making remote API calls.
609///
610/// Performance note:
611/// `monochange release --dry-run` should stay local and fast. The old path always went
612/// on to look up PRs and related issues for every changeset commit whenever a
613/// GitHub token was present, which turned a local preview into tens of seconds
614/// of serialized network traffic. The dry-run release path now uses this helper
615/// so the changelog context still gets stable GitHub commit links while the
616/// expensive hosted lookups remain reserved for commands that truly need them.
617pub fn annotate_changeset_context(
618	source: &SourceConfiguration,
619	changesets: &mut [PreparedChangeset],
620) {
621	apply_github_changeset_annotations(source, changesets);
622}
623
624/// Enrich changeset context with remote GitHub review-request and issue data.
625#[tracing::instrument(skip_all)]
626pub async fn enrich_changeset_context(
627	source: &SourceConfiguration,
628	changesets: &mut [PreparedChangeset],
629) {
630	apply_github_changeset_annotations(source, changesets);
631
632	let Ok(token) = env::var("GITHUB_TOKEN").or_else(|_| env::var("GH_TOKEN")) else {
633		tracing::debug!("skipping GitHub enrichment: no GITHUB_TOKEN or GH_TOKEN found");
634		return;
635	};
636	let api_base_url = env::var("GITHUB_API_URL").ok();
637	let Ok(client) = build_github_client(&token, api_base_url.as_deref()) else {
638		return;
639	};
640	enrich_changeset_context_with_client(&client, source, changesets).await;
641}
642
643/// Convert releasable targets into provider-specific GitHub release requests.
644#[must_use]
645pub fn build_release_requests(
646	source: &SourceConfiguration,
647	manifest: &ReleaseManifest,
648) -> Vec<GitHubReleaseRequest> {
649	manifest
650		.release_targets
651		.iter()
652		.filter(|target| target.release)
653		.map(|target| {
654			GitHubReleaseRequest {
655				provider: SourceProvider::GitHub,
656				repository: format!("{}/{}", source.owner, source.repo),
657				owner: source.owner.clone(),
658				repo: source.repo.clone(),
659				target_id: target.id.clone(),
660				target_kind: target.kind,
661				tag_name: target.tag_name.clone(),
662				name: if target.rendered_title.is_empty() {
663					target.tag_name.clone()
664				} else {
665					target.rendered_title.clone()
666				},
667				body: release_body(source, manifest, target),
668				draft: source.releases.draft,
669				prerelease: source.releases.prerelease,
670				generate_release_notes: source.releases.generate_notes,
671			}
672		})
673		.collect()
674}
675
676/// Build the release pull request request for the configured GitHub repository.
677#[must_use]
678pub fn build_release_pull_request_request(
679	source: &SourceConfiguration,
680	manifest: &ReleaseManifest,
681) -> GitHubPullRequestRequest {
682	let repository = format!("{}/{}", source.owner, source.repo);
683	let title = source.pull_requests.title.clone();
684	GitHubPullRequestRequest {
685		provider: SourceProvider::GitHub,
686		repository: repository.clone(),
687		owner: source.owner.clone(),
688		repo: source.repo.clone(),
689		base_branch: source.pull_requests.base.clone(),
690		head_branch: release_pull_request_branch(
691			&source.pull_requests.branch_prefix,
692			&manifest.command,
693		),
694		title: title.clone(),
695		body: release_pull_request_body(manifest),
696		labels: source.pull_requests.labels.clone(),
697		auto_merge: source.pull_requests.auto_merge,
698		commit_message: CommitMessage {
699			subject: title,
700			body: None,
701		},
702	}
703}
704
705async fn enrich_changeset_context_with_client(
706	client: &Octocrab,
707	source: &SourceConfiguration,
708	changesets: &mut [PreparedChangeset],
709) {
710	let host = github_host();
711	let capabilities = github_hosting_capabilities();
712	for changeset in changesets.iter_mut() {
713		let Some(context) = changeset.context.as_mut() else {
714			continue;
715		};
716		context.provider = HostingProviderKind::GitHub;
717		context.host.clone_from(&host);
718		context.capabilities = capabilities.clone();
719		for revision in [&mut context.introduced, &mut context.last_updated] {
720			let Some(revision) = revision.as_mut() else {
721				continue;
722			};
723			if let Some(commit) = revision.commit.as_mut() {
724				commit.provider = HostingProviderKind::GitHub;
725				commit.host.clone_from(&host);
726				commit.url = Some(github_commit_url(source, &commit.sha));
727			}
728			if let Some(actor) = revision.actor.as_mut() {
729				actor.provider = HostingProviderKind::GitHub;
730				actor.host.clone_from(&host);
731			}
732		}
733	}
734	let review_request_lookup_shas = collect_review_request_lookup_shas(changesets);
735	let review_requests_by_sha =
736		load_review_requests_for_commits_with_client(client, source, &review_request_lookup_shas)
737			.await
738			.unwrap_or_else(|error| {
739				tracing::warn!(commits = review_request_lookup_shas.len(), %error, "failed to batch load GitHub review requests; continuing with commit annotations only");
740				BTreeMap::new()
741			});
742
743	for changeset in changesets.iter_mut() {
744		let Some(context) = changeset.context.as_mut() else {
745			continue;
746		};
747
748		let mut issues_by_id = BTreeMap::<String, HostedIssueRef>::new();
749
750		for revision in [&mut context.introduced, &mut context.last_updated] {
751			let Some(revision) = revision.as_mut() else {
752				continue;
753			};
754
755			let Some(commit) = revision.commit.as_ref() else {
756				continue;
757			};
758
759			if let Some(related_review_request) = review_requests_by_sha
760				.get(&commit.sha)
761				.and_then(Clone::clone)
762			{
763				for issue in related_review_request.issues {
764					issues_by_id.entry(issue.id.clone()).or_insert(issue);
765				}
766				revision.review_request = Some(related_review_request.review_request.clone());
767				if let Some(author) = related_review_request.review_request.author.clone() {
768					revision.actor = Some(author);
769				}
770			}
771
772			if let Some(actor) = revision.actor.as_mut() {
773				actor.provider = HostingProviderKind::GitHub;
774				actor.host.clone_from(&host);
775			}
776		}
777
778		context.related_issues = issues_by_id.into_values().collect();
779	}
780}
781
782fn collect_review_request_lookup_shas(changesets: &[PreparedChangeset]) -> Vec<String> {
783	let mut shas = changesets
784		.iter()
785		.filter_map(|changeset| changeset.context.as_ref())
786		.flat_map(|context| [&context.introduced, &context.last_updated])
787		.filter_map(|revision| revision.as_ref())
788		.filter_map(|revision| revision.commit.as_ref())
789		.map(|commit| commit.sha.clone())
790		.collect::<Vec<_>>();
791
792	shas.sort();
793	shas.dedup();
794
795	shas
796}
797
798// patch-coverage:ignore-start -- GitHub review-request batching requires live Octocrab API calls; empty-input and parsing paths are covered.
799async fn load_review_requests_for_commits_with_client(
800	client: &Octocrab,
801	source: &SourceConfiguration,
802	shas: &[String],
803) -> MonochangeResult<BTreeMap<String, Option<GitHubRelatedReviewRequest>>> {
804	if shas.is_empty() {
805		return Ok(BTreeMap::new());
806	}
807
808	tracing::info!(
809		commits = shas.len(),
810		requests = 1,
811		"loading GitHub review requests"
812	);
813
814	let review_requests_by_sha =
815		load_review_request_batch_with_client(client, source, shas).await?;
816
817	let review_requests_found = review_requests_by_sha
818		.values()
819		.filter(|review_request| review_request.is_some())
820		.count();
821
822	tracing::debug!(
823		commits = shas.len(),
824		review_requests = review_requests_found,
825		"resolved GitHub review requests"
826	);
827
828	Ok(review_requests_by_sha)
829}
830// patch-coverage:ignore-end
831
832async fn load_review_request_batch_with_client(
833	client: &Octocrab,
834	source: &SourceConfiguration,
835	shas: &[String],
836) -> MonochangeResult<BTreeMap<String, Option<GitHubRelatedReviewRequest>>> {
837	let query = build_review_request_batch_query(&source.owner, &source.repo, shas);
838
839	let response = client
840		.graphql::<serde_json::Value>(&json!({ "query": query }))
841		.await
842		.map_err(|error| {
843			MonochangeError::Config(format!(
844				"failed to batch load GitHub review requests for {} commit(s): {error}",
845				shas.len()
846			))
847		})?;
848
849	let repository = response
850		.get("repository")
851		.or_else(|| response.get("data").and_then(|data| data.get("repository")))
852		.and_then(serde_json::Value::as_object)
853		.ok_or_else(|| {
854			MonochangeError::Config(
855				"GitHub review-request lookup returned no repository payload".to_string(),
856			)
857		})?;
858
859	let mut review_requests_by_sha = BTreeMap::<String, Option<GitHubRelatedReviewRequest>>::new();
860
861	for (index, sha) in shas.iter().enumerate() {
862		let alias = format!("commit_{index}");
863		let review_request = repository
864			.get(&alias)
865			.and_then(|commit| {
866				commit
867					.get("associatedPullRequests")
868					.and_then(|pull_requests| pull_requests.get("nodes"))
869					.and_then(serde_json::Value::as_array)
870					.and_then(|pull_requests| pull_requests.first())
871			})
872			.and_then(|pull_request| parse_review_request_from_graphql(source, pull_request));
873		review_requests_by_sha.insert(sha.clone(), review_request);
874	}
875
876	Ok(review_requests_by_sha)
877}
878
879fn build_review_request_batch_query(owner: &str, repo: &str, shas: &[String]) -> String {
880	let mut query = format!("query {{ repository(owner: \"{owner}\", name: \"{repo}\") {{");
881	for (index, sha) in shas.iter().enumerate() {
882		let alias = format!("commit_{index}");
883		let _ = write!(
884			query,
885			" {alias}: object(expression: \"{sha}\") {{ ... on Commit {{ associatedPullRequests(first: 1) {{ nodes {{ number title url body author {{ login url }} }} }} }} }}"
886		);
887	}
888	query.push_str(" } }");
889	query
890}
891
892fn parse_review_request_from_graphql(
893	source: &SourceConfiguration,
894	pull_request: &serde_json::Value,
895) -> Option<GitHubRelatedReviewRequest> {
896	let number = pull_request.get("number")?.as_u64()?;
897	let title = pull_request
898		.get("title")
899		.and_then(serde_json::Value::as_str)?
900		.to_string();
901	let body = pull_request
902		.get("body")
903		.and_then(serde_json::Value::as_str)
904		.map(str::to_string);
905	let author = pull_request
906		.get("author")
907		.and_then(serde_json::Value::as_object)
908		.map(|author| {
909			HostedActorRef {
910				provider: HostingProviderKind::GitHub,
911				host: github_host(),
912				id: None,
913				login: author
914					.get("login")
915					.and_then(serde_json::Value::as_str)
916					.map(str::to_string),
917				display_name: author
918					.get("login")
919					.and_then(serde_json::Value::as_str)
920					.map(str::to_string),
921				url: author
922					.get("url")
923					.and_then(serde_json::Value::as_str)
924					.map(str::to_string),
925				source: HostedActorSourceKind::ReviewRequestAuthor,
926			}
927		});
928	let review_request = HostedReviewRequestRef {
929		provider: HostingProviderKind::GitHub,
930		host: github_host(),
931		kind: HostedReviewRequestKind::PullRequest,
932		id: format!("#{number}"),
933		title: Some(title),
934		url: pull_request
935			.get("url")
936			.and_then(serde_json::Value::as_str)
937			.map(str::to_string)
938			.or_else(|| Some(github_pull_request_url(source, number))),
939		author,
940	};
941	let mut issues_by_id = BTreeMap::<String, HostedIssueRef>::new();
942	for issue_number in body
943		.as_deref()
944		.map(extract_closing_issue_numbers)
945		.unwrap_or_default()
946	{
947		issues_by_id.insert(
948			format!("#{issue_number}"),
949			HostedIssueRef {
950				provider: HostingProviderKind::GitHub,
951				host: github_host(),
952				id: format!("#{issue_number}"),
953				title: None,
954				url: Some(github_issue_url(source, issue_number)),
955				relationship: HostedIssueRelationshipKind::ClosedByReviewRequest,
956			},
957		);
958	}
959	for issue_number in body
960		.as_deref()
961		.map(extract_issue_numbers)
962		.unwrap_or_default()
963	{
964		issues_by_id
965			.entry(format!("#{issue_number}"))
966			.or_insert_with(|| {
967				HostedIssueRef {
968					provider: HostingProviderKind::GitHub,
969					host: github_host(),
970					id: format!("#{issue_number}"),
971					title: None,
972					url: Some(github_issue_url(source, issue_number)),
973					relationship: HostedIssueRelationshipKind::ReferencedByReviewRequest,
974				}
975			});
976	}
977	Some(GitHubRelatedReviewRequest {
978		review_request,
979		issues: issues_by_id.into_values().collect(),
980	})
981}
982
983fn issue_reference_regex() -> &'static Regex {
984	static ISSUE_REFERENCE_RE: OnceLock<Regex> = OnceLock::new();
985	ISSUE_REFERENCE_RE.get_or_init(|| {
986		Regex::new(r"(?:[\w.-]+/[\w.-]+)?#(?P<number>\d+)")
987			.unwrap_or_else(|error| panic!("issue reference regex should compile: {error}"))
988	})
989}
990
991fn closing_issue_reference_regex() -> &'static Regex {
992	static CLOSING_ISSUE_REFERENCE_RE: OnceLock<Regex> = OnceLock::new();
993	CLOSING_ISSUE_REFERENCE_RE.get_or_init(|| {
994		Regex::new(r"(?i)\b(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\b[:\s]*(?P<refs>(?:[\w.-]+/[\w.-]+)?#\d+(?:\s*(?:,|and)\s*(?:[\w.-]+/[\w.-]+)?#\d+)*)")
995		.unwrap_or_else(|error| panic!("closing issue regex should compile: {error}"))
996	})
997}
998
999fn extract_closing_issue_numbers(text: &str) -> std::collections::BTreeSet<u64> {
1000	let mut issue_numbers = std::collections::BTreeSet::new();
1001	for captures in closing_issue_reference_regex().captures_iter(text) {
1002		let Some(references) = captures.name("refs") else {
1003			continue;
1004		};
1005		issue_numbers.extend(extract_issue_numbers(references.as_str()));
1006	}
1007	issue_numbers
1008}
1009
1010fn extract_issue_numbers(text: &str) -> std::collections::BTreeSet<u64> {
1011	issue_reference_regex()
1012		.captures_iter(text)
1013		.filter_map(|captures| captures.name("number"))
1014		.filter_map(|number| number.as_str().parse::<u64>().ok())
1015		.collect()
1016}
1017
1018/// Plan release comments for issues that are closed by the manifest's review requests.
1019#[must_use]
1020pub fn plan_released_issue_comments(
1021	source: &SourceConfiguration,
1022	manifest: &ReleaseManifest,
1023) -> Vec<GitHubIssueCommentPlan> {
1024	let release_tags = manifest
1025		.release_targets
1026		.iter()
1027		.filter(|target| target.release)
1028		.map(|target| target.tag_name.clone())
1029		.collect::<Vec<_>>();
1030	if release_tags.is_empty() {
1031		return Vec::new();
1032	}
1033	let marker = release_comment_marker(&release_tags);
1034	let body = release_issue_comment_body(&release_tags, &marker);
1035	let mut plans_by_issue = BTreeMap::<String, GitHubIssueCommentPlan>::new();
1036	for issue in manifest
1037		.changesets
1038		.iter()
1039		.filter_map(|changeset| changeset.context.as_ref())
1040		.flat_map(|context| context.related_issues.iter())
1041	{
1042		plans_by_issue.entry(issue.id.clone()).or_insert_with(|| {
1043			GitHubIssueCommentPlan {
1044				repository: format!("{}/{}", source.owner, source.repo),
1045				issue_id: issue.id.clone(),
1046				issue_url: issue.url.clone(),
1047				body: body.clone(),
1048				close: issue.relationship != HostedIssueRelationshipKind::ClosedByReviewRequest,
1049			}
1050		});
1051	}
1052	plans_by_issue.into_values().collect()
1053}
1054
1055/// Create release comments on linked GitHub issues when they have not been posted yet.
1056#[tracing::instrument(skip_all)]
1057#[must_use = "the comment result must be checked"]
1058#[allow(clippy::let_and_return, tail_expr_drop_order)]
1059pub async fn comment_released_issues(
1060	source: &SourceConfiguration,
1061	manifest: &ReleaseManifest,
1062) -> MonochangeResult<Vec<GitHubIssueCommentOutcome>> {
1063	let plans = plan_released_issue_comments(source, manifest);
1064	if plans.is_empty() {
1065		return Ok(Vec::new());
1066	}
1067	let client = github_client_from_env(source)?;
1068	comment_released_issues_with_client(&client, source, &plans).await
1069}
1070
1071async fn comment_released_issues_with_client(
1072	client: &Octocrab,
1073	source: &SourceConfiguration,
1074	plans: &[GitHubIssueCommentPlan],
1075) -> MonochangeResult<Vec<GitHubIssueCommentOutcome>> {
1076	let mut outcomes = Vec::with_capacity(plans.len());
1077	for plan in plans {
1078		let issue_number = plan
1079			.issue_id
1080			.trim_start_matches('#')
1081			.parse::<u64>()
1082			.map_err(|error| {
1083				MonochangeError::Config(format!(
1084					"invalid issue id `{}` for release comment: {error}",
1085					plan.issue_id
1086				))
1087			})?;
1088		let path = format!(
1089			"/repos/{}/{}/issues/{}/comments",
1090			source.owner, source.repo, issue_number
1091		);
1092		let existing_comments = get_json::<Vec<GitHubIssueCommentResponse>>(client, &path).await?;
1093		if existing_comments.iter().any(|comment| {
1094			comment
1095				.body
1096				.as_deref()
1097				.is_some_and(|body| body.contains(&plan.body))
1098		}) {
1099			outcomes.push(GitHubIssueCommentOutcome {
1100				repository: plan.repository.clone(),
1101				issue_id: plan.issue_id.clone(),
1102				operation: GitHubIssueCommentOperation::SkippedExisting,
1103				url: plan.issue_url.clone(),
1104			});
1105			if plan.close {
1106				let issue_path = format!(
1107					"/repos/{}/{}/issues/{}",
1108					source.owner, source.repo, issue_number
1109				);
1110				let _: serde_json::Value =
1111					patch_json(client, &issue_path, &json!({ "state": "closed" })).await?;
1112			}
1113			continue;
1114		}
1115		let response = post_json::<_, GitHubIssueCommentResponse>(
1116			client,
1117			&path,
1118			&json!({ "body": plan.body }),
1119		)
1120		.await?;
1121		outcomes.push(GitHubIssueCommentOutcome {
1122			repository: plan.repository.clone(),
1123			issue_id: plan.issue_id.clone(),
1124			operation: if plan.close {
1125				GitHubIssueCommentOperation::Closed
1126			} else {
1127				GitHubIssueCommentOperation::Created
1128			},
1129			url: response.html_url.or_else(|| plan.issue_url.clone()),
1130		});
1131	}
1132	Ok(outcomes)
1133}
1134
1135fn release_comment_marker(release_tags: &[String]) -> String {
1136	format!("<!-- monochange:released-in:{} -->", release_tags.join("|"))
1137}
1138
1139fn release_issue_comment_body(release_tags: &[String], marker: &str) -> String {
1140	if let Some(release_tag) = release_tags.first().filter(|_| release_tags.len() == 1) {
1141		format!("Released in {release_tag}.\n\n{marker}")
1142	} else {
1143		format!("Released in {}.\n\n{marker}", release_tags.join(", "))
1144	}
1145}
1146
1147/// Publish or update all planned GitHub releases for a manifest.
1148#[tracing::instrument(skip_all)]
1149#[must_use = "the publish result must be checked"]
1150#[allow(clippy::let_and_return)]
1151pub async fn publish_release_requests(
1152	source: &SourceConfiguration,
1153	requests: &[GitHubReleaseRequest],
1154) -> MonochangeResult<Vec<GitHubReleaseOutcome>> {
1155	let client = github_client_from_env(source)?;
1156	let result = publish_release_requests_with_client(&client, requests).await;
1157	result
1158}
1159
1160/// Commit, push, and publish the release pull request against GitHub.
1161#[tracing::instrument(skip_all)]
1162#[must_use = "the pull request result must be checked"]
1163#[allow(clippy::let_and_return, tail_expr_drop_order)]
1164pub async fn publish_release_pull_request(
1165	source: &SourceConfiguration,
1166	root: &Path,
1167	request: &GitHubPullRequestRequest,
1168	tracked_paths: &[PathBuf],
1169	no_verify: bool,
1170	stage_all: bool,
1171) -> MonochangeResult<GitHubPullRequestOutcome> {
1172	let lookup_source = source.clone();
1173	let lookup_request = request.clone();
1174	let existing_pull_request =
1175		tokio::spawn(
1176			async move { lookup_existing_pull_request(&lookup_source, &lookup_request).await },
1177		);
1178	git_checkout_branch(root, &request.head_branch).await?;
1179	git_stage_paths(root, tracked_paths, stage_all).await?;
1180	git_commit_paths(root, &request.commit_message, no_verify).await?;
1181	let mut head_commit = git_head_commit(root).await?;
1182	let existing = join_existing_pull_request_lookup(existing_pull_request).await?;
1183	let head_matches_existing = existing
1184		.as_ref()
1185		.and_then(|pull_request| pull_request.head.sha.as_deref())
1186		== Some(head_commit.as_str());
1187	if !head_matches_existing {
1188		git_push_branch(root, &request.head_branch, no_verify).await?;
1189		// Commits created through GitHub's Git Database API from GitHub Actions can be
1190		// marked verified by GitHub. Keep the pushed git commit as the fallback if the
1191		// API commit cannot be created, verified, or moved onto the release branch.
1192		head_commit = maybe_replace_release_pull_request_commit_with_verified_github_commit(
1193			source,
1194			request,
1195			&head_commit,
1196			root,
1197			tracked_paths,
1198		)
1199		.await
1200		.unwrap_or_else(|warning| {
1201			tracing::warn!(%warning, commit = %head_commit, "falling back to regular release pull request commit");
1202			head_commit.clone()
1203		});
1204	}
1205
1206	let client = github_client_from_env(source)?;
1207	publish_release_pull_request_with_existing_pull_request(
1208		&client,
1209		request,
1210		existing.as_ref(),
1211		&head_commit,
1212	)
1213	.await
1214}
1215
1216async fn maybe_replace_release_pull_request_commit_with_verified_github_commit(
1217	source: &SourceConfiguration,
1218	request: &GitHubPullRequestRequest,
1219	fallback_commit: &str,
1220	root: &Path,
1221	tracked_paths: &[PathBuf],
1222) -> GitHubVerifiedCommitAttempt {
1223	if !github_actions_release_commit_verification_enabled(source) {
1224		return Ok(fallback_commit.to_string());
1225	}
1226
1227	let commit_client = github_commit_client_from_env(source).map_err(|error| error.to_string())?;
1228	let verified_commit = create_verified_github_commit_for_release_pull_request(
1229		&commit_client,
1230		request,
1231		fallback_commit,
1232		root,
1233		tracked_paths,
1234	)
1235	.await?;
1236	update_github_branch_ref_to_verified_commit(
1237		&commit_client,
1238		request,
1239		fallback_commit,
1240		&verified_commit,
1241	)
1242	.await?;
1243	Ok(verified_commit)
1244}
1245
1246fn github_actions_release_commit_verification_enabled(source: &SourceConfiguration) -> bool {
1247	if !source.pull_requests.verified_commits {
1248		return false;
1249	}
1250	if env::var("GITHUB_ACTIONS").as_deref() != Ok("true") {
1251		return false;
1252	}
1253	let repository = format!("{}/{}", source.owner, source.repo);
1254	env::var("GITHUB_REPOSITORY").is_ok_and(|value| value.eq_ignore_ascii_case(&repository))
1255}
1256
1257async fn create_verified_github_commit_for_release_pull_request(
1258	client: &Octocrab,
1259	request: &GitHubPullRequestRequest,
1260	fallback_commit: &str,
1261	root: &Path,
1262	tracked_paths: &[PathBuf],
1263) -> GitHubVerifiedCommitAttempt {
1264	let commit_path = format!(
1265		"/repos/{}/{}/git/commits/{}",
1266		request.owner, request.repo, fallback_commit
1267	);
1268	let original: GitHubGitCommitResponse = get_json(client, &commit_path)
1269		.await
1270		.map_err(|error| error.to_string())?;
1271
1272	// Get the first parent's tree SHA to use as base_tree
1273	let base_tree = match original.parents.first() {
1274		Some(parent) => {
1275			let parent_path = format!(
1276				"/repos/{}/{}/git/commits/{}",
1277				request.owner, request.repo, parent.sha
1278			);
1279			let parent_commit: GitHubGitCommitResponse = get_json(client, &parent_path)
1280				.await
1281				.map_err(|error| error.to_string())?;
1282			Some(parent_commit.tree.sha)
1283		}
1284		None => None,
1285	};
1286
1287	let mut tree_entries = Vec::with_capacity(tracked_paths.len());
1288	for path in tracked_paths {
1289		let absolute_path = root.join(path);
1290		let relative_path = path.to_string_lossy().to_string();
1291
1292		if !absolute_path.exists() {
1293			// File was deleted
1294			tree_entries.push(GitHubCreateTreeEntry {
1295				path: relative_path,
1296				mode: "100644",
1297				entry_type: "blob",
1298				sha: None,
1299			});
1300			continue;
1301		}
1302
1303		let metadata = fs::symlink_metadata(&absolute_path)
1304			.map_err(|e| format!("failed to read metadata for {}: {}", path.display(), e))?;
1305
1306		if metadata.is_dir() {
1307			// Directories are handled implicitly by their children
1308			continue;
1309		}
1310
1311		let (content, mode) = if metadata.is_symlink() {
1312			let target = fs::read_link(&absolute_path)
1313				.map_err(|e| format!("failed to read symlink {}: {}", path.display(), e))?;
1314			let content = target.to_string_lossy().to_string();
1315			(content, "120000")
1316		} else {
1317			let content = fs::read_to_string(&absolute_path)
1318				.map_err(|e| format!("failed to read file {}: {}", path.display(), e))?;
1319			let mode = git_blob_mode(&metadata);
1320			(content, mode)
1321		};
1322
1323		let blob_payload = GitHubCreateBlobPayload {
1324			content,
1325			encoding: "utf-8",
1326		};
1327		let blob_path = format!("/repos/{}/{}/git/blobs", request.owner, request.repo);
1328		let blob: GitHubCreateBlobResponse = post_json(client, &blob_path, &blob_payload)
1329			.await
1330			.map_err(|error| error.to_string())?;
1331
1332		tree_entries.push(GitHubCreateTreeEntry {
1333			path: relative_path,
1334			mode,
1335			entry_type: "blob",
1336			sha: Some(blob.sha),
1337		});
1338	}
1339
1340	// Create tree
1341	let tree_sha = if tree_entries.is_empty() {
1342		// No changes — reuse base tree
1343		base_tree.ok_or_else(|| "no base tree available for empty commit".to_string())?
1344	} else {
1345		let tree_payload = GitHubCreateTreePayload {
1346			base_tree: base_tree.as_deref(),
1347			tree: tree_entries,
1348		};
1349		let tree_path = format!("/repos/{}/{}/git/trees", request.owner, request.repo);
1350		let tree: GitHubCreateTreeResponse = post_json(client, &tree_path, &tree_payload)
1351			.await
1352			.map_err(|error| error.to_string())?;
1353		tree.sha
1354	};
1355
1356	let commit_payload = GitHubCreateCommitPayload {
1357		message: original.message,
1358		tree: tree_sha,
1359		parents: original.parents.into_iter().map(|p| p.sha).collect(),
1360	};
1361	let create_path = format!("/repos/{}/{}/git/commits", request.owner, request.repo);
1362	let commit: GitHubGitCommitResponse = post_json(client, &create_path, &commit_payload)
1363		.await
1364		.map_err(|error| error.to_string())?;
1365
1366	if commit.verification.verified {
1367		tracing::info!(
1368			commit = %commit.sha,
1369			reason = ?commit.verification.reason,
1370			"created verified GitHub release pull request commit"
1371		);
1372		return Ok(commit.sha);
1373	}
1374
1375	Err(format!(
1376		"GitHub Git Database API created commit {} without verification ({})",
1377		commit.sha,
1378		commit
1379			.verification
1380			.reason
1381			.unwrap_or_else(|| "unknown reason".to_string())
1382	))
1383}
1384
1385async fn update_github_branch_ref_to_verified_commit(
1386	client: &Octocrab,
1387	request: &GitHubPullRequestRequest,
1388	fallback_commit: &str,
1389	verified_commit: &str,
1390) -> GitHubVerifiedCommitAttempt {
1391	let get_ref_path = github_head_ref_get_path(request);
1392	let current: GitHubGitRefResponse = get_json(client, &get_ref_path)
1393		.await
1394		.map_err(|error| error.to_string())?;
1395	if current.object.sha != fallback_commit {
1396		return Err(format!(
1397			"release branch {} moved from {} to {}; refusing to replace it with verified commit {}",
1398			request.head_branch, fallback_commit, current.object.sha, verified_commit
1399		));
1400	}
1401	let payload = GitHubUpdateRefPayload {
1402		sha: verified_commit,
1403		force: true,
1404	};
1405	let update_ref_path = github_head_ref_update_path(request);
1406	let updated: GitHubGitRefResponse = patch_json(client, &update_ref_path, &payload)
1407		.await
1408		.map_err(|error| error.to_string())?;
1409	if updated.object.sha != verified_commit {
1410		return Err(format!(
1411			"GitHub returned {} after updating {}, expected {}",
1412			updated.object.sha, request.head_branch, verified_commit
1413		));
1414	}
1415	Ok(updated.object.sha)
1416}
1417
1418fn github_head_ref_get_path(request: &GitHubPullRequestRequest) -> String {
1419	format!(
1420		"/repos/{}/{}/git/ref/heads/{}",
1421		request.owner, request.repo, request.head_branch
1422	)
1423}
1424
1425fn github_head_ref_update_path(request: &GitHubPullRequestRequest) -> String {
1426	format!(
1427		"/repos/{}/{}/git/refs/heads/{}",
1428		request.owner, request.repo, request.head_branch
1429	)
1430}
1431
1432/// Sync existing GitHub releases so retargeted tags point at the new commits.
1433#[tracing::instrument(skip_all)]
1434#[must_use = "the sync result must be checked"]
1435#[allow(clippy::let_and_return, tail_expr_drop_order)]
1436pub async fn sync_retargeted_releases(
1437	source: &SourceConfiguration,
1438	tag_updates: &[RetargetTagResult],
1439	dry_run: bool,
1440) -> MonochangeResult<Vec<RetargetProviderResult>> {
1441	let client = github_client_from_env(source)?;
1442	sync_retargeted_releases_with_client(&client, source, tag_updates, dry_run).await
1443}
1444
1445async fn publish_release_requests_with_client(
1446	client: &Octocrab,
1447	requests: &[GitHubReleaseRequest],
1448) -> MonochangeResult<Vec<GitHubReleaseOutcome>> {
1449	let mut outcomes = Vec::with_capacity(requests.len());
1450	for request in requests {
1451		outcomes.push(publish_release_request_with_client(client, request).await?);
1452	}
1453	Ok(outcomes)
1454}
1455
1456async fn publish_release_request_with_client(
1457	client: &Octocrab,
1458	request: &GitHubReleaseRequest,
1459) -> MonochangeResult<GitHubReleaseOutcome> {
1460	tracing::info!(tag = %request.tag_name, repository = %request.repository, "publishing GitHub release");
1461	let payload = GitHubReleasePayload {
1462		tag_name: &request.tag_name,
1463		name: &request.name,
1464		body: request.body.as_deref(),
1465		draft: request.draft,
1466		prerelease: request.prerelease,
1467		generate_release_notes: request.generate_release_notes,
1468	};
1469	let existing = lookup_existing_release_with_client(client, request).await?;
1470	let (operation, response) = match existing {
1471		Some(existing) => {
1472			(
1473				GitHubReleaseOperation::Updated,
1474				patch_json::<_, GitHubReleaseResponse>(
1475					client,
1476					&format!(
1477						"/repos/{}/{}/releases/{}",
1478						request.owner, request.repo, existing.id
1479					),
1480					&payload,
1481				)
1482				.await?,
1483			)
1484		}
1485		None => {
1486			(
1487				GitHubReleaseOperation::Created,
1488				post_json::<_, GitHubReleaseResponse>(
1489					client,
1490					&format!("/repos/{}/{}/releases", request.owner, request.repo),
1491					&payload,
1492				)
1493				.await?,
1494			)
1495		}
1496	};
1497	Ok(GitHubReleaseOutcome {
1498		provider: SourceProvider::GitHub,
1499		repository: request.repository.clone(),
1500		tag_name: request.tag_name.clone(),
1501		operation,
1502		url: response.html_url,
1503	})
1504}
1505
1506#[cfg_attr(not(test), allow(dead_code))]
1507async fn publish_release_pull_request_with_client(
1508	client: &Octocrab,
1509	request: &GitHubPullRequestRequest,
1510) -> MonochangeResult<GitHubPullRequestOutcome> {
1511	let existing = lookup_existing_pull_request_with_client(client, request).await?;
1512	publish_release_pull_request_with_existing_pull_request(client, request, existing.as_ref(), "")
1513		.await
1514}
1515
1516async fn publish_release_pull_request_with_existing_pull_request(
1517	client: &Octocrab,
1518	request: &GitHubPullRequestRequest,
1519	existing: Option<&GitHubExistingPullRequest>,
1520	head_commit: &str,
1521) -> MonochangeResult<GitHubPullRequestOutcome> {
1522	let labels_match = existing.is_some_and(|pull_request| {
1523		request.labels.iter().all(|label| {
1524			pull_request
1525				.labels
1526				.iter()
1527				.any(|existing_label| existing_label.name == *label)
1528		})
1529	});
1530	let content_matches = existing.is_some_and(|pull_request| {
1531		pull_request.title == request.title
1532			&& pull_request.body.as_deref().unwrap_or_default() == request.body
1533			&& pull_request.base.ref_name == request.base_branch
1534	});
1535	let head_matches_existing =
1536		existing.and_then(|pull_request| pull_request.head.sha.as_deref()) == Some(head_commit);
1537	let (operation, pull_request) = match existing {
1538		Some(existing_pull_request) if content_matches => {
1539			(
1540				if head_matches_existing && labels_match && !request.auto_merge {
1541					GitHubPullRequestOperation::Skipped
1542				} else {
1543					GitHubPullRequestOperation::Updated
1544				},
1545				GitHubPullRequestResponse {
1546					number: existing_pull_request.number,
1547					html_url: existing_pull_request.html_url.clone(),
1548					node_id: existing_pull_request.node_id.clone(),
1549				},
1550			)
1551		}
1552		Some(existing_pull_request) => {
1553			(
1554				GitHubPullRequestOperation::Updated,
1555				sync_existing_pull_request_metadata(client, request, existing_pull_request).await?,
1556			)
1557		}
1558		None => {
1559			(
1560				GitHubPullRequestOperation::Created,
1561				post_json::<_, GitHubPullRequestResponse>(
1562					client,
1563					&format!("/repos/{}/{}/pulls", request.owner, request.repo),
1564					&GitHubPullRequestPayload {
1565						title: &request.title,
1566						head: &request.head_branch,
1567						base: &request.base_branch,
1568						body: &request.body,
1569						draft: false,
1570					},
1571				)
1572				.await?,
1573			)
1574		}
1575	};
1576	if !request.labels.is_empty() && !labels_match {
1577		let _: serde_json::Value = post_json(
1578			client,
1579			&format!(
1580				"/repos/{}/{}/issues/{}/labels",
1581				request.owner, request.repo, pull_request.number
1582			),
1583			&GitHubLabelsPayload {
1584				labels: &request.labels,
1585			},
1586		)
1587		.await?;
1588	}
1589	if request.auto_merge {
1590		enable_pull_request_auto_merge_with_client(client, &pull_request.node_id).await?;
1591	}
1592	Ok(GitHubPullRequestOutcome {
1593		provider: SourceProvider::GitHub,
1594		repository: request.repository.clone(),
1595		number: pull_request.number,
1596		head_branch: request.head_branch.clone(),
1597		operation,
1598		url: pull_request.html_url,
1599	})
1600}
1601
1602async fn sync_retargeted_releases_with_client(
1603	client: &Octocrab,
1604	source: &SourceConfiguration,
1605	tag_updates: &[RetargetTagResult],
1606	dry_run: bool,
1607) -> MonochangeResult<Vec<RetargetProviderResult>> {
1608	let mut results = Vec::with_capacity(tag_updates.len());
1609	for update in tag_updates {
1610		if dry_run {
1611			results.push(RetargetProviderResult {
1612				provider: SourceProvider::GitHub,
1613				tag_name: update.tag_name.clone(),
1614				target_commit: update.to_commit.clone(),
1615				operation: RetargetProviderOperation::Planned,
1616				url: None,
1617				message: None,
1618			});
1619			continue;
1620		}
1621		let path = format!(
1622			"/repos/{}/{}/releases/tags/{}",
1623			source.owner, source.repo, update.tag_name
1624		);
1625		let Some(existing) = get_optional_json::<GitHubExistingRelease>(client, &path).await?
1626		else {
1627			return Err(MonochangeError::Config(format!(
1628				"GitHub release for tag `{}` could not be found",
1629				update.tag_name
1630			)));
1631		};
1632		if existing.target_commitish.as_deref() == Some(update.to_commit.as_str())
1633			|| update.operation == RetargetOperation::AlreadyUpToDate
1634		{
1635			results.push(RetargetProviderResult {
1636				provider: SourceProvider::GitHub,
1637				tag_name: update.tag_name.clone(),
1638				target_commit: update.to_commit.clone(),
1639				operation: RetargetProviderOperation::AlreadyAligned,
1640				url: existing.html_url,
1641				message: None,
1642			});
1643			continue;
1644		}
1645		let response = patch_json::<_, GitHubReleaseResponse>(
1646			client,
1647			&format!(
1648				"/repos/{}/{}/releases/{}",
1649				source.owner, source.repo, existing.id
1650			),
1651			&GitHubReleaseRetargetPayload {
1652				target_commitish: &update.to_commit,
1653			},
1654		)
1655		.await?;
1656		results.push(RetargetProviderResult {
1657			provider: SourceProvider::GitHub,
1658			tag_name: update.tag_name.clone(),
1659			target_commit: update.to_commit.clone(),
1660			operation: RetargetProviderOperation::Synced,
1661			url: response.html_url,
1662			message: None,
1663		});
1664	}
1665	Ok(results)
1666}
1667
1668async fn sync_existing_pull_request_metadata(
1669	client: &Octocrab,
1670	request: &GitHubPullRequestRequest,
1671	existing: &GitHubExistingPullRequest,
1672) -> MonochangeResult<GitHubPullRequestResponse> {
1673	patch_json::<_, GitHubPullRequestResponse>(
1674		client,
1675		&format!(
1676			"/repos/{}/{}/pulls/{}",
1677			request.owner, request.repo, existing.number
1678		),
1679		&GitHubPullRequestUpdatePayload {
1680			title: &request.title,
1681			body: &request.body,
1682			base: &request.base_branch,
1683		},
1684	)
1685	.await
1686}
1687
1688async fn lookup_existing_release_with_client(
1689	client: &Octocrab,
1690	request: &GitHubReleaseRequest,
1691) -> MonochangeResult<Option<GitHubExistingRelease>> {
1692	get_optional_json(
1693		client,
1694		&format!(
1695			"/repos/{}/{}/releases/tags/{}",
1696			request.owner,
1697			request.repo,
1698			encode(&request.tag_name)
1699		),
1700	)
1701	.await
1702}
1703
1704async fn lookup_existing_pull_request_with_client(
1705	client: &Octocrab,
1706	request: &GitHubPullRequestRequest,
1707) -> MonochangeResult<Option<GitHubExistingPullRequest>> {
1708	let path = format!(
1709		"/repos/{}/{}/pulls?state=open&head={}:{}&base={}&per_page=1",
1710		request.owner,
1711		request.repo,
1712		encode(&request.owner),
1713		encode(&request.head_branch),
1714		encode(&request.base_branch)
1715	);
1716	let pull_requests = get_json::<Vec<GitHubExistingPullRequest>>(client, &path).await?;
1717	Ok(pull_requests.into_iter().next())
1718}
1719
1720#[allow(clippy::let_and_return)]
1721async fn lookup_existing_pull_request(
1722	source: &SourceConfiguration,
1723	request: &GitHubPullRequestRequest,
1724) -> MonochangeResult<Option<GitHubExistingPullRequest>> {
1725	let client = github_client_from_env(source)?;
1726	lookup_existing_pull_request_with_client(&client, request).await
1727}
1728
1729async fn enable_pull_request_auto_merge_with_client(
1730	client: &Octocrab,
1731	node_id: &str,
1732) -> MonochangeResult<()> {
1733	let response = client
1734		.graphql::<GraphqlEnableAutoMergeResponse>(&json!({
1735			"query": "mutation($pullRequestId: ID!) { enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: SQUASH }) { pullRequest { number } } }",
1736			"variables": {
1737				"pullRequestId": node_id,
1738			},
1739		}))
1740		.await
1741		.map_err(|error| {
1742			MonochangeError::Config(format!(
1743				"failed to enable GitHub pull request auto merge: {error}"
1744			))
1745		})?;
1746	if response
1747		.enable_pull_request_auto_merge
1748		.and_then(|payload| payload.pull_request)
1749		.is_none()
1750	{
1751		return Err(MonochangeError::Config(
1752			"GitHub pull request auto merge returned no pull request payload".to_string(),
1753		));
1754	}
1755	Ok(())
1756}
1757
1758fn github_client_from_env(source: &SourceConfiguration) -> MonochangeResult<Octocrab> {
1759	let token = env::var("GITHUB_TOKEN")
1760		.or_else(|_| env::var("GH_TOKEN"))
1761		.map_err(|_| {
1762			MonochangeError::Config(
1763				"set `GITHUB_TOKEN` (or `GH_TOKEN`) before running GitHub automation".to_string(),
1764			)
1765		})?;
1766	let env_api_url = env::var("GITHUB_API_URL").ok();
1767	let api_url = source.api_url.as_deref().or(env_api_url.as_deref());
1768	build_github_client(&token, api_url)
1769}
1770
1771/// Client specifically for Git Database API operations that create blobs, trees,
1772/// commits, and update refs. In GitHub Actions, this must use `GITHUB_TOKEN`
1773/// (not a PAT) for GitHub to auto-sign commits with the web-flow GPG key.
1774fn github_commit_client_from_env(source: &SourceConfiguration) -> MonochangeResult<Octocrab> {
1775	let token = env::var("GITHUB_COMMIT_TOKEN")
1776		.or_else(|_| env::var("GITHUB_TOKEN"))
1777		.map_err(|_| {
1778			MonochangeError::Config(
1779				"set `GITHUB_COMMIT_TOKEN` (or `GITHUB_TOKEN`) for GitHub commit verification"
1780					.to_string(),
1781			)
1782		})?;
1783	let env_api_url = env::var("GITHUB_API_URL").ok();
1784	let api_url = source.api_url.as_deref().or(env_api_url.as_deref());
1785	build_github_client(&token, api_url)
1786}
1787
1788fn build_github_client(token: &str, base_uri: Option<&str>) -> MonochangeResult<Octocrab> {
1789	ensure_rustls_provider();
1790	let builder = Octocrab::builder().personal_token(token.to_string());
1791	let builder = if let Some(base_uri) = base_uri {
1792		builder.base_uri(base_uri).map_err(|error| {
1793			MonochangeError::Config(format!(
1794				"failed to configure GitHub base URL `{base_uri}`: {error}"
1795			))
1796		})?
1797	} else {
1798		builder
1799	};
1800	builder.build().map_err(|error| {
1801		MonochangeError::Config(format!("failed to build GitHub API client: {error}"))
1802	})
1803}
1804
1805fn format_github_api_error(method: &str, path: &str, error: &octocrab::Error) -> String {
1806	match error {
1807		octocrab::Error::GitHub { source, .. } => {
1808			let mut parts = vec![
1809				format!("status {}", source.status_code.as_u16()),
1810				source.message.clone(),
1811			];
1812			if let Some(documentation_url) = &source.documentation_url {
1813				parts.push(format!("documentation: {documentation_url}"));
1814			}
1815			if let Some(errors) = &source.errors
1816				&& !errors.is_empty()
1817			{
1818				for error in errors {
1819					parts.push(format!("details: {error}"));
1820				}
1821			}
1822			format!("GitHub API {method} `{path}` failed: {}", parts.join("; "))
1823		}
1824		_ => format!("GitHub API {method} `{path}` failed: {error}"),
1825	}
1826}
1827
1828async fn get_optional_json<T>(client: &Octocrab, path: &str) -> MonochangeResult<Option<T>>
1829where
1830	T: DeserializeOwned,
1831{
1832	match client.get::<T, _, _>(path, None::<&()>).await {
1833		Ok(value) => Ok(Some(value)),
1834		Err(octocrab::Error::GitHub { source, .. }) if source.status_code.as_u16() == 404 => {
1835			Ok(None)
1836		}
1837		Err(error) => {
1838			Err(MonochangeError::Config(format_github_api_error(
1839				"GET", path, &error,
1840			)))
1841		}
1842	}
1843}
1844
1845async fn get_json<T>(client: &Octocrab, path: &str) -> MonochangeResult<T>
1846where
1847	T: DeserializeOwned,
1848{
1849	match client.get::<T, _, _>(path, None::<&()>).await {
1850		Ok(value) => Ok(value),
1851		Err(error) => {
1852			Err(MonochangeError::Config(format_github_api_error(
1853				"GET", path, &error,
1854			)))
1855		}
1856	}
1857}
1858
1859async fn post_json<Body, Response>(
1860	client: &Octocrab,
1861	path: &str,
1862	body: &Body,
1863) -> MonochangeResult<Response>
1864where
1865	Body: Serialize + ?Sized,
1866	Response: DeserializeOwned,
1867{
1868	client
1869		.post(path, Some(body))
1870		.await
1871		.map_err(|error| MonochangeError::Config(format_github_api_error("POST", path, &error)))
1872}
1873
1874async fn patch_json<Body, Response>(
1875	client: &Octocrab,
1876	path: &str,
1877	body: &Body,
1878) -> MonochangeResult<Response>
1879where
1880	Body: Serialize + ?Sized,
1881	Response: DeserializeOwned,
1882{
1883	client
1884		.patch(path, Some(body))
1885		.await
1886		.map_err(|error| MonochangeError::Config(format_github_api_error("PATCH", path, &error)))
1887}
1888
1889async fn join_existing_pull_request_lookup(
1890	handle: tokio::task::JoinHandle<MonochangeResult<Option<GitHubExistingPullRequest>>>,
1891) -> MonochangeResult<Option<GitHubExistingPullRequest>> {
1892	handle.await.map_err(|_| {
1893		MonochangeError::Config("failed to join GitHub pull request lookup task".to_string())
1894	})?
1895}
1896
1897async fn git_checkout_branch(root: &Path, branch: &str) -> MonochangeResult<()> {
1898	if matches!(git_current_branch(root).await.as_deref(), Ok(current) if current == branch) {
1899		return Ok(());
1900	}
1901	run_command(
1902		git_checkout_branch_command(root, branch),
1903		"prepare release pull request branch",
1904	)
1905	.await
1906}
1907
1908async fn git_stage_paths(
1909	root: &Path,
1910	tracked_paths: &[PathBuf],
1911	stage_all: bool,
1912) -> MonochangeResult<()> {
1913	let stageable_paths = resolve_stageable_release_paths(root, tracked_paths).await?;
1914	if stageable_paths.is_empty() {
1915		return Ok(());
1916	}
1917	let command = if stage_all {
1918		git_stage_all_command(root)
1919	} else {
1920		git_stage_paths_command(root, &stageable_paths)
1921	};
1922	run_command(command, "stage release pull request files").await
1923}
1924
1925async fn resolve_stageable_release_paths(
1926	root: &Path,
1927	tracked_paths: &[PathBuf],
1928) -> MonochangeResult<Vec<PathBuf>> {
1929	let mut stageable_paths = Vec::with_capacity(tracked_paths.len());
1930	let path_filter = DiscoveryPathFilter::new(root);
1931	for path in tracked_paths {
1932		if release_path_requires_staging(root, path, &path_filter).await? {
1933			stageable_paths.push(path.clone());
1934		}
1935	}
1936	Ok(stageable_paths)
1937}
1938
1939async fn release_path_requires_staging(
1940	root: &Path,
1941	path: &Path,
1942	path_filter: &DiscoveryPathFilter,
1943) -> MonochangeResult<bool> {
1944	let relative = root_relative_path(root, path);
1945	if !relative.starts_with(".monochange/releases")
1946		&& release_path_is_ignored_by_filter(root, relative, path_filter)
1947	{
1948		return Ok(false);
1949	}
1950
1951	let absolute_path = root.join(path);
1952	if absolute_path.exists() {
1953		if git_path_is_tracked(root, path).await? {
1954			return Ok(true);
1955		}
1956		return Ok(!git_path_is_ignored(root, path).await?);
1957	}
1958	git_path_is_tracked(root, path).await
1959}
1960
1961fn root_relative_path<'a>(root: &Path, path: &'a Path) -> &'a Path {
1962	if path.is_absolute() {
1963		path.strip_prefix(root).unwrap_or(path)
1964	} else {
1965		path
1966	}
1967}
1968
1969fn release_path_is_ignored_by_filter(
1970	root: &Path,
1971	relative: &Path,
1972	path_filter: &DiscoveryPathFilter,
1973) -> bool {
1974	!path_filter.allows(&root.join(relative))
1975}
1976
1977async fn git_path_is_tracked(root: &Path, path: &Path) -> MonochangeResult<bool> {
1978	let relative = path.to_string_lossy();
1979	let output = git_command_output(root, &["ls-files", "--error-unmatch", "--", &relative])
1980		.await
1981		.map_err(|error| {
1982			MonochangeError::Config(format!(
1983				"failed to inspect tracked git path {}: {error}",
1984				path.display()
1985			))
1986		})?;
1987	match output.status.code() {
1988		Some(0) => Ok(true),
1989		Some(1) => Ok(false),
1990		_ => {
1991			Err(MonochangeError::Config(format!(
1992				"failed to inspect tracked git path {}: {}",
1993				path.display(),
1994				git_error_detail(&output)
1995			)))
1996		}
1997	}
1998}
1999
2000async fn git_path_is_ignored(root: &Path, path: &Path) -> MonochangeResult<bool> {
2001	let relative = path.to_string_lossy();
2002	let output = git_command_output(root, &["check-ignore", "-q", "--", &relative])
2003		.await
2004		.map_err(|error| {
2005			MonochangeError::Config(format!(
2006				"failed to inspect ignored git path {}: {error}",
2007				path.display()
2008			))
2009		})?;
2010	match output.status.code() {
2011		Some(0) => Ok(true),
2012		Some(1) => Ok(false),
2013		_ => {
2014			Err(MonochangeError::Config(format!(
2015				"failed to inspect ignored git path {}: {}",
2016				path.display(),
2017				git_error_detail(&output)
2018			)))
2019		}
2020	}
2021}
2022
2023async fn git_commit_paths(
2024	root: &Path,
2025	message: &CommitMessage,
2026	no_verify: bool,
2027) -> MonochangeResult<()> {
2028	run_git_commit_message(
2029		root,
2030		message,
2031		"commit release pull request changes",
2032		no_verify,
2033	)
2034	.await
2035}
2036
2037async fn git_push_branch(root: &Path, branch: &str, no_verify: bool) -> MonochangeResult<()> {
2038	run_command(
2039		git_push_branch_command(root, branch, no_verify),
2040		"push release pull request branch",
2041	)
2042	.await
2043}
2044
2045fn release_body(
2046	github: &SourceConfiguration,
2047	manifest: &ReleaseManifest,
2048	target: &ReleaseManifestTarget,
2049) -> Option<String> {
2050	match github.releases.source {
2051		ProviderReleaseNotesSource::GitHubGenerated => None,
2052		ProviderReleaseNotesSource::Monochange => Some(monochange_release_body(manifest, target)),
2053	}
2054}
2055
2056fn monochange_release_body(manifest: &ReleaseManifest, target: &ReleaseManifestTarget) -> String {
2057	let target_changelog = manifest
2058		.changelogs
2059		.iter()
2060		.find(|changelog| changelog.owner_id == target.id && changelog.owner_kind == target.kind);
2061	let member_changelogs = uncovered_member_changelogs(manifest, target, target_changelog);
2062
2063	match (target_changelog, member_changelogs.is_empty()) {
2064		(Some(changelog), _) if changelog_has_release_notes(changelog) => {
2065			changelog.rendered.clone()
2066		}
2067		(_, false) => grouped_member_release_body(target, &member_changelogs),
2068		(_, true) => minimal_release_body(manifest, target),
2069	}
2070}
2071
2072fn uncovered_member_changelogs<'a>(
2073	manifest: &'a ReleaseManifest,
2074	target: &ReleaseManifestTarget,
2075	target_changelog: Option<&ReleaseManifestChangelog>,
2076) -> Vec<&'a ReleaseManifestChangelog> {
2077	if target.kind != ReleaseOwnerKind::Group {
2078		return Vec::new();
2079	}
2080
2081	manifest
2082		.changelogs
2083		.iter()
2084		.filter(|changelog| {
2085			changelog.owner_kind == ReleaseOwnerKind::Package
2086				&& target.members.contains(&changelog.owner_id)
2087				&& changelog_has_release_notes(changelog)
2088				&& changelog_has_uncovered_notes(changelog, target_changelog)
2089		})
2090		.collect()
2091}
2092
2093fn grouped_member_release_body(
2094	target: &ReleaseManifestTarget,
2095	member_changelogs: &[&ReleaseManifestChangelog],
2096) -> String {
2097	let title = if target.rendered_changelog_title.is_empty() {
2098		target.rendered_title.as_str()
2099	} else {
2100		target.rendered_changelog_title.as_str()
2101	};
2102	let title = if title.is_empty() {
2103		target.tag_name.as_str()
2104	} else {
2105		title
2106	};
2107	let mut lines = vec![format!("## {title}"), String::new()];
2108	lines.push(format!("Grouped release for `{}`.", target.id));
2109	lines.push(String::new());
2110	push_member_changelogs(&mut lines, member_changelogs);
2111	lines.join("\n")
2112}
2113
2114fn push_member_changelogs(lines: &mut Vec<String>, changelogs: &[&ReleaseManifestChangelog]) {
2115	lines.push("## Member package changelogs".to_string());
2116
2117	for changelog in changelogs {
2118		lines.push(String::new());
2119		lines.push(format!("### `{}`", changelog.owner_id));
2120		push_changelog_notes(lines, changelog);
2121	}
2122}
2123
2124fn push_changelog_notes(lines: &mut Vec<String>, changelog: &ReleaseManifestChangelog) {
2125	for paragraph in &changelog.notes.summary {
2126		lines.push(String::new());
2127		lines.push(paragraph.clone());
2128	}
2129
2130	for section in &changelog.notes.sections {
2131		let entries = section
2132			.entries
2133			.iter()
2134			.filter(|entry| !is_empty_release_note(entry))
2135			.cloned()
2136			.collect::<Vec<_>>();
2137		if entries.is_empty() {
2138			continue;
2139		}
2140		lines.push(String::new());
2141		lines.push(format!("#### {}", section.title));
2142		lines.push(String::new());
2143		push_body_entries(lines, &entries);
2144	}
2145}
2146
2147fn changelog_has_release_notes(changelog: &ReleaseManifestChangelog) -> bool {
2148	changelog.notes.sections.iter().any(|section| {
2149		section
2150			.entries
2151			.iter()
2152			.any(|entry| !is_empty_release_note(entry))
2153	})
2154}
2155
2156fn is_empty_release_note(entry: &str) -> bool {
2157	entry.contains("No group-facing notes were recorded for this release")
2158		|| entry.contains("No package-specific changes were recorded")
2159		|| entry.contains("No significant changes")
2160}
2161
2162fn changelog_has_uncovered_notes(
2163	changelog: &ReleaseManifestChangelog,
2164	target_changelog: Option<&ReleaseManifestChangelog>,
2165) -> bool {
2166	let Some(target_changelog) = target_changelog else {
2167		return true;
2168	};
2169	let covered_entries = target_changelog
2170		.notes
2171		.sections
2172		.iter()
2173		.flat_map(|section| &section.entries)
2174		.map(|entry| normalized_release_entry(entry))
2175		.collect::<Vec<_>>();
2176
2177	changelog
2178		.notes
2179		.sections
2180		.iter()
2181		.flat_map(|section| &section.entries)
2182		.filter(|entry| !is_empty_release_note(entry))
2183		.any(|entry| !covered_entries.contains(&normalized_release_entry(entry)))
2184}
2185
2186fn normalized_release_entry(entry: &str) -> String {
2187	entry
2188		.lines()
2189		.filter(|line| !line.trim_start().starts_with("_Packages:"))
2190		.collect::<Vec<_>>()
2191		.join("\n")
2192		.trim()
2193		.to_string()
2194}
2195
2196fn release_pull_request_branch(branch_prefix: &str, command: &str) -> String {
2197	let command = command
2198		.chars()
2199		.map(|character| {
2200			if character.is_ascii_alphanumeric() {
2201				character.to_ascii_lowercase()
2202			} else {
2203				'-'
2204			}
2205		})
2206		.collect::<String>()
2207		.trim_matches('-')
2208		.to_string();
2209	let command = if command.is_empty() {
2210		"release".to_string()
2211	} else {
2212		command
2213	};
2214	format!("{}/{}", branch_prefix.trim_end_matches('/'), command)
2215}
2216
2217fn release_pull_request_body(manifest: &ReleaseManifest) -> String {
2218	let mut lines = vec!["## Prepared release".to_string(), String::new()];
2219	lines.push(format!("- command: `{}`", manifest.command));
2220	for target in manifest
2221		.release_targets
2222		.iter()
2223		.filter(|target| target.release)
2224	{
2225		lines.push(format!(
2226			"- {} `{}` -> `{}`",
2227			target.kind, target.id, target.tag_name
2228		));
2229	}
2230	if !manifest.release_targets.iter().any(|target| target.release) {
2231		lines.push("- no outward release targets".to_string());
2232	}
2233	lines.push(String::new());
2234	lines.push("## Release notes".to_string());
2235	for target in manifest
2236		.release_targets
2237		.iter()
2238		.filter(|target| target.release)
2239	{
2240		lines.push(String::new());
2241		lines.push(format!("### {} {}", target.id, target.version));
2242		if let Some(changelog) = manifest.changelogs.iter().find(|changelog| {
2243			changelog.owner_id == target.id && changelog.owner_kind == target.kind
2244		}) {
2245			for paragraph in &changelog.notes.summary {
2246				lines.push(String::new());
2247				lines.push(paragraph.clone());
2248			}
2249			for section in &changelog.notes.sections {
2250				if section.entries.is_empty() {
2251					continue;
2252				}
2253				lines.push(String::new());
2254				lines.push(format!("### {}", section.title));
2255				lines.push(String::new());
2256				push_body_entries(&mut lines, &section.entries);
2257			}
2258		} else {
2259			lines.push(String::new());
2260			lines.push(minimal_release_body(manifest, target));
2261		}
2262	}
2263	if !manifest.changed_files.is_empty() {
2264		lines.push(String::new());
2265		lines.push("## Changed files".to_string());
2266		lines.push(String::new());
2267		for path in &manifest.changed_files {
2268			lines.push(format!("- {}", path.display()));
2269		}
2270	}
2271	lines.join("\n")
2272}
2273
2274fn push_body_entries(lines: &mut Vec<String>, entries: &[String]) {
2275	for (index, entry) in entries.iter().enumerate() {
2276		let trimmed = entry.trim();
2277		if trimmed.contains('\n') {
2278			lines.extend(trimmed.lines().map(ToString::to_string));
2279			if index + 1 < entries.len() {
2280				lines.push(String::new());
2281			}
2282			continue;
2283		}
2284		if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with('#') {
2285			lines.push(trimmed.to_string());
2286		} else {
2287			lines.push(format!("- {trimmed}"));
2288		}
2289	}
2290}
2291
2292#[cfg(unix)]
2293fn git_blob_mode(metadata: &fs::Metadata) -> &'static str {
2294	use std::os::unix::fs::PermissionsExt;
2295
2296	if metadata.permissions().mode() & 0o100 != 0 {
2297		"100755"
2298	} else {
2299		"100644"
2300	}
2301}
2302
2303#[cfg(not(unix))]
2304fn git_blob_mode(_metadata: &std::fs::Metadata) -> &'static str {
2305	"100644"
2306}
2307
2308fn minimal_release_body(manifest: &ReleaseManifest, target: &ReleaseManifestTarget) -> String {
2309	let mut lines = vec![format!("Release target `{}`", target.id), String::new()];
2310	if !target.members.is_empty() {
2311		lines.push(format!("Members: {}", target.members.join(", ")));
2312		lines.push(String::new());
2313	}
2314	let reasons = manifest
2315		.plan
2316		.decisions
2317		.iter()
2318		.filter(|decision| {
2319			target.kind == ReleaseOwnerKind::Package || target.members.contains(&decision.package)
2320		})
2321		.flat_map(|decision| decision.reasons.iter().cloned())
2322		.collect::<Vec<_>>();
2323	if reasons.is_empty() {
2324		lines.push("- prepare release".to_string());
2325	} else {
2326		for reason in reasons {
2327			lines.push(format!("- {reason}"));
2328		}
2329	}
2330	lines.join("\n")
2331}
2332
2333use std::collections::BTreeMap;
2334use std::fs;
2335
2336use monochange_core::TrustedPublishingSettings;
2337use monochange_publish::PublishRequest;
2338use serde_json::Value as JsonValue;
2339use serde_yaml_ng::Value as YamlValue;
2340
2341pub const GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN: &str = "ACTIONS_ID_TOKEN_REQUEST_TOKEN";
2342pub const GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL: &str = "ACTIONS_ID_TOKEN_REQUEST_URL";
2343
2344#[derive(Debug, Clone, Eq, PartialEq)]
2345pub struct GitHubTrustContext {
2346	pub repository: String,
2347	pub workflow: String,
2348	pub environment: Option<String>,
2349}
2350
2351pub fn verify_github_trust_context(
2352	request: &PublishRequest,
2353	root: &Path,
2354	env_map: &BTreeMap<String, String>,
2355	expected: &GitHubTrustContext,
2356	actual_repository: Option<&str>,
2357	actual_workflow: Option<&str>,
2358	actual_environment: Option<&str>,
2359) -> MonochangeResult<()> {
2360	let actual_repository = actual_repository.ok_or_else(|| {
2361		trusted_publishing_identity_error(
2362			request,
2363			"GitHub Actions did not expose `GITHUB_REPOSITORY`".to_string(),
2364		)
2365	})?;
2366	if actual_repository != expected.repository {
2367		return Err(trusted_publishing_identity_error(
2368			request,
2369			format!(
2370				"expected GitHub repository `{}`, but detected `{actual_repository}`",
2371				expected.repository
2372			),
2373		));
2374	}
2375
2376	let actual_workflow = actual_workflow.ok_or_else(|| {
2377		trusted_publishing_identity_error(
2378			request,
2379			"GitHub Actions did not expose `GITHUB_WORKFLOW_REF` with a workflow filename"
2380				.to_string(),
2381		)
2382	})?;
2383	if actual_workflow != expected.workflow {
2384		return Err(trusted_publishing_identity_error(
2385			request,
2386			format!(
2387				"expected GitHub workflow `{}`, but detected `{actual_workflow}`",
2388				expected.workflow
2389			),
2390		));
2391	}
2392
2393	if let Some(expected_environment) = expected.environment.as_deref() {
2394		let resolved_environment = actual_environment
2395			.map(ToString::to_string)
2396			.or_else(|| resolve_github_job_environment(root, actual_workflow, env_map));
2397		if resolved_environment.as_deref() != Some(expected_environment) {
2398			return Err(trusted_publishing_identity_error(
2399				request,
2400				format!(
2401					"expected GitHub environment `{expected_environment}`, but detected `{}`",
2402					resolved_environment.as_deref().unwrap_or("none")
2403				),
2404			));
2405		}
2406	}
2407
2408	if !env_map.contains_key(GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL)
2409		|| !env_map.contains_key(GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN)
2410	{
2411		return Err(trusted_publishing_identity_error(
2412			request,
2413			format!(
2414				"GitHub Actions did not expose `{GITHUB_ACTIONS_ID_TOKEN_REQUEST_URL}` and `{GITHUB_ACTIONS_ID_TOKEN_REQUEST_TOKEN}`; grant `id-token: write` to the publish job"
2415			),
2416		));
2417	}
2418
2419	Ok(())
2420}
2421
2422pub fn trusted_publishing_identity_error(
2423	request: &PublishRequest,
2424	reason: impl std::fmt::Display,
2425) -> MonochangeError {
2426	MonochangeError::Config(format!(
2427		"`{}` requires trusted publishing from the configured GitHub Actions OIDC identity, but the current context does not match: {reason}. Run `monochange step publish-packages` from the configured CI workflow or set `publish.trusted_publishing = false` to opt out.",
2428		request.package_id,
2429	))
2430}
2431
2432pub fn resolve_github_trust_context(
2433	root: &Path,
2434	source: Option<&SourceConfiguration>,
2435	trust: &TrustedPublishingSettings,
2436	env_map: &BTreeMap<String, String>,
2437) -> MonochangeResult<GitHubTrustContext> {
2438	let repository = trust
2439		.repository
2440		.clone()
2441		.or_else(|| source.map(|source| format!("{}/{}", source.owner, source.repo)))
2442		.or_else(|| env_map.get("GITHUB_REPOSITORY").cloned())
2443		.ok_or_else(|| {
2444			MonochangeError::Config(
2445				"trusted publishing could not determine the GitHub repository; set `publish.trusted_publishing.repository`".to_string(),
2446			)
2447		})?;
2448
2449	let workflow = trust
2450		.workflow
2451		.clone()
2452		.or_else(|| {
2453			env_map
2454				.get("GITHUB_WORKFLOW_REF")
2455				.and_then(|value| parse_github_workflow_ref(value))
2456		})
2457		.ok_or_else(|| {
2458			MonochangeError::Config(
2459				"trusted publishing could not determine the GitHub workflow; set `publish.trusted_publishing.workflow`".to_string(),
2460			)
2461		})?;
2462
2463	let environment = trust
2464		.environment
2465		.clone()
2466		.or_else(|| resolve_github_job_environment(root, &workflow, env_map));
2467
2468	Ok(GitHubTrustContext {
2469		repository,
2470		workflow,
2471		environment,
2472	})
2473}
2474
2475pub fn parse_github_workflow_ref(value: &str) -> Option<String> {
2476	let (_, path_and_ref) = value.split_once('/')?;
2477	let (_, path_and_ref) = path_and_ref.split_once('/')?;
2478	let (_, path_and_ref) = path_and_ref.split_once('/')?;
2479	let (workflow_path, _) = path_and_ref.split_once('@')?;
2480	Path::new(workflow_path)
2481		.file_name()
2482		.and_then(|name| name.to_str())
2483		.map(ToString::to_string)
2484}
2485
2486pub fn resolve_github_job_environment(
2487	root: &Path,
2488	workflow: &str,
2489	env_map: &BTreeMap<String, String>,
2490) -> Option<String> {
2491	let job_id = env_map.get("GITHUB_JOB")?;
2492	let workflow_path = root.join(".github/workflows").join(workflow);
2493	let contents = fs::read_to_string(workflow_path).ok()?;
2494	let parsed = serde_yaml_ng::from_str::<YamlValue>(&contents).ok()?;
2495	let jobs = parsed.get("jobs")?;
2496	let job = jobs.get(job_id.as_str())?;
2497	match job.get("environment") {
2498		Some(YamlValue::String(environment)) => Some(environment.clone()),
2499		Some(YamlValue::Mapping(mapping)) => {
2500			mapping
2501				.get(YamlValue::String("name".to_string()))
2502				.and_then(YamlValue::as_str)
2503				.map(ToString::to_string)
2504		}
2505		_ => None,
2506	}
2507}
2508
2509pub fn trust_list_contains_context(output: &str, context: &GitHubTrustContext) -> bool {
2510	if let Ok(json) = serde_json::from_str::<JsonValue>(output) {
2511		let mut required = vec![context.repository.as_str(), context.workflow.as_str()];
2512		if let Some(environment) = &context.environment {
2513			required.push(environment.as_str());
2514		}
2515		return required
2516			.into_iter()
2517			.all(|needle| json_value_contains(&json, needle));
2518	}
2519
2520	output.contains(&context.repository)
2521		&& output.contains(&context.workflow)
2522		&& context
2523			.environment
2524			.as_deref()
2525			.is_none_or(|environment| output.contains(environment))
2526}
2527
2528pub fn json_value_contains(value: &JsonValue, needle: &str) -> bool {
2529	match value {
2530		JsonValue::String(text) => text.contains(needle),
2531		JsonValue::Array(items) => items.iter().any(|item| json_value_contains(item, needle)),
2532		JsonValue::Object(map) => map.values().any(|value| json_value_contains(value, needle)),
2533		_ => false,
2534	}
2535}
2536
2537pub fn format_manual_trust_context(context: &GitHubTrustContext) -> String {
2538	let mut parts = vec![
2539		format!("repository `{}`", context.repository),
2540		format!("workflow `{}`", context.workflow),
2541	];
2542	if let Some(environment) = &context.environment {
2543		parts.push(format!("environment `{environment}`"));
2544	}
2545	parts.join(", ")
2546}
2547
2548#[cfg(test)]
2549#[path = "__tests__/lib_tests.rs"]
2550mod tests;