Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
caac4c0
chore(release): v1.1.0-BETA.1 changelog
catapult-counterweight[bot] Nov 10, 2026
d2a3cd2
chore(release): v1.1.0 changelog
catapult-counterweight[bot] Nov 10, 2026
b1f56be
feat(api): tell the settings whether the title's conditions and drops…
enimaloc Nov 11, 2026
d85167e
fix(api): explain each title condition and the drops in the help, onl…
enimaloc Nov 11, 2026
b5ad1b1
fix(web): name the title's conditions and drop variables under its fi…
enimaloc Nov 11, 2026
939704b
feat(api): let an admin game mapping ignore its game by default
enimaloc Nov 11, 2026
aac9d6e
feat(web): ignore a game by default from the admin game mappings
enimaloc Nov 11, 2026
8478f18
fix(api): read DTDD's new topic severity instead of failing every ite…
enimaloc Nov 12, 2026
5f54d94
feat(api): expose each DTDD topic's severity in the public game detail
enimaloc Nov 12, 2026
0b11316
feat(api): track each DTDD key's remaining quota and let bulk work ke…
enimaloc Nov 12, 2026
8ed75a3
feat(api): list the DTDD caches for the admins and refetch them under…
enimaloc Nov 12, 2026
6f7012c
feat(web): add an admin tab for the DTDD caches with a refetch per en…
enimaloc Nov 12, 2026
458d771
feat(api): give the admins everything the DTDD caches keep about one …
enimaloc Nov 12, 2026
525ecd0
feat(web): open a DTDD cache entry in full from the admin tab
enimaloc Nov 12, 2026
67508d9
feat(api): keep each cached DTDD topic's severity and show it to the …
enimaloc Nov 12, 2026
0bb16dc
feat(web): show each DTDD topic's severity in a wider entry dialog
enimaloc Nov 12, 2026
2092d4c
feat(api): let a DTDD topic's severity keep its TW off below the mapp…
enimaloc Nov 12, 2026
a3656a7
feat(api): let the admins read and set each DTDD topic's least severe…
enimaloc Nov 12, 2026
9c099d8
feat(web): edit a TW's DTDD topics and their least severe level from …
enimaloc Nov 12, 2026
6e8c455
feat(api): tell a streamer why each of a binding's TWs is on
enimaloc Nov 12, 2026
2c1e5d9
feat(web): show a streamer why each of a binding's TWs is on, in a de…
enimaloc Nov 12, 2026
4bbbc96
fix(api): leave the Steam store alone after a 429, for its Retry-Afte…
enimaloc Nov 12, 2026
d7170cb
fix(api): resolve a Steam game's TWs again once the store's rate limi…
enimaloc Nov 12, 2026
1ae844a
feat(api): run one TW recalculation at a time, follow and cancel it, …
enimaloc Nov 12, 2026
9f3102f
feat(web): show the TW recalculation's progress in the admin tab, and…
enimaloc Nov 12, 2026
e13f736
refactor(api): inject the binding service's event publisher through i…
enimaloc Nov 12, 2026
4dab0bc
feat: let an admin game mapping ignore its game by default
enimaloc Nov 12, 2026
859daae
fix(api): decide the title help's parts in a service, not in the cont…
enimaloc Nov 12, 2026
375f54a
chore: merge dev into feat/dtdd-cache-admin
enimaloc Nov 12, 2026
9ee1eef
chore: merge dev into feat/tw-dtdd-severity
enimaloc Nov 12, 2026
11e0f6e
fix: show the title's conditions and drop variables in its help when …
enimaloc Nov 12, 2026
63a3ae7
fix(api): read DTDD's new topic severity instead of failing every ite…
enimaloc Nov 12, 2026
025d804
fix(web): write the DTDD caches' API paths in their calls
enimaloc Nov 12, 2026
bc3ce92
test(web): cover the DTDD caches calls of the real and mock API services
enimaloc Nov 12, 2026
62bf3a7
chore: merge feat/dtdd-cache-admin into feat/tw-dtdd-severity
enimaloc Nov 12, 2026
6cf1834
test(web): cover the TW details and DTDD topics calls of the real and…
enimaloc Nov 12, 2026
e6a9391
chore: merge dev into feat/dtdd-cache-admin
enimaloc Nov 12, 2026
939f5f2
chore: merge feat/dtdd-cache-admin into feat/tw-dtdd-severity
enimaloc Nov 12, 2026
bc730cc
chore: merge feat/tw-dtdd-severity into feat/tw-rebuild-progress
enimaloc Nov 12, 2026
4f681f3
feat: an admin tab for the DTDD caches, with each topic's severity
enimaloc Nov 12, 2026
45ffb48
feat: let a DTDD topic's severity decide its TW, and tell streamers w…
enimaloc Nov 12, 2026
e9b65e8
test(common): cover the admin DTOs' shorter constructors and older pa…
enimaloc Nov 12, 2026
3a9d91d
feat: follow the TW recalculation and respect Steam's rate limit
enimaloc Nov 12, 2026
cc3231d
feat(ratelimit): add route keys and caller priority
enimaloc Nov 12, 2026
cd059a6
feat(ratelimit): learn limits and resume delays from 429s
enimaloc Nov 12, 2026
a62732a
feat(ratelimit): persist learned rate limits
enimaloc Nov 12, 2026
434b489
fix(web): stop completing an SSE stream the container already failed
enimaloc Nov 12, 2026
8ff3dc2
chore(release): promote dev straight to main, skipping beta
enimaloc Nov 12, 2026
353eaaf
chore(release): auto-merge the backports that bring a release back to…
enimaloc Nov 12, 2026
89bb860
ci: run the checks on pull requests only and cancel superseded runs
enimaloc Nov 12, 2026
9029240
fix(api): leave the query string out of the http.client.requests uri tag
enimaloc Nov 12, 2026
3a1bc1c
fix(api): never take a Twitch website URL for a binding's category id
enimaloc Nov 12, 2026
4465212
feat(ratelimit): throttle routes by learned limits with live priority
enimaloc Nov 12, 2026
6694be6
feat(ratelimit): apply learned limits to outbound HTTP calls
enimaloc Nov 12, 2026
e4227c5
feat(ratelimit): bound the routes per host and learn only from window…
enimaloc Nov 12, 2026
da95d94
refactor(steam): rely on learned rate limits for the store
enimaloc Nov 12, 2026
8f4041a
fix(xbox): use a URI template for the store's product calls
enimaloc Nov 12, 2026
ca73b99
feat(tw): run the TW recalculation as bulk traffic under the learned …
enimaloc Nov 12, 2026
0e7496d
chore: merge ci-skip-beta into dev
enimaloc Nov 12, 2026
1f3f8db
feat(admin): expose the learned rate limits in the admin API
enimaloc Nov 12, 2026
48cac62
refactor(ratelimit): name the host row check hostScope so JSON keeps …
enimaloc Nov 12, 2026
e55fe64
feat(admin): a tab to see, correct and reset the learned rate limits
enimaloc Nov 12, 2026
4a7cd42
fix(ratelimit): fall back to the system clock in profiles without a C…
enimaloc Nov 12, 2026
142d52d
fix: merge fix/1.1.1 into dev
enimaloc Nov 12, 2026
3add1c0
fix(ratelimit): keep admin changes across instances and learn no tiny…
enimaloc Nov 12, 2026
1f6de35
fix(steam): let only live appdetails refusals stop the store, back of…
enimaloc Nov 12, 2026
e83f87b
fix(admin): give a route's rate limit scope back to the learning
enimaloc Nov 12, 2026
47477c9
feat: merge learned rate limits into dev
enimaloc Nov 12, 2026
7a06d13
chore(release): backport v1.1.0 to dev
enimaloc Nov 12, 2026
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
2 changes: 1 addition & 1 deletion .github/release/dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { createGitHub } from "./lib/github.mjs";
const MARKER = "<!-- release-dashboard -->";
const TITLE = "🚀 Releases en cours";
const LABEL = "release";
const PAIRS = new Set(["dev>beta", "beta>main"]);
const PAIRS = new Set(["dev>main", "dev>beta", "beta>main"]);
const TITLE_VERSION = /^chore\(release\): (v\S+)$/;

