Skip to content

Commit e07cbb1

Browse files
authored
fix(release): adopt verified stranded drafts (#10560)
1 parent b0c7754 commit e07cbb1

8 files changed

Lines changed: 555 additions & 8 deletions

File tree

.github/workflows/release.yml

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,9 @@ jobs:
242242
steps:
243243
- uses: actions/checkout@v4
244244
with:
245-
ref: ${{ inputs.release_tag || github.ref }}
245+
# Recovery checks out an older release tag downstream, but the
246+
# finalizer must include the current recovery contract from main.
247+
ref: ${{ github.sha }}
246248

247249
- name: Install Rust toolchain
248250
uses: dtolnay/rust-toolchain@stable
@@ -883,14 +885,46 @@ jobs:
883885
run: |
884886
rm -f artifacts/*-dist-manifest.json
885887
888+
- name: Create remote draft adoption manifest
889+
if: needs.prepare.outputs.recovery-release == 'true'
890+
env:
891+
RELEASE_TAG: ${{ needs.prepare.outputs['release-tag'] }}
892+
RELEASE_VERSION: ${{ needs.prepare.outputs['release-version'] }}
893+
EXPECTED_ASSETS: ${{ needs.plan.outputs.expected-assets }}
894+
run: |
895+
set -euo pipefail
896+
mkdir -p draft-adoption
897+
COMMIT="$(git rev-parse "${RELEASE_TAG}^{}")"
898+
jq -n \
899+
--arg schema homeboy.draft-adoption \
900+
--argjson schema_version 1 \
901+
--arg component_id homeboy \
902+
--arg tag "${RELEASE_TAG}" \
903+
--arg version "${RELEASE_VERSION}" \
904+
--arg commit "${COMMIT}" \
905+
--argjson expected_assets "${EXPECTED_ASSETS}" \
906+
'{schema: $schema, schema_version: $schema_version, component_id: $component_id, tag: $tag, version: $version, commit: $commit, expected_assets: $expected_assets}' > draft-adoption/manifest.json
907+
908+
- name: Download current Homeboy finalizer
909+
if: needs.prepare.outputs.recovery-release == 'true'
910+
uses: actions/download-artifact@v4
911+
with:
912+
name: homeboy-binary
913+
path: .homeboy-bin
914+
915+
- name: Verify current Homeboy finalizer
916+
if: needs.prepare.outputs.recovery-release == 'true'
917+
run: chmod +x .homeboy-bin/homeboy
918+
886919
- name: Finish Homeboy release pipeline at tag
887920
uses: Extra-Chill/homeboy-action@v2
888921
with:
889922
source: '.'
890923
component: homeboy
891924
commands: release
925+
binary-path: ${{ needs.prepare.outputs.recovery-release == 'true' && '.homeboy-bin/homeboy' || '' }}
892926
release-head: 'true'
893-
release-from-artifacts: artifacts
927+
release-from-artifacts: ${{ needs.prepare.outputs.recovery-release == 'true' && 'draft-adoption' || 'artifacts' }}
894928
release-skip-publish: 'true'
895929

896930
# ── Step 5b: Fail loudly if a prepared release did not publish ──

crates/homeboy-release/src/release/execution_plan.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ fn initial_release_state(
149149
artifacts: Vec::new(),
150150
package_owned_paths: Vec::new(),
151151
changelog_validation: None,
152+
draft_adoption: None,
152153
})
153154
}
154155

crates/homeboy-release/src/release/executor/artifacts.rs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ use serde::Deserialize;
44
use std::collections::BTreeMap;
55

66
use super::{step_success, ReleaseArtifact, ReleaseState, ReleaseStepResult};
7+
use crate::release::types::DraftAdoptionIntent;
78

89
const PACKAGE_RECOVERY_MANIFEST: &str = "manifest.json";
910
pub(crate) const PACKAGE_RECOVERY_MANIFEST_SCHEMA: &str = "homeboy.package-recovery";
1011
pub(crate) const PACKAGE_RECOVERY_MANIFEST_SCHEMA_VERSION: u32 = 1;
12+
const DRAFT_ADOPTION_MANIFEST_SCHEMA: &str = "homeboy.draft-adoption";
13+
const DRAFT_ADOPTION_MANIFEST_SCHEMA_VERSION: u32 = 1;
1114

1215
#[derive(Deserialize)]
1316
struct PackageRecoveryManifest {
@@ -20,6 +23,17 @@ struct PackageRecoveryManifest {
2023
artifacts: Vec<ReleaseArtifact>,
2124
}
2225

26+
#[derive(Deserialize)]
27+
struct DraftAdoptionManifest {
28+
schema: String,
29+
schema_version: u32,
30+
component_id: String,
31+
tag: String,
32+
version: String,
33+
commit: String,
34+
expected_assets: Vec<String>,
35+
}
36+
2337
pub(crate) struct PackageRecoveryContext<'a> {
2438
pub(crate) component_id: &'a str,
2539
pub(crate) tag: &'a str,
@@ -46,6 +60,19 @@ pub(crate) fn run_artifact_inventory(
4660
}
4761

4862
let manifest_path = dir.join(PACKAGE_RECOVERY_MANIFEST);
63+
if manifest_path.is_file() && is_draft_adoption_manifest(&manifest_path) {
64+
let intent = inventory_draft_adoption_manifest(&manifest_path, recovery_context)?;
65+
let count = intent.expected_assets.len();
66+
state.draft_adoption = Some(intent);
67+
return Ok(step_success(
68+
"artifacts.inventory",
69+
"artifacts.inventory",
70+
Some(
71+
serde_json::json!({ "dir": artifact_dir, "artifact_count": 0, "adoption_asset_count": count }),
72+
),
73+
Vec::new(),
74+
));
75+
}
4976
let mut artifacts = if manifest_path.is_file() && is_package_recovery_manifest(&manifest_path) {
5077
inventory_package_recovery_manifest(dir, &manifest_path, recovery_context)?
5178
} else {
@@ -85,6 +112,17 @@ pub(crate) fn run_artifact_inventory(
85112
/// explicit component `scripts.build` artifact; identical lower-precedence
86113
/// duplicates collapse, while differing bytes fail before commit/tag/push.
87114
pub(crate) fn establish_publication_authority(state: &mut ReleaseState) -> Result<Vec<String>> {
115+
if state.draft_adoption.is_some() {
116+
if !state.artifacts.is_empty() {
117+
return Err(Error::validation_invalid_argument(
118+
"release assets",
119+
"draft adoption cannot include local artifacts",
120+
None,
121+
None,
122+
));
123+
}
124+
return Ok(Vec::new());
125+
}
88126
let mut selected: BTreeMap<String, usize> = BTreeMap::new();
89127
let mut diagnostics = Vec::new();
90128
for artifact in &mut state.artifacts {
@@ -189,6 +227,97 @@ fn is_package_recovery_manifest(manifest_path: &std::path::Path) -> bool {
189227
== Some(PACKAGE_RECOVERY_MANIFEST_SCHEMA)
190228
}
191229

230+
fn is_draft_adoption_manifest(manifest_path: &std::path::Path) -> bool {
231+
let Ok(contents) = std::fs::read_to_string(manifest_path) else {
232+
return false;
233+
};
234+
let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&contents) else {
235+
return false;
236+
};
237+
manifest.get("schema").and_then(serde_json::Value::as_str)
238+
== Some(DRAFT_ADOPTION_MANIFEST_SCHEMA)
239+
}
240+
241+
fn inventory_draft_adoption_manifest(
242+
manifest_path: &std::path::Path,
243+
context: &PackageRecoveryContext,
244+
) -> Result<DraftAdoptionIntent> {
245+
let contents = std::fs::read_to_string(manifest_path).map_err(|error| {
246+
Error::internal_io(
247+
format!(
248+
"Failed to read draft adoption manifest '{}': {error}",
249+
manifest_path.display()
250+
),
251+
Some(manifest_path.display().to_string()),
252+
)
253+
})?;
254+
let manifest: DraftAdoptionManifest = serde_json::from_str(&contents).map_err(|error| {
255+
Error::validation_invalid_argument(
256+
"from-artifacts",
257+
format!(
258+
"Draft adoption manifest '{}' is invalid: {error}",
259+
manifest_path.display()
260+
),
261+
Some(manifest_path.display().to_string()),
262+
None,
263+
)
264+
})?;
265+
if manifest.schema_version != DRAFT_ADOPTION_MANIFEST_SCHEMA_VERSION {
266+
return Err(Error::validation_invalid_argument(
267+
"from-artifacts",
268+
"Draft adoption manifest has unsupported schema version",
269+
Some(manifest_path.display().to_string()),
270+
None,
271+
));
272+
}
273+
let identity = PackageRecoveryManifest {
274+
schema: manifest.schema,
275+
schema_version: 1,
276+
component_id: manifest.component_id,
277+
tag: manifest.tag,
278+
version: manifest.version,
279+
commit: manifest.commit,
280+
artifacts: Vec::new(),
281+
};
282+
validate_recovery_identity(&identity, manifest_path, context)?;
283+
if manifest.expected_assets.is_empty()
284+
|| manifest
285+
.expected_assets
286+
.iter()
287+
.any(|name| !valid_asset_name(name))
288+
{
289+
return Err(Error::validation_invalid_argument(
290+
"from-artifacts",
291+
"Draft adoption manifest must contain non-empty safe asset names",
292+
Some(manifest_path.display().to_string()),
293+
None,
294+
));
295+
}
296+
let mut names = manifest.expected_assets;
297+
names.sort();
298+
if names.windows(2).any(|pair| pair[0] == pair[1]) {
299+
return Err(Error::validation_invalid_argument(
300+
"from-artifacts",
301+
"Draft adoption manifest contains duplicate asset names",
302+
Some(manifest_path.display().to_string()),
303+
None,
304+
));
305+
}
306+
Ok(DraftAdoptionIntent {
307+
expected_assets: names,
308+
})
309+
}
310+
311+
fn valid_asset_name(name: &str) -> bool {
312+
!name.is_empty()
313+
&& std::path::Path::new(name)
314+
.file_name()
315+
.and_then(|value| value.to_str())
316+
== Some(name)
317+
&& name != "."
318+
&& name != ".."
319+
}
320+
192321
fn inventory_directory_files(
193322
dir: &std::path::Path,
194323
artifact_dir: &str,
@@ -609,6 +738,62 @@ mod tests {
609738
})
610739
}
611740

741+
#[test]
742+
fn draft_adoption_manifest_records_remote_only_intent_and_rejects_bad_identity_or_names() {
743+
let temp = tempfile::tempdir().expect("tempdir");
744+
let manifest = |component: &str, names: serde_json::Value| {
745+
serde_json::json!({
746+
"schema": "homeboy.draft-adoption", "schema_version": 1,
747+
"component_id": component, "tag": "v1.2.3", "version": "1.2.3", "commit": "abc123",
748+
"expected_assets": names
749+
})
750+
};
751+
std::fs::write(
752+
temp.path().join(PACKAGE_RECOVERY_MANIFEST),
753+
manifest(
754+
"plugin",
755+
serde_json::json!(["plugin.zip", "plugin.zip.sha256"]),
756+
)
757+
.to_string(),
758+
)
759+
.unwrap();
760+
let mut state = ReleaseState::default();
761+
run_artifact_inventory(
762+
&mut state,
763+
&temp.path().to_string_lossy(),
764+
&recovery_context(),
765+
)
766+
.expect("adoption inventory");
767+
assert!(state.artifacts.is_empty());
768+
assert_eq!(
769+
state.draft_adoption.unwrap().expected_assets,
770+
["plugin.zip", "plugin.zip.sha256"]
771+
);
772+
773+
std::fs::write(
774+
temp.path().join(PACKAGE_RECOVERY_MANIFEST),
775+
manifest("other", serde_json::json!(["plugin.zip"])).to_string(),
776+
)
777+
.unwrap();
778+
assert!(run_artifact_inventory(
779+
&mut ReleaseState::default(),
780+
&temp.path().to_string_lossy(),
781+
&recovery_context()
782+
)
783+
.is_err());
784+
std::fs::write(
785+
temp.path().join(PACKAGE_RECOVERY_MANIFEST),
786+
manifest("plugin", serde_json::json!(["plugin.zip", "plugin.zip"])).to_string(),
787+
)
788+
.unwrap();
789+
assert!(run_artifact_inventory(
790+
&mut ReleaseState::default(),
791+
&temp.path().to_string_lossy(),
792+
&recovery_context()
793+
)
794+
.is_err());
795+
}
796+
612797
#[test]
613798
fn final_package_authority_supersedes_different_preflight_bytes_and_publishes_once() {
614799
let temp = tempfile::tempdir().expect("tempdir");

0 commit comments

Comments
 (0)