Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/reference/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ kind in your configuration file. This kind supports the same `from`
directives as the `github/repo` kind, allowing you to backup releases
from your own repositories, those of other users, or those of an organization.

## Release Notes
Each release is backed up together with its release notes (the description
shown on the GitHub release page). When backing up to the local filesystem,
the notes are written to a `RELEASE_NOTES.md` file alongside the release's
artifacts (under `<owner>/<repo>/<tag>/`). When replicating to a Forgejo
target, the notes are stored in the corresponding Forgejo release's
description instead, and are kept in sync if they change on the source.

The source code archive (`source.tar.gz`) and the `RELEASE_NOTES.md` file are
not uploaded as assets when replicating to Forgejo, since Forgejo generates
its own source archives and the notes are stored in the release description.

## Examples

```yaml{5-6,11-12,16-17,22-23} title="config.yaml"
Expand Down Expand Up @@ -43,6 +55,12 @@ When backing up release artifacts, you may use the following fields in your filt
expressions. These fields are accessed using the `release.<field>` syntax, for example
`release.prerelease` to determine if a release is a pre-release.

Filters are evaluated per-asset, so the `asset.*` fields let you control exactly
which artifacts within a release are backed up, while the `release.*` and `repo.*`
fields apply to the release and its source repository as a whole. The release
notes are backed up whenever the release matches at the `release.*` / `repo.*`
level.

For `kind: github/release`

| Field | Type | Description (_Example_) |
Expand Down
213 changes: 144 additions & 69 deletions src/engines/forgejo/release.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@ use std::sync::atomic::{AtomicBool, Ordering};
use tracing_batteries::prelude::*;

use crate::{
BackupEntity, FilterValue, Filterable,
engines::BackupState,
entities::{Credentials, HttpFile},
engines::{BackupState, summarize_states},
entities::{Credentials, HttpFile, Release},
errors::HumanizableError,
helpers::forgejo::{CreateReleaseOptions, CreateReleaseResult, ForgejoClient},
helpers::forgejo::{
CreateReleaseOptions, CreateReleaseResult, EditReleaseOptions, ForgejoClient,
},
target::RemoteTarget,
};

/// An engine which uploads release artifacts to a Forgejo instance, creating
/// the corresponding release if it does not yet exist.
/// Artifacts which are generated for local backups but should not be uploaded
/// as release assets when replicating to Forgejo. The source tarball is
/// produced by GitHub on demand (and Forgejo generates its own), while the
/// release notes are replicated into the Forgejo release description instead.
const EXCLUDED_ASSETS: &[&str] = &["source.tar.gz", "RELEASE_NOTES.md"];

/// An engine which replicates releases (their notes and artifacts) to a Forgejo
/// instance, creating or updating the corresponding release as required.
#[derive(Clone, Default)]
pub struct ForgejoReleaseEngine {
client: ForgejoClient,
Expand All @@ -24,70 +31,143 @@ impl ForgejoReleaseEngine {
#[tracing::instrument(skip(self, entity, target, cancel), fields(entity = %entity))]
pub async fn backup(
&self,
entity: &HttpFile,
entity: &Release,
target: &RemoteTarget,
cancel: &AtomicBool,
) -> Result<BackupState, crate::Error> {
if cancel.load(Ordering::Relaxed) {
return Ok(BackupState::Skipped);
}

let (repo, tag, asset_name) = parse_release_path(entity)?;

let release = match self.client.get_release_by_tag(target, &repo, &tag).await? {
Some(release) => release,
None => {
trace!("Release {tag} does not exist on Forgejo, creating it.");
let draft = matches!(entity.get("release.draft"), FilterValue::Bool(true));
let prerelease =
matches!(entity.get("release.prerelease"), FilterValue::Bool(true));
let options = CreateReleaseOptions::new(tag.clone())
.with_draft(draft)
.with_prerelease(prerelease);

match self.client.create_release(target, &repo, &options).await? {
CreateReleaseResult::Created(release) => release,
CreateReleaseResult::AlreadyExists => {
// Forgejo reports a 409 Conflict when a release already
// exists for this tag, even though the lookup above
// returned nothing. This happens for draft releases our
// token cannot surface, or for tags synced onto a
// mirrored repository. Try the lookup once more, and if
// the release still cannot be retrieved skip the asset
// rather than failing the entire backup policy.
match self.client.get_release_by_tag(target, &repo, &tag).await? {
Some(release) => release,
None => {
warn!(
"A release for tag '{tag}' already exists on the Forgejo target but could not be retrieved; skipping asset '{asset_name}'."
);
return Ok(BackupState::Skipped);
}
}
}
}
}
let repo = forgejo_repo_name(&entity.full_name);

let (release, release_state) = match self.ensure_release(target, &repo, entity).await? {
Some(result) => result,
None => return Ok(BackupState::Skipped),
};

if release.has_asset(&asset_name) {
return Ok(BackupState::Unchanged(Some(format!("asset {asset_name}"))));
let mut states = vec![release_state];

for asset in &entity.assets {
if cancel.load(Ordering::Relaxed) {
return Ok(BackupState::Skipped);
}

let asset_name = asset_file_name(asset);
if EXCLUDED_ASSETS.contains(&asset_name) {
continue;
}

if release.has_asset(asset_name) {
states.push(BackupState::Unchanged(Some(format!("asset {asset_name}"))));
continue;
}

let data = self.download_asset(asset).await?;

if cancel.load(Ordering::Relaxed) {
return Ok(BackupState::Skipped);
}

self.client
.upload_release_asset(target, &repo, release.id, asset_name, data)
.await?;

states.push(BackupState::New(Some(format!("asset {asset_name}"))));
}

if cancel.load(Ordering::Relaxed) {
return Ok(BackupState::Skipped);
Ok(summarize_states(&states))
}

/// Fetches the Forgejo release for the source release's tag, creating it
/// (or updating its notes) as required so that it matches the source.
///
/// Returns `None` when a release already exists but cannot be retrieved
/// (for example a hidden draft synced onto a mirror), in which case the
/// backup of this release is skipped rather than failing the whole policy.
async fn ensure_release(
&self,
target: &RemoteTarget,
repo: &str,
entity: &Release,
) -> Result<Option<(crate::helpers::forgejo::Release, BackupState)>, crate::Error> {
if let Some(release) = self
.client
.get_release_by_tag(target, repo, &entity.tag)
.await?
{
let state = self
.sync_release_notes(target, repo, &release, entity)
.await?;
return Ok(Some((release, state)));
}

trace!(
"Release {} does not exist on Forgejo, creating it.",
entity.tag
);
let options = CreateReleaseOptions::new(entity.tag.clone())
.with_draft(entity.draft)
.with_prerelease(entity.prerelease)
.with_body(entity.body.clone());

match self.client.create_release(target, repo, &options).await? {
CreateReleaseResult::Created(release) => Ok(Some((
release,
BackupState::New(Some("release".to_string())),
))),
CreateReleaseResult::AlreadyExists => {
// Forgejo reports a 409 Conflict when a release already exists
// for this tag, even though the lookup above returned nothing.
// This happens for draft releases our token cannot surface, or
// for tags synced onto a mirrored repository. Try the lookup
// once more, and if the release still cannot be retrieved skip
// it rather than failing the entire backup policy.
match self
.client
.get_release_by_tag(target, repo, &entity.tag)
.await?
{
Some(release) => {
let state = self
.sync_release_notes(target, repo, &release, entity)
.await?;
Ok(Some((release, state)))
}
None => {
warn!(
"A release for tag '{}' already exists on the Forgejo target but could not be retrieved; skipping it.",
entity.tag
);
Ok(None)
}
}
}
}
}

let data = self.download_asset(entity).await?;
/// Updates the Forgejo release's notes to match the source if they differ.
async fn sync_release_notes(
&self,
target: &RemoteTarget,
repo: &str,
release: &crate::helpers::forgejo::Release,
entity: &Release,
) -> Result<BackupState, crate::Error> {
let desired = entity.body.as_deref().unwrap_or("");
let existing = release.body.as_deref().unwrap_or("");

if cancel.load(Ordering::Relaxed) {
return Ok(BackupState::Skipped);
if desired == existing {
return Ok(BackupState::Unchanged(Some("release".to_string())));
}

trace!("Release notes for {} differ, updating them.", entity.tag);
let options = EditReleaseOptions::new().with_body(entity.body.clone());
self.client
.upload_release_asset(target, &repo, release.id, &asset_name, data)
.update_release(target, repo, release.id, &options)
.await?;

Ok(BackupState::New(Some(format!("asset {asset_name}"))))
Ok(BackupState::Updated(Some("release notes".to_string())))
}

async fn download_asset(&self, entity: &HttpFile) -> Result<Vec<u8>, crate::Error> {
Expand Down Expand Up @@ -131,23 +211,18 @@ impl ForgejoReleaseEngine {
}
}

/// Release artifacts are named `owner/repo/tag/asset`; we extract the repo,
/// tag, and asset name so we can address the corresponding Forgejo release.
fn parse_release_path(entity: &HttpFile) -> Result<(String, String, String), crate::Error> {
let parts: Vec<&str> = entity.name().splitn(4, '/').collect();
if parts.len() != 4 {
return Err(human_errors::user(
format!(
"The release artifact '{}' did not have the expected 'owner/repo/tag/asset' structure.",
entity.name()
),
&["This is likely a bug in github-backup, please report it to us on GitHub."],
));
}
/// Forgejo repository names cannot contain a `/`, so we use the final path
/// segment of the source repository's name as the Forgejo repository name.
fn forgejo_repo_name(full_name: &str) -> String {
full_name
.rsplit('/')
.next()
.unwrap_or(full_name)
.to_string()
}

Ok((
parts[1].to_string(),
parts[2].to_string(),
parts[3].to_string(),
))
/// Release assets are named `owner/repo/tag/asset`; the file name uploaded to
/// Forgejo is the final path segment.
fn asset_file_name(asset: &HttpFile) -> &str {
asset.name.rsplit('/').next().unwrap_or(&asset.name)
}
Loading
Loading