// GitHub turns #N into a link to the pull request.
Expand Down
21 changes: 21 additions & 0 deletions .github/release/lib/flow.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// The promotion chain. Until a v2 (maybe never), `beta` is skipped: dev is promoted straight to main
// as a stable, and a release is brought back to dev straight from main.
// To bring beta back: set BETA to true here, then put `beta` back in promote.yml's push branches (the
// workflows' other conditions accept both chains).

export const BETA = false;

/** The promotions the bot opens, in release order. */
export function promotions(beta = BETA) {
return beta
? [
{ source: "dev", target: "beta" },
{ source: "beta", target: "main" },
]
: [{ source: "dev", target: "main" }];
}

/** The branch `source` is promoted to, or null when it is not promoted. */
export function promotionTarget(source, beta = BETA) {
return promotions(beta).find((p) => p.source === source)?.target ?? null;
}
17 changes: 16 additions & 1 deletion .github/release/lib/version.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Version computation for the dev -> beta -> main release flow.
// Version computation for the release flow: dev -> main while beta is skipped (lib/flow.mjs), else
// dev -> beta -> main.
// Tags are `vX.Y.Z` (stable) and `vX.Y.Z-BETA.N` (pre-release).

const TAG = /^v(\d+)\.(\d+)\.(\d+)(?:-BETA\.(\d+))?$/;
Expand Down Expand Up @@ -74,6 +75,20 @@ export function nextBeta(lastStable, betaTags, subjects) {
return format(target, n);
}

