use super::action::identity::{ActionId, CommitDate, CommitSha, Repository, Version};
use super::action::resolved::{Commit, Resolved, ResolvedRef};
use super::action::spec::Spec as ActionSpec;
use super::action::specifier::Specifier;
use super::action::tag_selection::select_most_specific_tag;
use super::action::uses_ref::RefType;
use std::fmt;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Forge {
GitHub,
}
impl Forge {
#[must_use]
pub fn token_env(self) -> &'static str {
match self {
Self::GitHub => "GITHUB_TOKEN",
}
}
}
impl fmt::Display for Forge {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::GitHub => f.write_str("GitHub"),
}
}
}
pub const MAX_RETRY_WAIT_SECS: u64 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RetryAfter {
Unstated,
After(Duration),
TooDistant,
}
#[derive(Debug, Clone, Error)]
#[non_exhaustive]
pub enum Error {
#[error("failed to resolve {spec}: {reason}")]
ResolveFailed { spec: ActionSpec, reason: String },
#[error("{forge} rate limit exhausted; set {} to raise the limit", forge.token_env())]
RateLimited {
forge: Forge,
retry_after: RetryAfter,
},
#[error(
"{forge} requires authorization; set {} to a token with repository read access",
forge.token_env()
)]
AuthRequired {
forge: Forge,
},
}
impl Error {
#[must_use]
pub fn is_skippable(&self) -> bool {
matches!(self, Self::RateLimited { .. } | Self::AuthRequired { .. })
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(self, Self::RateLimited { .. })
}
}
#[derive(Debug, Clone)]
pub struct ShaDescription {
pub tags: Vec<Version>,
pub repository: Repository,
pub date: CommitDate,
}
pub trait VersionRegistry {
fn lookup_sha(&self, id: &ActionId, version: &Version) -> Result<Commit, Error>;
fn all_tags(&self, id: &ActionId) -> Result<Vec<Version>, Error>;
fn describe_sha(&self, id: &ActionId, sha: &CommitSha) -> Result<ShaDescription, Error>;
}
pub struct ActionResolver<'reg, R: VersionRegistry> {
registry: &'reg R,
}
impl<'reg, R: VersionRegistry> ActionResolver<'reg, R> {
#[must_use]
pub fn new(registry: &'reg R) -> Self {
Self { registry }
}
#[must_use]
pub fn registry(&self) -> &R {
self.registry
}
pub fn resolve(&self, spec: &ActionSpec) -> Result<Resolved, Error> {
let version = Version::from(spec.specifier.to_lookup_tag());
let commit = self.registry.lookup_sha(&spec.id, &version)?;
let reference = match &spec.specifier {
Specifier::Range { .. } => ResolvedRef::Tag(version),
Specifier::Ref(_) => ResolvedRef::Branch(version),
Specifier::Sha(_) => ResolvedRef::Commit,
};
Ok(Resolved { reference, commit })
}
pub fn resolve_from_sha(&self, id: &ActionId, sha: &CommitSha) -> Result<Resolved, Error> {
let desc = self.registry.describe_sha(id, sha)?;
let (reference, ref_type) = match select_most_specific_tag(&desc.tags) {
Some(tag) => (ResolvedRef::Tag(tag), Some(RefType::Tag)),
None => (ResolvedRef::Commit, Some(RefType::Commit)),
};
Ok(Resolved {
reference,
commit: Commit {
sha: sha.clone(),
repository: desc.repository,
ref_type,
date: desc.date,
},
})
}
}
#[path = "resolution_testutil.rs"]
pub mod testutil;
#[cfg(test)]
#[expect(
clippy::expect_used,
clippy::assertions_on_result_states,
reason = "tests use unwrap, indexing, and other patterns freely"
)]
mod tests {
use super::testutil::FakeRegistry;
use super::{
ActionId, ActionResolver, ActionSpec, Commit, CommitDate, CommitSha, Duration, Error,
Forge, RefType, Repository, RetryAfter, Version,
};
use crate::domain::action::resolved::ResolvedRef;
use crate::domain::action::specifier::Specifier;
#[test]
fn resolve_success() {
let mock_registry = FakeRegistry::new().with_lookup_result(Ok(Commit {
sha: CommitSha::from("abc123def456789012345678901234567890abcd"),
repository: Repository::from("actions/checkout"),
ref_type: Some(RefType::Tag),
date: CommitDate::from("2026-01-01T00:00:00Z"),
}));
let service = ActionResolver::new(&mock_registry);
let spec = ActionSpec::new(ActionId::from("actions/checkout"), Specifier::from_v1("v4"));
let result = service.resolve(&spec);
let resolved = result.expect("Expected Ok result");
assert_eq!(
resolved.reference,
ResolvedRef::Tag(Version::from("v4")),
"a range specifier resolves to a Tag reference"
);
assert_eq!(
resolved.commit.sha.as_str(),
"abc123def456789012345678901234567890abcd"
);
}
#[test]
fn resolve_failure() {
let registry = FakeRegistry::new().with_lookup_result(Err(Error::ResolveFailed {
spec: ActionSpec::new(ActionId::from("actions/checkout"), Specifier::from_v1("v4")),
reason: "not found".to_owned(),
}));
let service = ActionResolver::new(®istry);
let spec = ActionSpec::new(ActionId::from("actions/checkout"), Specifier::from_v1("v4"));
let result = service.resolve(&spec);
assert!(result.is_err());
}
#[test]
fn resolve_from_sha_with_tags() {
let sha = CommitSha::from("abc123def456789012345678901234567890abcd");
let registry = FakeRegistry::new().with_sha_tags(
"owner/repo",
sha.as_str(),
vec!["v3", "v3.6", "v3.6.1"],
);
let service = ActionResolver::new(®istry);
let id = ActionId::from("owner/repo");
let result = service
.resolve_from_sha(&id, &sha)
.expect("Expected Ok result");
assert_eq!(result.reference, ResolvedRef::Tag(Version::from("v3.6.1")));
assert_eq!(result.commit.sha, sha);
assert_eq!(result.commit.ref_type, Some(RefType::Tag));
assert_eq!(result.commit.repository.as_str(), "owner/repo");
}
#[test]
fn resolve_from_sha_no_tags() {
let sha = CommitSha::from("abc123def456789012345678901234567890abcd");
let registry = FakeRegistry::new();
let service = ActionResolver::new(®istry);
let id = ActionId::from("owner/repo");
let result = service
.resolve_from_sha(&id, &sha)
.expect("Expected Ok result");
assert_eq!(result.reference, ResolvedRef::Commit);
assert_eq!(result.commit.sha, sha);
assert_eq!(result.commit.ref_type, Some(RefType::Commit));
}
#[test]
fn resolve_from_sha_describe_error_propagates() {
let registry = FakeRegistry::new().failing_describe(Error::AuthRequired {
forge: Forge::GitHub,
});
let service = ActionResolver::new(®istry);
let id = ActionId::from("owner/repo");
let sha = CommitSha::from("abc123def456789012345678901234567890abcd");
let result = service.resolve_from_sha(&id, &sha);
assert!(
matches!(
result,
Err(Error::AuthRequired {
forge: Forge::GitHub
})
),
"describe_sha error should propagate through resolve_from_sha"
);
}
fn resolve_failed() -> Error {
Error::ResolveFailed {
spec: ActionSpec::new(ActionId::from("actions/checkout"), Specifier::from_v1("v4")),
reason: "not found".to_owned(),
}
}
#[test]
fn rate_limited_is_skippable() {
assert!(
Error::RateLimited {
forge: Forge::GitHub,
retry_after: RetryAfter::Unstated,
}
.is_skippable()
);
}
#[test]
fn auth_required_is_skippable() {
assert!(
Error::AuthRequired {
forge: Forge::GitHub
}
.is_skippable()
);
}
#[test]
fn strict_errors_are_not_skippable() {
assert!(!resolve_failed().is_skippable());
}
#[test]
fn rate_limited_is_retryable() {
assert!(
Error::RateLimited {
forge: Forge::GitHub,
retry_after: RetryAfter::Unstated,
}
.is_retryable()
);
}
#[test]
fn auth_required_is_not_retryable() {
assert!(
!Error::AuthRequired {
forge: Forge::GitHub
}
.is_retryable()
);
}
#[test]
fn strict_errors_are_not_retryable() {
assert!(!resolve_failed().is_retryable());
}
#[test]
fn rate_limited_message_names_forge_and_remedy() {
let message = Error::RateLimited {
forge: Forge::GitHub,
retry_after: RetryAfter::After(Duration::from_secs(3)),
}
.to_string();
assert!(message.contains("GitHub"), "names the forge: {message}");
assert!(
message.contains("GITHUB_TOKEN"),
"names the remedy: {message}"
);
assert!(
!message.contains("resets"),
"must not claim a reset time it never read: {message}"
);
}
#[test]
fn auth_required_message_names_forge_and_remedy() {
let message = Error::AuthRequired {
forge: Forge::GitHub,
}
.to_string();
assert!(message.contains("GitHub"), "names the forge: {message}");
assert!(
message.contains("GITHUB_TOKEN"),
"names the remedy: {message}"
);
}
}