Merge feat/launcher-native-mods-page (main merged in)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-28 14:27:32 +01:00
commit 505de825db
12 files changed
+270 -171

No files matched your search

+20 -1
View File
@@ -25,6 +25,9 @@ run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.req
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
@@ -76,6 +79,7 @@ jobs:
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -103,6 +107,18 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- name: The source of the build (horizon-source.tar.gz)
if: steps.pick.outputs.build == 'true'
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
@@ -144,7 +160,7 @@ jobs:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -187,6 +203,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
@@ -198,7 +215,9 @@ jobs:
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
@@ -7,6 +7,7 @@
use horizon_client::{
containers::{Furnace, FurnaceKind},
crafting::Recipes,
furnace::FurnaceCooking,
item_data::{canon_stack, parse_stack},
items::Items,
menu::{Env, Input, Kind, Menu},
+2 -166
View File
@@ -5,11 +5,8 @@ pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
};
use std::{collections::HashMap, sync::Arc};
use crate::items::ItemStack;
use std::collections::HashMap;
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
@@ -380,167 +377,6 @@ impl BlockEntity {
}
}
pub use crate::providers::Providers;
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
}
impl Furnace {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
pub fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
}
}
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
pub fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
pub fn tick(
&mut self,
recipes: &Recipes,
items: &Items,
providers: &Providers,
) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
}
}
/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
/// more with the chance of what is left over (a level random draw only when there is some).
+173
View File
@@ -0,0 +1,173 @@
//! `AbstractFurnaceBlockEntity`'s cooking over the furnace data the chunk
//! holds (`containers::Furnace`): it needs the recipes, so it lives above them.
use crate::{
containers::{Furnace, recipe_used_key},
crafting::Recipes,
items::{ItemStack, Items},
providers::Providers,
};
use std::sync::Arc;
/// The furnace methods that read the recipes.
pub trait FurnaceCooking {
/// `AbstractFurnaceBlockEntity.setItem` on the input slot: anything but
/// the same item with the same components restarts cooking, with the
/// total time of the new input's recipe (200 ticks without one).
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>);
/// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
/// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)>;
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
}
fn remainder(item: &str) -> Option<&'static str> {
match item {
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
Some("minecraft:bucket")
}
_ => None,
}
}
impl FurnaceCooking for Furnace {
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
let same = match (&self.items[0], &stack) {
(Some(old), Some(new)) => old.same_item_same_components(new),
_ => false,
};
self.items[0] = stack;
if !same {
self.cooking_total = self.items[0]
.as_ref()
.and_then(|input| recipes.cook(self.kind.recipe_type(), input))
.map_or(200, |recipe| total_time(recipe.time, self.speed));
self.cooking_timer = 0;
}
}
fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
self.recipes_used
.iter()
.map(|(id, count)| (*count, recipes.cooking_experience(id)))
.collect()
}
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>) {
if self.items[0] != before {
let after = std::mem::replace(&mut self.items[0], before);
self.set_input(recipes, after);
}
}
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
let was_lit = self.lit_remaining > 0;
let mut lit = false;
if self.lit_remaining > 0 {
self.lit_remaining -= 1;
lit = self.lit_remaining > 0;
}
let fast = self.kind.fast();
let has_ingredient = self.items[0].is_some();
let has_fuel = self.items[1].is_some();
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
Some(out) => {
out.item == recipe.result.item && out.count + recipe.result.count <= max
}
};
if can_burn {
if !lit {
let fuel = self.items[1].clone();
let time = fuel
.as_ref()
.map_or(0, |f| providers.burn_time(items, f, fast));
let speed = fuel
.as_ref()
.map_or(1.0, |f| providers.speed(items, f, fast));
self.lit_remaining = time;
self.lit_total = time;
self.speed = speed;
if self.cooking_total > 0 && self.cooking_timer < self.cooking_total {
let ratio = self.cooking_timer as f32 / self.cooking_total as f32;
self.cooking_total = total_time(recipe.time, self.speed);
self.cooking_timer =
(ratio * self.cooking_total as f32).ceil() as i32;
}
if time > 0 {
if let Some(f) = self.items[1].as_mut() {
let item = f.item.clone();
f.count -= 1;
if f.count == 0 {
self.items[1] = remainder(&item).map(|r| ItemStack {
item: Arc::from(r),
count: 1,
damage: 0,
extra: None,
});
}
}
lit = true;
}
}
if lit {
self.cooking_timer += 1;
if self.cooking_timer >= self.cooking_total {
self.cooking_timer = 0;
self.cooking_total = total_time(recipe.time, self.speed);
match &mut self.items[2] {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
self.items[0] = None;
}
}
}
} else {
self.cooking_timer = 0;
}
} else {
self.cooking_timer = 0;
}
}
} else {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
(time as f32 / speed).ceil() as i32
} else {
time as i32
}
}
+1
View File
@@ -222,6 +222,7 @@ use horizon_client::{
entities::{Experience, ExperienceOrb, ItemEntity},
environment::Environment,
first_person::{Hand, SwingAnimation},
furnace::FurnaceCooking,
gfx::{Frame, Renderer},
hud::{HudAtlas, HudBatch, rgba},
hud_layout, hud_overlays,
@@ -9,6 +9,7 @@ use horizon_client::{
Result,
block_entities::{Pos, Ticking},
containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS},
furnace::FurnaceCooking,
};
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
@@ -7,6 +7,7 @@
use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
furnace::FurnaceCooking,
items::ItemStack,
lan::{guest::Guest, menu::Slots},
menu::{Env, Input, Kind, Menu},
+1
View File
@@ -7,6 +7,7 @@ use super::{Game, GameLevel, Screen, dispenser::new_item_motion, storage::is_shu
use horizon_client::{
block_entity_item, block_entity_nbt,
containers::{Banner, BlockEntity},
furnace::FurnaceCooking,
item_data,
items::ItemStack,
save,
+1
View File
@@ -69,6 +69,7 @@ pub mod flatgen;
pub mod fog;
pub mod font;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gfx;
+6
View File
@@ -449,6 +449,12 @@ than main's head and holds something main lacks, otherwise main (`tools/ci/night
new publishes nothing. A Nightly can include pull requests not reviewed yet: that is what it is for, playing them
before the gate.
Every Nightly ships its exact source. Its notes name the commit; `horizon-source.tar.gz` on the release is that
commit's `git archive`, signed with the builds in `SHA256SUMS`; and `nightly.py keep-source` tags the commit
`nightly-source/<date>-<short>`. That tag is never pruned, unlike the `nightly-<date>` release and the
`nightly/build-*` branch (7 days each), because native mods pin a Nightly's commit and the launcher downloads that
commit's source to build them ([native-mods.md](native-mods.md)).
### Trusted reviewers
One list, `.github/trusted-reviewers.txt`, governs two things for the people on it (kacpherek, rdbtCVS and
+24
View File
@@ -62,6 +62,19 @@ class NightlyTests(unittest.TestCase):
self.assertIn("- Fix pistons (#2476)", text)
self.assertIn("Everything in it is on main.", nightly.notes("2026-10-28", ref("main", "m" * 40, 0), []))
def test_notes_name_the_commit_and_its_permanent_source_tag(self):
sha = "0c0206714abc" + "0" * 28
text = nightly.notes("2026-10-28", ref("union/a", sha, 1), [])
self.assertIn(f"Source: commit {sha}, kept for good as the tag `nightly-source/2026-10-28-0c02067`", text)
main = nightly.Ref("main", "m" * 40, None)
self.assertIn("`nightly-source/2026-10-28-bbbbbbb`",
nightly.set_notes("2026-10-28", nightly.Ref("b", "b" * 40, None), main, [], []))
def test_the_source_tag_outlives_every_prune(self):
tag = nightly.source_tag("2026-10-20", "a" * 40)
self.assertEqual(nightly.stale([{"tagName": tag, "isPrerelease": True}], dt.date(2026, 11, 28), 7), [])
self.assertIsNone(nightly.DATED.match(tag))
class ScheduledTests(unittest.TestCase):
@@ -149,6 +162,17 @@ class PruneBuildsTests(unittest.TestCase):
"feature/nightly/build-2026-10-01-1": "g", "nightly/build-2026-10-02-1": "n"}
self.assertEqual(nightly.stale_builds(branches, set(), self.TODAY, 7), [])
def test_the_source_is_archived_from_git_signed_attached_and_tagged(self):
build = (WORKFLOWS / "nightly-build.yml").read_text()
pick = build.split("\n build:", 1)[0]
self.assertIn('git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"',
pick)
sign = build.split("\n sign:", 1)[1].split("\n publish:", 1)[0]
self.assertIn("dist/horizon-source.tar.gz", sign.split("sign_release.sh nightly", 1)[1])
publish = build.split("\n publish:", 1)[1].split("\n answer:", 1)[0]
self.assertIn('"dist/horizon-source.tar.gz#Source of this Nightly', publish)
self.assertIn('nightly.py keep-source "$SHA" "$DATE"', publish)
def test_the_workflow_prunes_on_schedule_only(self):
build = (WORKFLOWS / "nightly-build.yml").read_text()
step = build.split("Old nightly/build-* branches deleted", 1)[1].split("- uses:", 1)[0]
+39 -4
View File
@@ -6,6 +6,7 @@
nightly.py prune [--days 7] deletes the nightly-<date> pre-releases and tags older than that, nothing else
nightly.py prune-builds [--days 7] deletes the nightly/build-<date>-<n> branches older than that, but the
newest and any a published nightly points to (Ludovic, 2026-10-27)
nightly.py keep-source <sha> <date> tags the nightly's commit nightly-source/<date>-<short>, for good
A nightly builds main plus the pull requests the trusted reviewers picked (the set: tools/ci/nightly_set.py), merged
in their order and pushed as nightly/build-<date>-<n>. With no set it builds the newest union branch on origin
@@ -13,6 +14,10 @@ in their order and pushed as nightly/build-<date>-<n>. With no set it builds the
newer than main's head. It is published as the `nightly` pre-release; the previous one stays a week as
nightly-<date>. Runs from main's checkout with `gh`; it reads and merges the branches with git and never runs
anything from them.
Each nightly ships its exact source: horizon-source.tar.gz (`git archive` of the commit, signed with the builds) on
the release, and the tag nightly-source/<date>-<short>, which no prune deletes. Native mods pin a nightly's commit
(docs/native-mods.md), so that commit must stay downloadable after its release and build branch are gone.
"""
from __future__ import annotations
@@ -33,6 +38,7 @@ else: # run as tools/ci/nightly.py: its folder is on the path
DATED = re.compile(r"^nightly-(\d{4}-\d{2}-\d{2})$")
TITLE_DATE = re.compile(r"Nightly (\d{4}-\d{2}-\d{2})")
SOURCE_TAG = "nightly-source/"
@dataclass
@@ -84,10 +90,20 @@ OPT_IN = ("**Opt-in, not a release.** A Nightly can include pull requests not re
"as they are. Report bugs with the version the game shows (\"Nightly <date> · <commit>\").")
def source_tag(today: str, sha: str) -> str:
"""The permanent tag of a nightly's commit: neither `prune` (nightly-<date> only) nor `prune-builds` touches it."""
return f"{SOURCE_TAG}{today}-{sha[:7]}"
def source_line(today: str, sha: str) -> str:
return (f"Source: commit {sha}, kept for good as the tag `{source_tag(today, sha)}`; horizon-source.tar.gz is "
"its tree. Native mods built for this Nightly pin that commit.")
def set_notes(today: str, chosen: Ref, main: Ref, prs: list[dict], left_out: list[str]) -> str:
"""The notes of a build of main plus the reviewers' set."""
lines = [f"Nightly {today} · {chosen.sha[:7]}: main ({main.sha[:7]}) with {len(prs)} pull requests merged in, "
f"built from `{chosen.name}` ({chosen.sha}).", "", OPT_IN, "",
f"built from `{chosen.name}` ({chosen.sha}).", "", source_line(today, chosen.sha), "", OPT_IN, "",
f"### Pull requests in this Nightly ({len(prs)})", ""]
lines += [f"- {ns.plain(e['title'])} (#{e['number']}) by {e['author']}, at {e['head'][:7]}, picked by "
f"{e['asked_by']}" for e in prs]
@@ -97,7 +113,8 @@ def set_notes(today: str, chosen: Ref, main: Ref, prs: list[dict], left_out: lis
def notes(today: str, chosen: Ref, held: list[dict], left_out: list[str] | None = None) -> str:
lines = [f"Nightly {today} · {chosen.sha[:7]}: built from `{chosen.name}` ({chosen.sha}).", "", OPT_IN, ""]
lines = [f"Nightly {today} · {chosen.sha[:7]}: built from `{chosen.name}` ({chosen.sha}).", "",
source_line(today, chosen.sha), "", OPT_IN, ""]
if left_out:
lines += [f"The reviewers' set had nothing left to build: {'; '.join(ns.plain(w) for w in left_out)}.", ""]
if held:
@@ -184,7 +201,7 @@ def build_set(main: Ref, prs: list[dict], left_out: list[str], today: str, force
with open("nightly-notes.md", "w") as f:
f.write(set_notes(today, chosen, main, kept, left_out))
output(build=str(build).lower(), sha=sha, short=sha[:7], source=name, date=today, version=f"nightly-{today}",
nightly=f"{today} {sha[:7]}")
nightly=f"{today} {sha[:7]}", source_tag=source_tag(today, sha))
print(f"{'build' if build else 'skip (unchanged since the last nightly)'}: {name} {sha}, main {main.sha[:7]} + "
+ ", ".join(f"#{e['number']}" for e in kept))
@@ -233,7 +250,7 @@ def build_union(main: Ref, today: str, force: bool, left_out: list[str] | None =
with open("nightly-notes.md", "w") as f:
f.write(notes(today, chosen, held, left_out))
output(build=str(build).lower(), sha=chosen.sha, short=chosen.sha[:7], source=chosen.name, date=today,
version=f"nightly-{today}", nightly=f"{today} {chosen.sha[:7]}")
version=f"nightly-{today}", nightly=f"{today} {chosen.sha[:7]}", source_tag=source_tag(today, chosen.sha))
print(f"{'build' if build else 'skip (unchanged since the last nightly)'}: {chosen.name} {chosen.sha}, "
f"{len(held)} open pull requests beyond main")
@@ -309,6 +326,19 @@ def prune_builds(days: int) -> None:
f"nightlies' and the last {days} days')")
def keep_source(sha: str, today: str) -> None:
"""Tags the published nightly's commit for good (source_tag); a rerun of the same build finds it there."""
tag = source_tag(today, sha)
there = remote_refs(f"refs/tags/{tag}").get(tag)
if there == sha:
print(f"`{tag}` already names {sha[:7]}")
return
if there:
raise SystemExit(f"`{tag}` names {there[:7]}, not {sha[:7]}")
gh("api", f"repos/{os.environ['GITHUB_REPOSITORY']}/git/refs", "-f", f"ref=refs/tags/{tag}", "-f", f"sha={sha}")
print(f"tagged {sha[:7]} `{tag}`")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
@@ -316,6 +346,9 @@ def main() -> None:
sub.add_parser("rotate")
sub.add_parser("prune").add_argument("--days", type=int, default=7)
sub.add_parser("prune-builds").add_argument("--days", type=int, default=7)
keep = sub.add_parser("keep-source")
keep.add_argument("sha")
keep.add_argument("date")
args = ap.parse_args()
if args.cmd == "pick":
pick(args.force)
@@ -323,6 +356,8 @@ def main() -> None:
rotate()
elif args.cmd == "prune-builds":
prune_builds(args.days)
elif args.cmd == "keep-source":
keep_source(args.sha, args.date)
else:
prune(args.days)