/**
* Next stable promoted straight from dev (beta skipped): lastStable bumped by the commits, or the highest
* target among the betas newer than lastStable when that is higher; null when no commit is releasable.
*/
export function nextDirectStable(lastStable, betaTags, subjects) {
const kind = bumpKind(subjects, lastStable);
if (kind === null) return null;
let target = bump(parseVersion(lastStable), kind);
for (const beta of betaTags.map(parseVersion)) {
if (beta.beta !== null && compare(beta, target) > 0) target = beta;
}
return format(target);
}

export function nextStable(betaTag) {
const version = parseVersion(betaTag);
if (version.beta === null) throw new Error(`Not a beta tag: ${betaTag}`);
Expand Down
25 changes: 16 additions & 9 deletions .github/release/promote.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// Opens (in draft) or refreshes the single promotion PR from `source` to `target`.
// CLI: node .github/release/promote.mjs --source dev --target beta
// (env: GH_TOKEN, GITHUB_REPOSITORY=owner/repo, APP_SLUG = the release App's slug)
// CLI: node .github/release/promote.mjs --source dev [--target main]
// (--target defaults to the source's next branch in lib/flow.mjs; env: GH_TOKEN,
// GITHUB_REPOSITORY=owner/repo, APP_SLUG = the release App's slug)

import { parseArgs } from "node:util";
import { pathToFileURL } from "node:url";
import { parseVersion, bumpKind, nextBeta, nextStable } from "./lib/version.mjs";
import { parseVersion, bumpKind, nextBeta, nextDirectStable, nextStable } from "./lib/version.mjs";
import { promotionTarget } from "./lib/flow.mjs";
import { buildBody, parseBody, scheduledText, seenId, setPromotedHead, setScheduledText, updateBody } from "./lib/pr-body.mjs";
import { activeSchedule, NO_SCHEDULE } from "./lib/schedule.mjs";
import { createGitHub } from "./lib/github.mjs";
Expand Down Expand Up @@ -37,16 +39,20 @@ export function compareVersions(a, b) {
}

/**
* Stable for a promotion to `main`, beta otherwise; null when there is nothing to release. `commits`
* are the promoted ones (release commits left out). ready.mjs checks the PR title against it.
* Stable for a promotion to `main` (dev -> main bumps the last stable, beta -> main finishes the last
* beta), beta otherwise; null when there is nothing to release. `commits` are the promoted ones
* (release commits left out). ready.mjs checks the PR title against it.
*/
export function releaseVersion(target, tags, commits) {
export function releaseVersion(source, target, tags, commits) {
const versions = versionTags(tags).sort(compareVersions);
const stables = versions.filter((v) => v.beta === null);
const lastStable = stables.length ? stables[stables.length - 1] : { tag: "v0.0.0", ...parseVersion("v0.0.0") };
const betas = versions.filter((v) => v.beta !== null && compareVersions(v, lastStable) > 0);
if (target === "main") return betas.length ? nextStable(betas[betas.length - 1].tag) : null;
const messages = commits.map((c) => c.message);
if (target === "main" && source === "dev") {
return nextDirectStable(lastStable.tag, betas.map((v) => v.tag), messages);
}
if (target === "main") return betas.length ? nextStable(betas[betas.length - 1].tag) : null;
if (bumpKind(messages, lastStable.tag) === null && betas.length === 0) return null;
return nextBeta(lastStable.tag, betas.map((v) => v.tag), messages);
}
Expand Down Expand Up @@ -97,7 +103,7 @@ export async function promote({ gh, source, target, owner, repo, appSlug }) {
return { action: "closed", number: pull.number };
}

const version = releaseVersion(target, await gh.listTags(), commits);
const version = releaseVersion(source, target, await gh.listTags(), commits);
if (version === null) return withNumber("none");
const title = `chore(release): ${version}`;

Expand Down Expand Up @@ -137,7 +143,8 @@ export async function promote({ gh, source, target, owner, repo, appSlug }) {

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
const { values } = parseArgs({ options: { source: { type: "string" }, target: { type: "string" } } });
if (!values.source || !values.target) throw new Error("Usage: promote.mjs --source <branch> --target <branch>");
values.target ??= values.source ? (promotionTarget(values.source) ?? undefined) : undefined;
if (!values.source || !values.target) throw new Error("Usage: promote.mjs --source <branch> [--target <branch>]");
const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? "").split("/");
const gh = createGitHub({ token: process.env.GH_TOKEN, owner, repo });
const result = await promote({ ...values, gh, owner, repo, appSlug: process.env.APP_SLUG });
Expand Down
59 changes: 43 additions & 16 deletions .github/release/publish.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// A promotion (or hotfix) PR was merged: tag its merge commit, and after a stable or a hotfix open
// the backport PRs that bring it back to dev (main -> beta, then beta -> dev once that one is merged). The tag is pushed with the App token, so its push event runs
// the backport PRs that bring it back to dev, with auto-merge (main -> dev while beta is skipped, else
// main -> beta, then beta -> dev once that one is merged). The tag is pushed with the App token, so its push event runs
// release.yml, the single publication path (it reads the PR's notes itself). After a beta, the beta -> main
// promotion is retitled by publish.yml's own job, in the promote-beta concurrency group, not here.
// CLI: node .github/release/publish.mjs --number 12
Expand All @@ -10,6 +11,7 @@ import { pathToFileURL } from "node:url";
import { createGitHub } from "./lib/github.mjs";
import { refreshDashboardSafely } from "./dashboard.mjs";
import { kindOf, titleVersion } from "./ready.mjs";
import { BETA } from "./lib/flow.mjs";

const short = (sha) => sha.slice(0, 7);
const pattern = (kind) => (kind === "beta" ? "vX.Y.Z-BETA.N" : "vX.Y.Z");
Expand All @@ -32,40 +34,63 @@ function isBackportToBeta(pr, repository) {
return pr.headRepo === repository && pr.head === "main" && pr.base === "beta" && pr.labels?.includes("backport");
}

function backportBody(tag, pr, from, to) {
function backportBody(tag, pr, from, to, autoMerge) {
return (
`Report de \`${tag}\` (#${pr.number}) de \`${from}\` vers \`${to}\`.\n\n` +
`Fusionne cette PR par commit de merge pour ramener la release sur \`${to}\` ; tant qu'elle est ` +
`ouverte, la promotion depuis et vers \`${to}\` est refusée.\n`
(autoMerge
? `L'auto-merge est activé : la PR se fusionne d'elle-même (commit de merge) dès que les checks passent`
: `Fusionne cette PR par commit de merge pour ramener la release sur \`${to}\``) +
` ; tant qu'elle est ouverte, la promotion depuis et vers \`${to}\` est refusée.\n`
);
}

/** Auto-merge (merge commit) pinned to the backport's head; a failure leaves the merge to the owner. */
async function autoMergeBackport(gh, created, title) {
try {
await gh.enableAutoMerge(created, { expectedHeadOid: created.headSha, commitHeadline: title });
return true;
} catch (error) {
console.warn(`::warning::#${created.number}: could not enable auto-merge: ${error.message}`);
return false;
}
}

/**
* Opens the backport PR `from` -> `to` unless one is open (it is reused) or there is nothing to bring.
* Opens the backport PR `from` -> `to`, with auto-merge, unless one is open (it is reused) or there is
* nothing to bring.
* -> {number} when opened, {pending: true} when one is already open, null when there is nothing to bring.
*/
async function openBackport(gh, tag, pr, from, to) {
const [open, commits] = await Promise.all([gh.listPulls({ base: to, head: from }), gh.compareCommits(to, from)]);
if (open.length) return { pending: true };
if (commits.length === 0) return null;
const title = `chore(release): backport ${tag} to ${to}`;
const created = await gh.createPull({
title: `chore(release): backport ${tag} to ${to}`,
title,
base: to,
head: from,
draft: false,
labels: ["backport"],
body: backportBody(tag, pr, from, to),
body: backportBody(tag, pr, from, to, true),
});
console.log(`opened the backport #${created.number} ${from} -> ${to}`);
if (!(await autoMergeBackport(gh, created, title))) {
await gh.updatePull(created.number, { body: backportBody(tag, pr, from, to, false) });
}
return { number: created.number };
}

/**
* Brings the release back to dev, whose version line would otherwise conflict with the next
* promotion: a stable through beta -> dev; a hotfix through main -> beta first, beta -> dev being
* opened once that one is merged (or right away when beta needs nothing). Returns the PRs opened.
* promotion. Beta skipped (lib/flow.mjs): main -> dev. Otherwise a stable through beta -> dev; a hotfix
* through main -> beta first, beta -> dev being opened once that one is merged (or right away when beta
* needs nothing). Returns the PRs opened.
*/
async function openBackports(gh, tag, pr, kind) {
async function openBackports(gh, tag, pr, kind, beta) {
if (!beta && kind !== "backport") {
const toDev = await openBackport(gh, tag, pr, "main", "dev");
return toDev?.number ? [toDev.number] : [];
}
if (kind === "hotfix") {
const toBeta = await openBackport(gh, tag, pr, "main", "beta");
if (toBeta !== null) return toBeta.number ? [toBeta.number] : [];
Expand All @@ -74,31 +99,33 @@ async function openBackports(gh, tag, pr, kind) {
return toDev?.number ? [toDev.number] : [];
}

async function release(gh, pr, kind) {
async function release(gh, pr, kind, beta) {
if (!pr.merged || !pr.mergeCommitSha) throw new Error(`#${pr.number} is not merged`);
const tag = titleVersion(pr.title, kind);
if (tag === null) throw new Error(`#${pr.number}: the title does not carry the version to publish: ${pr.title}`);

await ensureTag(gh, tag, pr.mergeCommitSha, pr.title);
const backports = kind === "beta" ? [] : await openBackports(gh, tag, pr, kind);
// A stable promoted from beta goes back through beta -> dev, whatever the current chain.
const through = kind === "stable" && pr.head === "beta" ? true : beta;
const backports = kind === "beta" ? [] : await openBackports(gh, tag, pr, kind, through);
return { tag, backports };
}

/**
* -> {tag, backports} (numbers of the backport PRs opened). Throws on an unmerged or unrelated PR; a
* failure on a release PR is also reported on it.
* failure on a release PR is also reported on it. `beta`: whether the chain goes through beta.
*/
export async function publish({ gh, pr, owner, repo }) {
export async function publish({ gh, pr, owner, repo, beta = BETA }) {
if (isBackportToBeta(pr, `${owner}/${repo}`)) {
if (!pr.merged) throw new Error(`#${pr.number} is not merged`);
const tag = BACKPORT_TITLE.exec(pr.title)?.[1];
if (!tag) throw new Error(`#${pr.number}: the title does not carry the backported version: ${pr.title}`);
return { tag, backports: await openBackports(gh, tag, pr, "stable") };
return { tag, backports: await openBackports(gh, tag, pr, "backport", true) };
}
const kind = kindOf(pr, `${owner}/${repo}`);
if (kind === null) throw new Error(`#${pr.number} is not a promotion or hotfix pull request`);
try {
return await release(gh, pr, kind);
return await release(gh, pr, kind, beta);
} catch (error) {
const version = titleVersion(pr.title, kind);
if (pr.merged) {
Expand Down
9 changes: 6 additions & 3 deletions .github/release/ready.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,14 @@ export function bumpGradleVersion(text, version) {
return text.replace(VERSION_LINE, (_, prefix) => `${prefix}"${version}"`);
}

/** Promotions dev -> beta and beta -> main, and hotfixes into main, all from this repository. */
/**
* Promotions dev -> main (beta skipped, lib/flow.mjs), dev -> beta and beta -> main, and hotfixes into
* main, all from this repository.
*/
export function kindOf(pull, repository) {
if (pull.headRepo !== repository) return null;
if (pull.head === "dev" && pull.base === "beta") return "beta";
if (pull.head === "beta" && pull.base === "main") return "stable";
if ((pull.head === "dev" || pull.head === "beta") && pull.base === "main") return "stable";
if (pull.head.startsWith("hotfix/") && pull.base === "main") return "hotfix";
return null;
}
Expand Down Expand Up @@ -164,7 +167,7 @@ async function prepare({ gh, pr, pull, kind, owner, repo, appSlug, promote, wait
if (promotion) {
const [tags, compared] = await Promise.all([gh.listTags(), gh.compareCommits(pull.base, pull.head)]);
const promoted = compared.filter((c) => !isReleaseCommit(c, appSlug));
const expected = releaseVersion(pull.base, tags, promoted);
const expected = releaseVersion(pull.head, pull.base, tags, promoted);
if (expected !== version) return refuseAndRefresh(gh, pull, MESSAGES.staleVersion(version, expected), refresh);
}

Expand Down
22 changes: 10 additions & 12 deletions .github/release/schedule.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Scheduled releases. --confirm answers the owner's edits of a promotion PR: only the App's comment
// makes a date active. --due lists the source branches whose date has come, and --run takes their PR
// out of draft, then follows the same path as the owner doing it.
// CLI: node .github/release/schedule.mjs --confirm | --due | --run --source dev --target beta
// CLI: node .github/release/schedule.mjs --confirm | --due | --run --source dev [--target main]
// (env: GH_TOKEN, GITHUB_REPOSITORY=owner/repo, APP_SLUG = the release App's slug; GITHUB_EVENT_PATH
// for --confirm, GITHUB_OUTPUT for --due)
// for --confirm, GITHUB_OUTPUT for --due). --run takes the target from lib/flow.mjs: --target is only
// a fallback, so an older release-schedule.yml (still on main until the next release) stays right.

import { appendFileSync, readFileSync } from "node:fs";
import { parseArgs } from "node:util";
Expand All @@ -13,11 +14,7 @@ import { activeSchedule, formatParis, NO_SCHEDULE, parseScheduleDate, scheduleMa
import { createGitHub } from "./lib/github.mjs";
import { isReleaseCommit } from "./promote.mjs";
import { kindOf, onReady } from "./ready.mjs";

export const PROMOTIONS = [
{ source: "dev", target: "beta" },
{ source: "beta", target: "main" },
];
import { BETA, promotions, promotionTarget } from "./lib/flow.mjs";
// Past this delay, the start of the release says it is late (GitHub's schedules may be delayed).
export const LATE_NOTICE_MS = 60 * 60 * 1000;

Expand Down Expand Up @@ -72,9 +69,9 @@ export async function confirm({ gh, event, owner, repo, appSlug, now = new Date(
}

/** The source branches of the open promotions whose scheduled date has come. */
export async function dueSources({ gh, appSlug, now = new Date() }) {
export async function dueSources({ gh, appSlug, now = new Date(), beta = BETA }) {
const due = [];
for (const { source, target } of PROMOTIONS) {
for (const { source, target } of promotions(beta)) {
const [pull] = await gh.listPulls({ base: target, head: source });
if (!pull) continue;
const active = activeSchedule(await gh.listComments(pull.number), appSlug);
Expand Down Expand Up @@ -156,9 +153,10 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
const due = await dueSources({ gh, appSlug });
console.log(due);
appendFileSync(process.env.GITHUB_OUTPUT, `sources=${JSON.stringify(due)}\n`);
} else if (values.run && values.source && values.target) {
console.log(await run({ gh, source: values.source, target: values.target, owner, repo, appSlug }));
} else if (values.run && values.source && (promotionTarget(values.source) ?? values.target)) {
const target = promotionTarget(values.source) ?? values.target;
console.log(await run({ gh, source: values.source, target, owner, repo, appSlug }));
} else {
throw new Error("Usage: schedule.mjs --confirm | --due | --run --source <branch> --target <branch>");
throw new Error("Usage: schedule.mjs --confirm | --due | --run --source <branch> [--target <branch>]");
}
}
4 changes: 3 additions & 1 deletion .github/release/test/dashboard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,17 @@ test("refresh_derivesStateFromThePullRequests", async () => {
pull(1),
pull(2, { draft: false }),
pull(3, { draft: false, autoMerge: true, head: "beta", base: "main", title: "chore(release): v1.0.0" }),
pull(4, { draft: false, base: "main", head: "dev" }), // not a promotion pair
pull(4, { draft: false, base: "main", head: "feat/x" }), // not a promotion pair
pull(5, { labels: ["backport"], head: "main", base: "beta" }),
pull(6, { labels: [] }),
pull(7, { base: "main", head: "dev", title: "chore(release): v1.2.0" }), // beta skipped
],
mergeStates: { 2: "DIRTY" },
});
await refreshDashboard({ gh });
const body = gh.calls.find(([name]) => name === "create")[1].body;
assert.match(body, /v1\.0\.0-BETA\.1 \| draft \| #1/);
assert.match(body, /`dev` → `main` \| v1\.2\.0 \| draft \| #7/);
assert.match(body, /v1\.0\.0-BETA\.2 \| en conflit \| #2/);
assert.match(body, /\| v1\.0\.0 \| prête \(auto-merge\) \| #3/);
assert.doesNotMatch(body, /#4|#6/);
Expand Down
18 changes: 18 additions & 0 deletions .github/release/test/promote.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,24 @@ test("betaToMain_usesTheStableOfTheLatestBeta", async () => {
assert.equal(gh.calls[0][1].title, "chore(release): v1.0.0");
});

test("devToMain_bumpsTheLastStableStraightAway", async () => {
const gh = fakeGitHub({
tags: ["v1.0.0-BETA.102", "v1.1.0-BETA.1", "v1.1.0"],
ahead: { "main...dev": [commit("a", "feat: x"), commit("b", "fix: y")] },
});
assert.deepEqual(await run(gh, "dev", "main"), { action: "created", number: 100 });
const [[, created]] = gh.calls;
assert.equal(created.title, "chore(release): v1.2.0");
assert.equal(created.base, "main");
assert.equal(created.head, "dev");
});

test("devToMain_withoutReleasableCommit_doesNothing", async () => {
const gh = fakeGitHub({ tags: ["v1.1.0"], ahead: { "main...dev": [commit("a", "not conventional")] } });
assert.deepEqual(await run(gh, "dev", "main"), { action: "none" });
assert.deepEqual(gh.calls, []);
});

test("betaToMain_withoutBeta_doesNothing", async () => {
const gh = fakeGitHub({ tags: ["v1.0.0"], ahead: { "main...beta": [commit("a", "fix: x")] } });
assert.deepEqual(await run(gh, "beta", "main"), { action: "none" });
Expand Down
Loading
Loading