From e5569bd0a1db60072e75034099699ea132ba81e9 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 22 Sep 2026 05:34:03 -0400 Subject: [PATCH 1/2] Dublin time --- src/core/game3/battle/anim_seq.lua | 2 +- src/core/game3/battle/effects/secondary.lua | 6 +- src/core/game3/battle/engine.lua | 2 +- src/core/game3/battle/init.lua | 3 +- src/core/game3/battle/state.lua | 5 +- src/core/game3/battle/switch_seq.lua | 14 ++-- src/core/game3/runtime.lua | 11 +--- src/core/game3/scripting/ops_a.lua | 11 ++-- src/ui/game3/hud.lua | 11 +++- tests/drivers/game3_stitchuif_naming_pc.lua | 1 + tests/engine/game3_knock_off_item_test.lua | 66 +++++++++++++------ .../game3_naming_update_contract_test.lua | 42 ++++++++++++ .../engine/game3_script_verbs_subset_test.lua | 26 ++++++-- 13 files changed, 144 insertions(+), 56 deletions(-) diff --git a/src/core/game3/battle/anim_seq.lua b/src/core/game3/battle/anim_seq.lua index 78c20cf9..0a9d47dd 100644 --- a/src/core/game3/battle/anim_seq.lua +++ b/src/core/game3/battle/anim_seq.lua @@ -117,7 +117,7 @@ local function stand_in(id, slot) local mon = party and slot and party[slot] if not mon then return nil end local State = require("src.core.game3.battle.state") - local ok, b = pcall(State.makeBattler, mon, side, { partyIndex = slot, id = id }) + local ok, b = pcall(State.makeBattler, mon, side, { state = st, partyIndex = slot, id = id }) return ok and b or nil end diff --git a/src/core/game3/battle/effects/secondary.lua b/src/core/game3/battle/effects/secondary.lua index 61e81e74..acaa3db1 100644 --- a/src/core/game3/battle/effects/secondary.lua +++ b/src/core/game3/battle/effects/secondary.lua @@ -464,10 +464,8 @@ function Secondary.set(M, eff, primary, certain, affectsUser) end if tItem == 0 then return false end effBattler.item = 0 - -- The battler is a battle-local view: State.makeBattler rebuilds `item` - -- from held_item(mon), so clearing only the battler lets the knocked-off - -- item return on the next send-out. Write the removal through to the mon. - persist_item(effBattler, 0) + -- pokefirered/src/battle_script_commands.c:2750,4489 + -- Keep the party item; the battle mask suppresses it on later send-outs. effBattler.expKnockedOff = true local St = battle_state() if St then St.markKnockedOff(ad._st, effBattler) end diff --git a/src/core/game3/battle/engine.lua b/src/core/game3/battle/engine.lua index c33e95c9..9932e9ba 100644 --- a/src/core/game3/battle/engine.lua +++ b/src/core/game3/battle/engine.lua @@ -1887,7 +1887,7 @@ function Engine.performSwitch(st, adapter, side, slot, opts) end Engine.switchOutEffects(st, adapter, old) State.syncBattlerToParty(old, party) - local nb = State.makeBattler(party[slot], side, { partyIndex = slot, id = id }) + local nb = State.makeBattler(party[slot], side, { state = st, partyIndex = slot, id = id }) if opts.batonPass then -- pokefirered/src/battle_main.c:2350 for k, v in pairs(old.stages or {}) do nb.stages[k] = v end diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index e0350c0f..a962f468 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -2699,8 +2699,7 @@ function Battle.update(dt, game) local Naming = package.loaded["src.ui.game3.naming"] if Naming and Naming.isOpen and Naming.isOpen() then - if input then Naming.handleInput(input) end - Naming.update(dt or (1 / 60)) + -- Runtime ticks Hud after Battle; the naming stack entry owns this input. return end diff --git a/src/core/game3/battle/state.lua b/src/core/game3/battle/state.lua index b15e3b40..d829a93a 100644 --- a/src/core/game3/battle/state.lua +++ b/src/core/game3/battle/state.lua @@ -39,7 +39,7 @@ function State.makeBattler(mon, side, opts) if not ability and Pokemon.abilityId then ability = Pokemon.abilityId(species, mon.personality or 0) end - return { + local battler = { mon = mon, id = id, side = side, -- "player" | "enemy" @@ -59,6 +59,9 @@ function State.makeBattler(mon, side, opts) -- pokefirered/src/battle_main.c:2228 isFirstTurn = 2, } + -- pokefirered/src/battle_script_commands.c:4489 + if State.isKnockedOff(opts.state, battler) then battler.item = 0 end + return battler end -- pokefirered/src/battle_main.c:2565 diff --git a/src/core/game3/battle/switch_seq.lua b/src/core/game3/battle/switch_seq.lua index 71a66b6e..57536873 100644 --- a/src/core/game3/battle/switch_seq.lua +++ b/src/core/game3/battle/switch_seq.lua @@ -225,7 +225,7 @@ function SwitchSeq.beginPlayerSwitch(st, newSlot, opts) State.trackParticipant(st, st.enemy, oldBattler and oldBattler.partyIndex or 1) State.syncBattlerToParty(st.player, st.playerParty) State.wipeVolatilesAndStages(st.player, { batonPass = opts.batonPass }) - st.player = State.makeBattler(st.playerParty[newSlot], "player", { partyIndex = newSlot }) + st.player = State.makeBattler(st.playerParty[newSlot], "player", { state = st, partyIndex = newSlot }) State.trackParticipant(st, st.enemy, newSlot) Anim.syncDisplayFromState(st) local newName = State.displayName(st.player) @@ -263,14 +263,14 @@ function SwitchSeq.beginSendOut(st, side, newSlot, opts) if SwitchSeq._headless then if side == "player" then - st.player = State.makeBattler(st.playerParty[newSlot], "player", { partyIndex = newSlot }) + st.player = State.makeBattler(st.playerParty[newSlot], "player", { state = st, partyIndex = newSlot }) State.trackParticipant(st, st.enemy, newSlot) Anim.syncDisplayFromState(st) if SwitchSeq._pushMsg then SwitchSeq._pushMsg(Strings("Go! %s!", State.displayName(st.player))) end else - st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot }) + st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { state = st, partyIndex = newSlot }) Anim.syncDisplayFromState(st) if SwitchSeq._pushMsg then local tname = (st.trainerClassName and st.trainerClassName ~= "") @@ -422,12 +422,12 @@ function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts) State.trackParticipant(st, st.enemy, oldBattler and oldBattler.partyIndex or 1) State.syncBattlerToParty(st.player, st.playerParty) State.wipeVolatilesAndStages(st.player) - st.enemy = State.makeBattler(st.foeParty[enemySlot], "enemy", { partyIndex = enemySlot }) + st.enemy = State.makeBattler(st.foeParty[enemySlot], "enemy", { state = st, partyIndex = enemySlot }) local tname = (st.trainerClassName and st.trainerClassName ~= "") and (st.trainerClassName .. " " .. (st.trainerName or "")) or (st.trainerName or "TRAINER") if SwitchSeq._pushMsg then SwitchSeq._pushMsg(Strings("%s sent\nout %s!", tname, State.displayName(st.enemy))) end - st.player = State.makeBattler(st.playerParty[playerSlot], "player", { partyIndex = playerSlot }) + st.player = State.makeBattler(st.playerParty[playerSlot], "player", { state = st, partyIndex = playerSlot }) State.trackParticipant(st, st.enemy, playerSlot) Anim.syncDisplayFromState(st) if SwitchSeq._pushMsg then SwitchSeq._pushMsg(Strings("Go! %s!", State.displayName(st.player))) end @@ -568,14 +568,14 @@ local function run_step(step) State.syncBattlerToParty(st.player, st.playerParty) State.wipeVolatilesAndStages(st.player, { batonPass = d.batonPass }) end - st.player = State.makeBattler(st.playerParty[newSlot], "player", { partyIndex = newSlot }) + st.player = State.makeBattler(st.playerParty[newSlot], "player", { state = st, partyIndex = newSlot }) State.trackParticipant(st, st.enemy, newSlot) else if st and st.enemy then State.syncBattlerToParty(st.enemy, st.foeParty) State.wipeVolatilesAndStages(st.enemy) end - st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot }) + st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { state = st, partyIndex = newSlot }) end Anim.syncDisplayFromState(st) advance() diff --git a/src/core/game3/runtime.lua b/src/core/game3/runtime.lua index 48a57a1e..ebfdf474 100644 --- a/src/core/game3/runtime.lua +++ b/src/core/game3/runtime.lua @@ -228,6 +228,7 @@ function Runtime.update(dt) if not Runtime.active then return end local game = Runtime._game local Hud = require("src.ui.game3.hud") + local inputTop = require("src.ui.game3.stack").top() or false local inMenu = Hud.isMenuOpen and Hud.isMenuOpen() or false Runtime.drainDeferred() Runtime.noteFieldFocus(Runtime.fieldScreenOpen(inMenu)) @@ -270,21 +271,15 @@ function Runtime.update(dt) end local Message = package.loaded["src.ui.game3.message"] if Message and Message.tick then Message.tick() end - Hud.update(game, dt) + Hud.update(game, dt, inputTop) return end - local okN, Naming = pcall(require, "src.ui.game3.naming") - if okN and Naming.isOpen and Naming.isOpen() then - Naming.handleInput(game and game.input) - Naming.update(dt) - end - if not inMenu then local Field = require("src.core.game3.field") Field.update(dt) end - Hud.update(game, dt) + Hud.update(game, dt, inputTop) end function Runtime.uiBusy() diff --git a/src/core/game3/scripting/ops_a.lua b/src/core/game3/scripting/ops_a.lua index 95df1e97..b5fa0210 100644 --- a/src/core/game3/scripting/ops_a.lua +++ b/src/core/game3/scripting/ops_a.lua @@ -55,13 +55,11 @@ end -- Script local scratch space: pret's ScriptContext.data[4] (include/script.h:21). local function local_get(ctx, i) - ctx.locals = ctx.locals or {} - return tonumber(ctx.locals[(tonumber(i) or 0) + 1]) or 0 + return ctx.data[tonumber(i) or 0] or 0 end local function local_set(ctx, i, v) - ctx.locals = ctx.locals or {} - ctx.locals[(tonumber(i) or 0) + 1] = tonumber(v) or 0 + ctx.data[tonumber(i) or 0] = v or 0 end -- The port has no flat address space, so the *ptr family shares a synthetic @@ -74,12 +72,13 @@ end local function mem_set(ctx, ptr, v) ctx.scriptMem = ctx.scriptMem or {} - ctx.scriptMem[tonumber(ptr) or 0] = tonumber(v) or 0 + ctx.scriptMem[tonumber(ptr) or 0] = (tonumber(v) or 0) % 256 end -- pret src/scrcmd.c:358 Compare() local function cmp(a, b) - a, b = tonumber(a) or 0, tonumber(b) or 0 + -- pokefirered/src/scrcmd.c:368: local comparisons read the low byte. + a, b = (tonumber(a) or 0) % 256, (tonumber(b) or 0) % 256 if a < b then return 0 end if a == b then return 1 end return 2 diff --git a/src/ui/game3/hud.lua b/src/ui/game3/hud.lua index 0e0251e2..3ed6d882 100644 --- a/src/ui/game3/hud.lua +++ b/src/ui/game3/hud.lua @@ -174,11 +174,17 @@ function Hud.sampleFieldInput(game) } end -function Hud.update(game, _dt) +function Hud.update(game, _dt, inputTop) local dt = tonumber(_dt) or (1 / 60) -- Active stack modal menu tick local top = Stack.top() + local namingTick = top and top.id == "naming" + if namingTick and top.mod and top.mod.handleInput then + -- Naming consumes input before its page-swap timer can unlock the keyboard. + -- A prompt that opened it during this frame keeps its opening button press. + if inputTop == nil or top == inputTop then top.mod.handleInput(game and game.input) end + end if top and top.mod and top.mod.update then pcall(top.mod.update, dt) end @@ -213,6 +219,9 @@ function Hud.update(game, _dt) end end + -- Do not replay naming input or leak its closing press to the menu underneath. + if namingTick then return end + -- Active stack modal menu input takes top precedence. -- When battle is active, overlays like EvolutionScene or modal stack menus still receive input. if Stack.busy() then diff --git a/tests/drivers/game3_stitchuif_naming_pc.lua b/tests/drivers/game3_stitchuif_naming_pc.lua index 9106758a..a1b3e184 100644 --- a/tests/drivers/game3_stitchuif_naming_pc.lua +++ b/tests/drivers/game3_stitchuif_naming_pc.lua @@ -121,6 +121,7 @@ return function(game) end U.shot(game, DIR .. "/stitchuif_naming_pc_01_keyboard.png") typedName = (Naming._state and Naming._state.name) or "" + result(typedName == "AAA", "three A taps type exactly AAA, got " .. typedName) U.tap(game, "start") U.wait(20) U.tap(game, "a") diff --git a/tests/engine/game3_knock_off_item_test.lua b/tests/engine/game3_knock_off_item_test.lua index de45cc0e..a4c235e5 100644 --- a/tests/engine/game3_knock_off_item_test.lua +++ b/tests/engine/game3_knock_off_item_test.lua @@ -1,13 +1,5 @@ --- KNOCK_OFF must persist the removed item to the party mon. --- --- Regression: the KNOCK_OFF branch of Secondary.set cleared effBattler.item and --- set effBattler.expKnockedOff, but never wrote through to the party mon. The --- battler is a battle-local view: State.makeBattler rebuilds `item` from --- held_item(mon) on the next send-out, so the knocked-off item came back on --- switch-out (and could then be stolen or knocked off again). --- --- persist_item resolves the party mon via State.partyMon(b) (= b._partyMon or --- b.mon), so this drives the real effect with plain battler tables. +-- pokefirered/src/battle_script_commands.c:2750,4489 +-- Knock Off suppresses the battle copy across switches, preserving the party item. -- luajit tests/engine/game3_knock_off_item_test.lua package.path = "./?.lua;./?/init.lua;" .. package.path @@ -18,8 +10,14 @@ love = love or require("tests.love_stub") local Secondary = require("src.core.game3.battle.effects.secondary") -local function adapter() +local State = require("src.core.game3.battle.state") +local Engine = require("src.core.game3.battle.engine") +local SwitchSeq = require("src.core.game3.battle.switch_seq") + +local function adapter(st) return { + _st = st, + pushEvent = function() end, abilityOf = function(_, b) return b.ability end, hp = function(_, b) return b.hp or 100 end, ownSide = function() return nil end, @@ -41,26 +39,25 @@ local function battler(side, item, ability) } end -local function knock_off(user, target) +local function knock_off(user, target, st) return Secondary.set({ - adapter = adapter(), user = user, target = target, + adapter = adapter(st), user = user, target = target, move = { moveName = "KNOCK OFF" }, }, "KNOCK_OFF", false, true, false) end --- 1. A knocked-off item must not survive on the party mon, or it returns on --- switch-out (State.makeBattler reads held_item(mon)). +-- 1. The party keeps the item for future battles. local user, target = battler("enemy", 0), battler("player", 13) check(knock_off(user, target), "KNOCK_OFF reports the item was removed") eq(target.item, 0, "the battler's item is cleared") -eq(target.mon.item, nil, "the party mon no longer holds the item (does not return on switch-out)") -eq(target.mon.heldItem, nil, "heldItem is cleared too") +eq(target.mon.item, 13, "the party retains its item") +eq(target.mon.heldItem, 13, "the heldItem alias is retained too") --- 2. The enemy side persists as well. +-- 2. The enemy party retains its item too. local user2, target2 = battler("player", 0), battler("enemy", 13) knock_off(user2, target2) eq(target2.item, 0, "the enemy battler's item is cleared") -eq(target2.mon.item, nil, "the enemy party mon no longer holds the item") +eq(target2.mon.item, 13, "the enemy party mon retains its item") -- 3. STICKY_HOLD refuses and must leave the item intact everywhere. local user3, target3 = battler("enemy", 0), battler("player", 13, "STICKY_HOLD") @@ -72,4 +69,35 @@ eq(target3.mon.item, 13, "STICKY_HOLD keeps the party item") local user4, target4 = battler("enemy", 0), battler("player", 0) check(not knock_off(user4, target4), "a target with no item is a no-op") +local function mon(item) + return { species = 4, level = 10, hp = 30, maxHp = 30, + item = item, heldItem = item, ability = "NONE", moves = { 10 }, pp = { 35 } } +end +for _, side in ipairs({ "player", "enemy" }) do + local party, foes = { mon(13), mon(14) }, { mon(13), mon(14) } + local st = State.new({ playerParty = party, foeParty = foes }) + local victim = st[side] + local foe = side == "player" and st.enemy or st.player + local members = side == "player" and party or foes + knock_off(foe, victim, st) + check(State.isKnockedOff(st, victim), "the victim's party slot is marked") + check(not State.isKnockedOff(st, foe), "the opposite side's same slot is unaffected") + Engine.performSwitch(st, adapter(st), side, 2) + eq(st[side].item, 14, "another party slot keeps its active item") + Engine.performSwitch(st, adapter(st), side, 1) + eq(st[side].item, 0, "engine switch-in suppresses the knocked-off item") + eq(members[1].heldItem, 13, "switching preserves the party item") + SwitchSeq.beginSendOut(st, side, 1, { headless = true }) + eq(st[side].item, 0, "presentation send-out also suppresses the item") + if side == "player" then + SwitchSeq.beginPlayerSwitch(st, 1, { headless = true }) + eq(st.player.item, 0, "player switch sequence suppresses the item") + end + SwitchSeq.beginShiftSwitch(st, 1, 1, { headless = true }) + eq(st[side].item, 0, "shift switch sequence suppresses the item") + local fresh = State.new({ playerParty = party, foeParty = foes }) + eq(fresh[side].item, 13, "the original held item works in the next battle") + check(not State.isKnockedOff(fresh, fresh[side]), "the new battle has no knock-off mark") +end + T.finish("game3_knock_off_item_test") diff --git a/tests/engine/game3_naming_update_contract_test.lua b/tests/engine/game3_naming_update_contract_test.lua index 96710686..7cd4eca8 100644 --- a/tests/engine/game3_naming_update_contract_test.lua +++ b/tests/engine/game3_naming_update_contract_test.lua @@ -62,4 +62,46 @@ st4.swapT = 0 pcall(Naming.handleInput, input({ down = true })) eq(st4.row, 1, "input is ignored while the page swap is running") +-- The real runtime must leave stack input and timers to Hud in both branches. +local Runtime = require("src.core.game3.runtime") +local Battle = require("src.core.game3.battle") +Runtime.active = true +Runtime.session = {} +for _, inBattle in ipairs({ false, true }) do + Battle._active = inBattle + local active = open_naming() + Runtime._game = { input = input({ a = true }) } + Runtime.update(1 / 60) + eq(active.name, "A", "one runtime press adds one character, battle=" .. tostring(inBattle)) + eq(active.blink, 1 / 60, "runtime advances the naming timer once") + active.pcPages = { "first", "second", "third" } + active.pcPage = 1 + Runtime.update(1 / 60) + eq(active.pcPage, 2, "one runtime press advances one result page") + local swapping = open_naming() + swapping.swapT = 124 + swapping.swapTo = 2 + Runtime.update(1 / 60) + eq(swapping.name, "", "input remains blocked on the swap-completion frame") + eq(swapping.swapT, nil, "the page swap finishes") + Runtime.update(1 / 60) + eq(swapping.name, "a", "the next frame accepts input on the new page") +end +Battle._active = false +Stack.clear() +-- A prompt that opens Naming during the battle update must consume its A. +Battle._active = true +local battleUpdate = Battle.update +local opened +Battle.update = function() opened = open_naming() end +Runtime._game = { input = input({ a = true }) } +Runtime.update(1 / 60) +eq(opened.name, "", "the opening prompt's A does not type a character") +Battle.update = battleUpdate +Runtime.update(1 / 60) +eq(opened.name, "A", "the following frame's A reaches naming") +Battle._active = false +Runtime.active = false +Stack.clear() + T.finish("game3_naming_update_contract_test") diff --git a/tests/engine/game3_script_verbs_subset_test.lua b/tests/engine/game3_script_verbs_subset_test.lua index 01e12380..65246579 100644 --- a/tests/engine/game3_script_verbs_subset_test.lua +++ b/tests/engine/game3_script_verbs_subset_test.lua @@ -207,20 +207,22 @@ end -- 16. Script locals and the synthetic pointer store (src/scrcmd.c:293-375). vm = new_vm() -vm.ctx.locals = { 10, 20 } +Ops.dispatch(vm, { op = "loadbyte", 0, 10 }) +Ops.dispatch(vm, { op = "loadbyte", 1, 20 }) Ops.dispatch(vm, { op = "copylocal", [1] = 0, [2] = 1 }) -eq(vm.ctx.locals[1], 20, "copylocal copies a local") -vm.ctx.locals = { 5, 9 } +eq(vm.ctx.data[0], 20, "copylocal copies a local") +Ops.dispatch(vm, { op = "loadbyte", 0, 5 }) +Ops.dispatch(vm, { op = "loadbyte", 1, 9 }) Ops.dispatch(vm, { op = "compare_local_to_local", [1] = 0, [2] = 1 }) eq(vm.ctx.comparisonResult, 0, "compare_local_to_local reports LT") -vm.ctx.locals = { 7, 7 } +Ops.dispatch(vm, { op = "loadbyte", 0, 7 }) Ops.dispatch(vm, { op = "compare_local_to_value", [1] = 0, [2] = 7 }) eq(vm.ctx.comparisonResult, 1, "compare_local_to_value reports EQ") Ops.dispatch(vm, { op = "setptr", [1] = 42, [2] = 0x1234 }) eq(vm.ctx.scriptMem[0x1234], 42, "setptr writes the synthetic byte store") Ops.dispatch(vm, { op = "loadbytefromptr", [1] = 1, [2] = 0x1234 }) -eq(vm.ctx.locals[2], 42, "loadbytefromptr reads it back into a local") -vm.ctx.locals[3] = 9 +eq(vm.ctx.data[1], 42, "loadbytefromptr reads it back into a local") +Ops.dispatch(vm, { op = "loadbyte", 2, 9 }) Ops.dispatch(vm, { op = "setptrbyte", [1] = 2, [2] = 0x1235 }) eq(vm.ctx.scriptMem[0x1235], 9, "setptrbyte stores a local") Ops.dispatch(vm, { op = "copybyte", [1] = 0x1236, [2] = 0x1235 }) @@ -230,6 +232,18 @@ eq(vm.ctx.comparisonResult, 0, "compare_local_to_ptr compares local vs store") Ops.dispatch(vm, { op = "compare_ptr_to_ptr", [1] = 0x1234, [2] = 0x1236 }) eq(vm.ctx.comparisonResult, 2, "compare_ptr_to_ptr compares two stored bytes") +Ops.dispatch(vm, { op = "loadword", 0, 0x12AB }) +Ops.dispatch(vm, { op = "copylocal", 3, 0 }) +eq(vm.ctx.data[3], 0x12AB, "copylocal preserves the full loadword register") +Ops.dispatch(vm, { op = "compare_local_to_value", 3, 0xAB }) +eq(vm.ctx.comparisonResult, 1, "local comparisons read the low byte") +Ops.dispatch(vm, { op = "setptrbyte", 3, 0x1237 }) +Ops.dispatch(vm, { op = "loadbytefromptr", 0, 0x1237 }) +eq(vm.ctx.data[0], 0xAB, "pointer writes truncate a word register to a byte") +Ops.dispatch(vm, { op = "loadword", 0, "std:1" }) +Ops.dispatch(vm, { op = "copylocal", 1, 0 }) +eq(vm.ctx.data[1], "std:1", "copylocal preserves the host's symbolic text pointers") + -- 17. The RAM-script (v*) control flow (src/scrcmd.c:171-209, :1580). vm = new_vm() vm:setPc("t_main", 4) From f6108a98417eab46000af3eb0f51d5f41f482009 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 22 Sep 2026 06:06:13 -0400 Subject: [PATCH 2/2] release note add --- .github/workflows/release.yml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6630c26..c43af489 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -825,15 +825,34 @@ jobs: # Everyone whose commits are in the range: GitHub login when the # commit is linked to an account, the raw git author name when not; # CI bots filtered out. - base="${prev_tag:-$(git rev-list --max-parents=0 "$GITHUB_SHA" | tail -1)}" + if [ -n "$prev_tag" ]; then + commits_endpoint="repos/$GITHUB_REPOSITORY/compare/${prev_tag}...${GITHUB_SHA}?per_page=100" + else + # No previous release: include all reachable commits, even the root. + commits_endpoint="repos/$GITHUB_REPOSITORY/commits?sha=${GITHUB_SHA}&per_page=100" + fi contributors="$(gh api --paginate \ - "repos/$GITHUB_REPOSITORY/compare/${base}...${GITHUB_SHA}" \ - --jq '.commits[] | if .author and .author.login + "$commits_endpoint" \ + --jq '(if type == "array" then . else .commits end)[] + | if .author and .author.login then "@" + .author.login else .commit.author.name end' 2>/dev/null \ | grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)" + # Use the dev -> main promotion PR for this release commit, not an + # individual feature PR whose commits also happen to be in the range. + promotion_body="$(gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/pulls?per_page=100" 2>/dev/null \ + | jq -r --arg repo "$GITHUB_REPOSITORY" --arg sha "$GITHUB_SHA" ' + [.[][] | select(.merged_at != null + and .base.repo.full_name == $repo and .base.ref == "main" + and .head.repo.full_name == $repo and .head.ref == "dev" + and .merge_commit_sha == $sha)] + | sort_by(.merged_at) | last | .body // ""' || true)" notes="Download the right version for your device below." + if [ -n "$(printf '%s' "$promotion_body" | tr -d '[:space:]')" ]; then + notes="$promotion_body" + fi if [ -n "$closed" ]; then notes+=$'\n\n## Issues closed\n\n'"$closed" fi