Skip to main content

runnerd_core/api/
savings.rs

1//! What this machine's job minutes would have cost on GitHub-hosted runners (ADR 0072): GitHub's Linux per-minute
2//! rates, what each job bills, which jobs GitHub would not have billed, and every word clients show of it. Pure: the
3//! daemon reads the history and the config and passes them in; it does not price, and clients do not reword.
4
5use std::collections::BTreeMap;
6use std::fmt::{self, Write as _};
7
8use serde::{Deserialize, Serialize};
9
10use super::JobState;
11use super::units::{civil_from_days, days_from_civil, short_date};
12
13/// What a minute of a job costs on this machine, in micro-dollars, subtracted from each hosted rate. GitHub
14/// postponed its $0.002 a minute charge for self-hosted runners; this is the one place to set it if it comes back.
15pub const SELF_HOSTED_RATE: u64 = 0;
16
17/// The last 30 days' length, in seconds.
18const THIRTY_DAYS: u64 = 30 * 86_400;
19
20/// An amount in US dollars, as whole micro-dollars, so no float carries money.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct Usd(pub u64);
24
25impl fmt::Display for Usd {
26    /// To the cent, rounded down, so a figure never says more than the jobs cost: `$7.40`, `$1,204.18`.
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        let cents = self.0 / 10_000;
29        write!(f, "${}.{:02}", thousands(cents / 100), cents % 100)
30    }
31}
32
33/// A GitHub-hosted Linux runner, by the billing SKU GitHub's pricing page and usage report name it with, as a
34/// profile's `priced_as` takes it. Windows, macOS and GPU runners are not here: runnerd runs Linux containers.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
36pub enum Sku {
37    /// Linux 1-core x64, a standard runner.
38    #[serde(rename = "actions_linux_slim")]
39    ActionsLinuxSlim,
40    /// Linux 2-core x64, the standard runner `ubuntu-latest` gives a private repository.
41    #[serde(rename = "actions_linux")]
42    ActionsLinux,
43    /// Linux 2-core arm64, a standard runner.
44    #[serde(rename = "actions_linux_arm")]
45    ActionsLinuxArm,
46    /// Linux Advanced 2-core x64, a larger runner.
47    #[serde(rename = "linux_2_core_advanced")]
48    Linux2CoreAdvanced,
49    /// Linux 4-core x64.
50    #[serde(rename = "linux_4_core")]
51    Linux4Core,
52    /// Linux 8-core x64.
53    #[serde(rename = "linux_8_core")]
54    Linux8Core,
55    /// Linux 16-core x64.
56    #[serde(rename = "linux_16_core")]
57    Linux16Core,
58    /// Linux 32-core x64.
59    #[serde(rename = "linux_32_core")]
60    Linux32Core,
61    /// Linux 64-core x64.
62    #[serde(rename = "linux_64_core")]
63    Linux64Core,
64    /// Linux 96-core x64.
65    #[serde(rename = "linux_96_core")]
66    Linux96Core,
67    /// Linux 2-core arm64, a larger runner.
68    #[serde(rename = "linux_2_core_arm")]
69    Linux2CoreArm,
70    /// Linux 4-core arm64.
71    #[serde(rename = "linux_4_core_arm")]
72    Linux4CoreArm,
73    /// Linux 8-core arm64.
74    #[serde(rename = "linux_8_core_arm")]
75    Linux8CoreArm,
76    /// Linux 16-core arm64.
77    #[serde(rename = "linux_16_core_arm")]
78    Linux16CoreArm,
79    /// Linux 32-core arm64.
80    #[serde(rename = "linux_32_core_arm")]
81    Linux32CoreArm,
82    /// Linux 64-core arm64.
83    #[serde(rename = "linux_64_core_arm")]
84    Linux64CoreArm,
85}
86
87impl Sku {
88    /// The 2-core standard runner of an architecture as Rust names it (`std::env::consts::ARCH`): arm64's on
89    /// `aarch64`, x64's on any other.
90    pub fn standard_for(arch: &str) -> Sku {
91        if arch == "aarch64" {
92            Sku::ActionsLinuxArm
93        } else {
94            Sku::ActionsLinux
95        }
96    }
97
98    /// Its per-minute rate on GitHub's pricing page, read 2026-10-26.
99    pub fn rate(self) -> Usd {
100        Usd(match self {
101            Sku::ActionsLinuxSlim => 2_000,
102            Sku::ActionsLinux | Sku::Linux2CoreAdvanced => 6_000,
103            Sku::ActionsLinuxArm | Sku::Linux2CoreArm => 5_000,
104            Sku::Linux4Core => 12_000,
105            Sku::Linux8Core => 22_000,
106            Sku::Linux16Core => 42_000,
107            Sku::Linux32Core => 82_000,
108            Sku::Linux64Core => 162_000,
109            Sku::Linux96Core => 252_000,
110            Sku::Linux4CoreArm => 8_000,
111            Sku::Linux8CoreArm => 14_000,
112            Sku::Linux16CoreArm => 26_000,
113            Sku::Linux32CoreArm => 50_000,
114            Sku::Linux64CoreArm => 98_000,
115        })
116    }
117
118    /// Whether it is a standard runner: free in public repositories and for Dependabot and Pages. Larger runners
119    /// are billed everywhere.
120    pub fn standard(self) -> bool {
121        matches!(
122            self,
123            Sku::ActionsLinuxSlim | Sku::ActionsLinux | Sku::ActionsLinuxArm
124        )
125    }
126
127    /// GitHub's name for it, as `priced_as` takes it: `actions_linux`.
128    pub fn key(self) -> &'static str {
129        match self {
130            Sku::ActionsLinuxSlim => "actions_linux_slim",
131            Sku::ActionsLinux => "actions_linux",
132            Sku::ActionsLinuxArm => "actions_linux_arm",
133            Sku::Linux2CoreAdvanced => "linux_2_core_advanced",
134            Sku::Linux4Core => "linux_4_core",
135            Sku::Linux8Core => "linux_8_core",
136            Sku::Linux16Core => "linux_16_core",
137            Sku::Linux32Core => "linux_32_core",
138            Sku::Linux64Core => "linux_64_core",
139            Sku::Linux96Core => "linux_96_core",
140            Sku::Linux2CoreArm => "linux_2_core_arm",
141            Sku::Linux4CoreArm => "linux_4_core_arm",
142            Sku::Linux8CoreArm => "linux_8_core_arm",
143            Sku::Linux16CoreArm => "linux_16_core_arm",
144            Sku::Linux32CoreArm => "linux_32_core_arm",
145            Sku::Linux64CoreArm => "linux_64_core_arm",
146        }
147    }
148
149    /// The runner in words: `2-core Linux`, `8-core Linux arm64`.
150    pub fn runner(self) -> &'static str {
151        match self {
152            Sku::ActionsLinuxSlim => "1-core Linux",
153            Sku::ActionsLinux => "2-core Linux",
154            Sku::ActionsLinuxArm => "2-core Linux arm64",
155            Sku::Linux2CoreAdvanced => "Advanced 2-core Linux",
156            Sku::Linux4Core => "4-core Linux",
157            Sku::Linux8Core => "8-core Linux",
158            Sku::Linux16Core => "16-core Linux",
159            Sku::Linux32Core => "32-core Linux",
160            Sku::Linux64Core => "64-core Linux",
161            Sku::Linux96Core => "96-core Linux",
162            Sku::Linux2CoreArm => "2-core Linux arm64 (larger runner)",
163            Sku::Linux4CoreArm => "4-core Linux arm64",
164            Sku::Linux8CoreArm => "8-core Linux arm64",
165            Sku::Linux16CoreArm => "16-core Linux arm64",
166            Sku::Linux32CoreArm => "32-core Linux arm64",
167            Sku::Linux64CoreArm => "64-core Linux arm64",
168        }
169    }
170}
171
172impl fmt::Display for Sku {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        f.write_str(self.key())
175    }
176}
177
178/// A repository's visibility as GitHub gives it.
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "kebab-case")]
181pub enum Visibility {
182    /// Standard runners are free.
183    Public,
184    /// Billed.
185    Private,
186    /// An enterprise's: billed as private.
187    Internal,
188}
189
190/// The runner each profile's jobs are priced at, from the config running now.
191#[derive(Debug, Clone, PartialEq, Eq)]
192pub struct Pricing {
193    /// Each profile's `priced_as`, by profile; a profile without one is at `default_sku`.
194    pub profiles: BTreeMap<String, Sku>,
195    /// The runner of a profile that names none, or is gone from the config.
196    pub default_sku: Sku,
197}
198
199impl Pricing {
200    /// The runner a job of `profile` is priced at.
201    pub fn of(&self, profile: Option<&str>) -> Sku {
202        profile
203            .and_then(|profile| self.profiles.get(profile))
204            .copied()
205            .unwrap_or(self.default_sku)
206    }
207}
208
209/// A job of the history as pricing reads it: one a runner of this machine took, completed or lost. Times are
210/// milliseconds.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct PricedRun {
213    /// `owner/name`.
214    pub repository: String,
215    /// The workflow's name.
216    pub workflow: Option<String>,
217    /// The workflow's `runs-on` labels.
218    pub labels: Vec<String>,
219    /// `Completed` or `Lost`; others are not counted.
220    pub state: JobState,
221    /// The profile its runner ran in; `None` without a runner row.
222    pub profile: Option<String>,
223    /// When it was queued.
224    pub queued_at: u64,
225    /// When it started.
226    pub started_at: Option<u64>,
227    /// When it ended.
228    pub finished_at: Option<u64>,
229}
230
231impl PricedRun {
232    /// The time it is dated by: its end, or without one its start, else its queue time. The store selects by the
233    /// same.
234    pub fn at(&self) -> u64 {
235        self.finished_at
236            .or(self.started_at)
237            .unwrap_or(self.queued_at)
238    }
239
240    /// Whether its workflow is named as the ones GitHub generates for Dependabot updates and Pages builds, which
241    /// it runs free on a standard runner. A name is not proof, as any workflow may take it, so such a job is not
242    /// priced, and not called free either.
243    fn named_like_service(&self) -> bool {
244        self.workflow.as_deref().is_some_and(|workflow| {
245            let workflow = workflow.to_ascii_lowercase();
246            workflow == "dependabot updates"
247                || workflow == "pages build and deployment"
248                || workflow.starts_with("dynamic/dependabot/")
249                || workflow.starts_with("dynamic/pages/")
250        })
251    }
252}
253
254/// What pricing reads of the history.
255#[derive(Debug, Clone, Default, PartialEq, Eq)]
256pub struct PricingHistory {
257    /// The completed and lost jobs dated from [`reads_since`] on.
258    pub runs: Vec<PricedRun>,
259    /// Each repository's visibility as GitHub last gave it, by repository.
260    pub visibility: BTreeMap<String, Visibility>,
261    /// Since when, in milliseconds, the history holds every final job: when it started, or the cutoff of its last
262    /// prune of job records.
263    pub kept_since: u64,
264}
265
266/// From when, in milliseconds, [`Savings::of`] reads the history at `now` (ms): the earlier of the month's start and
267/// 30 days before.
268pub fn reads_since(now: u64) -> u64 {
269    month_start(now / 1000)
270        .min((now / 1000).saturating_sub(THIRTY_DAYS))
271        .saturating_mul(1000)
272}
273
274/// The start of the UTC calendar month of `at`, in seconds.
275fn month_start(at: u64) -> u64 {
276    let (year, month, _) = civil_from_days(at / 86_400);
277    days_from_civil(year, month, 1) * 86_400
278}
279
280/// `GET /v1/savings`: what this machine's job minutes would have cost on GitHub-hosted runners.
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
282pub struct Savings {
283    /// When it was read, in seconds.
284    pub until: u64,
285    /// The UTC calendar month so far.
286    pub month: SavedWindow,
287    /// The last 30 days.
288    pub last_30_days: SavedWindow,
289}
290
291/// Which window a [`SavedWindow`] covers.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "kebab-case")]
294pub enum Period {
295    /// The UTC calendar month so far: GitHub resets usage at the start of each month.
296    Month,
297    /// The 30 days to now.
298    Last30Days,
299}
300
301/// The jobs of one window and what they would have cost.
302#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
303pub struct SavedWindow {
304    /// Which window.
305    pub period: Period,
306    /// Its start, in seconds: the window's, or when the history starts holding every job if that is later.
307    pub since: u64,
308    /// Whether the history starts after the window's start, so `since` is when it does.
309    pub cut: bool,
310    /// The jobs priced.
311    pub jobs: u64,
312    /// Their minutes, each job's rounded up to a whole minute.
313    pub minutes: u64,
314    /// What they would have cost, before any minutes the account's plan includes.
315    pub cost: Usd,
316    /// The same by runner priced at, by SKU.
317    pub runners: Vec<PricedRunner>,
318    /// Jobs GitHub would have run free: on a standard runner, of a public repository.
319    pub free: u64,
320    /// Jobs on a standard runner whose workflow is named as GitHub's Dependabot or Pages workflow, which GitHub runs
321    /// free: not priced, as the name does not prove it.
322    #[serde(default)]
323    pub services: u64,
324    /// Jobs of repositories whose visibility GitHub has not given: not priced.
325    pub unknown: u64,
326    /// Lost jobs: not priced, as their end is when runnerd saw their runner gone.
327    pub lost: u64,
328    /// Completed jobs without a start or an end: not priced.
329    pub untimed: u64,
330}
331
332/// A window's jobs priced at one runner.
333#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
334pub struct PricedRunner {
335    /// The runner.
336    pub sku: Sku,
337    /// Its jobs.
338    pub jobs: u64,
339    /// Their minutes.
340    pub minutes: u64,
341    /// What they would have cost.
342    pub cost: Usd,
343}
344
345/// What GitHub would have done with one job.
346enum Priced {
347    /// Billed this many minutes at this runner.
348    Billed {
349        sku: Sku,
350        minutes: u64,
351    },
352    Free,
353    Service,
354    Unknown,
355    Lost,
356    Untimed,
357}
358
359/// How many minutes GitHub bills a job that ran from `started` to `finished` (ms): each partial minute rounded up,
360/// and at least one.
361pub fn billed_minutes(started: u64, finished: u64) -> u64 {
362    finished.saturating_sub(started).div_ceil(60_000).max(1)
363}
364
365fn price(run: &PricedRun, history: &PricingHistory, pricing: &Pricing) -> Option<Priced> {
366    match run.state {
367        JobState::Completed => {}
368        JobState::Lost => return Some(Priced::Lost),
369        JobState::Queued | JobState::Running | JobState::Elsewhere => return None,
370    }
371    let (Some(started), Some(finished)) = (run.started_at, run.finished_at) else {
372        return Some(Priced::Untimed);
373    };
374    let sku = pricing.of(run.profile.as_deref());
375    if sku.standard() && run.named_like_service() {
376        return Some(Priced::Service);
377    }
378    Some(match history.visibility.get(&run.repository) {
379        None => Priced::Unknown,
380        Some(Visibility::Public) if sku.standard() => Priced::Free,
381        Some(_) => Priced::Billed {
382            sku,
383            minutes: billed_minutes(started, finished),
384        },
385    })
386}
387
388impl Savings {
389    /// What the jobs of `history` would have cost at `pricing`, at `now` (ms).
390    pub fn of(history: &PricingHistory, pricing: &Pricing, now: u64) -> Savings {
391        let until = now / 1000;
392        Savings {
393            until,
394            month: SavedWindow::of(Period::Month, month_start(until), history, pricing, now),
395            last_30_days: SavedWindow::of(
396                Period::Last30Days,
397                until.saturating_sub(THIRTY_DAYS),
398                history,
399                pricing,
400                now,
401            ),
402        }
403    }
404}
405
406impl SavedWindow {
407    /// The window from `start` (seconds) to `now` (ms).
408    fn of(
409        period: Period,
410        start: u64,
411        history: &PricingHistory,
412        pricing: &Pricing,
413        now: u64,
414    ) -> SavedWindow {
415        let kept_since = history.kept_since / 1000;
416        let cut = kept_since > start;
417        let mut window = SavedWindow {
418            period,
419            since: start.max(kept_since),
420            cut,
421            jobs: 0,
422            minutes: 0,
423            cost: Usd(0),
424            runners: Vec::new(),
425            free: 0,
426            services: 0,
427            unknown: 0,
428            lost: 0,
429            untimed: 0,
430        };
431        let mut runners: BTreeMap<Sku, PricedRunner> = BTreeMap::new();
432        let from = start.saturating_mul(1000).max(history.kept_since);
433        for run in history
434            .runs
435            .iter()
436            .filter(|run| (from..=now).contains(&run.at()))
437        {
438            match price(run, history, pricing) {
439                None => {}
440                Some(Priced::Billed { sku, minutes }) => {
441                    let cost =
442                        minutes.saturating_mul(sku.rate().0.saturating_sub(SELF_HOSTED_RATE));
443                    let runner = runners.entry(sku).or_insert(PricedRunner {
444                        sku,
445                        jobs: 0,
446                        minutes: 0,
447                        cost: Usd(0),
448                    });
449                    runner.jobs += 1;
450                    runner.minutes += minutes;
451                    runner.cost.0 += cost;
452                    window.jobs += 1;
453                    window.minutes += minutes;
454                    window.cost.0 += cost;
455                }
456                Some(Priced::Free) => window.free += 1,
457                Some(Priced::Service) => window.services += 1,
458                Some(Priced::Unknown) => window.unknown += 1,
459                Some(Priced::Lost) => window.lost += 1,
460                Some(Priced::Untimed) => window.untimed += 1,
461            }
462        }
463        window.runners = runners.into_values().collect();
464        window
465    }
466
467    /// The jobs of the window not in its figure, free or not priced.
468    pub fn not_priced(&self) -> u64 {
469        self.free + self.services + self.unknown + self.lost + self.untimed
470    }
471
472    /// The figure with its basis, for compact places, which show it whole or not at all: "This month: 1,234
473    /// job-minutes here, an estimated $7.40 at GitHub's 2-core Linux rate, before your plan's free minutes, by today's
474    /// profiles and repository visibility (3 jobs not priced)".
475    pub fn headline(&self) -> String {
476        let when = if self.cut {
477            format!("Since {}", short_date(self.since))
478        } else {
479            match self.period {
480                Period::Month => "This month".to_owned(),
481                Period::Last30Days => "Last 30 days".to_owned(),
482            }
483        };
484        if self.jobs == 0 && self.not_priced() == 0 {
485            return format!("{when}: no job-minutes here yet");
486        }
487        let rate = match self.runners.as_slice() {
488            [one] => format!("at GitHub's {} rate", one.sku.runner()),
489            [] => "at GitHub's rates".to_owned(),
490            _ => "at GitHub's rates for each profile's runner".to_owned(),
491        };
492        let mut line = format!(
493            "{when}: {} here, an estimated {} {rate}, before your plan's free minutes, by today's profiles and \
494             repository visibility",
495            count(self.minutes, "job-minute", "job-minutes"),
496            self.cost,
497        );
498        if self.not_priced() > 0 {
499            let _ = write!(
500                line,
501                " ({} not priced)",
502                count(self.not_priced(), "job", "jobs")
503            );
504        }
505        line
506    }
507
508    /// What the headline sums and leaves out, a line each: the runners priced at, the jobs not priced and why, and
509    /// what the figure rests on.
510    pub fn details(&self) -> Vec<String> {
511        let mut lines: Vec<String> = self
512            .runners
513            .iter()
514            .map(|runner| {
515                format!(
516                    "{} of {} at {} ({}, {} a minute): {}",
517                    count(runner.minutes, "minute", "minutes"),
518                    count(runner.jobs, "job", "jobs"),
519                    runner.sku.key(),
520                    runner.sku.runner(),
521                    rate(runner.sku.rate()),
522                    runner.cost,
523                )
524            })
525            .collect();
526        for (jobs, why) in [
527            (
528                self.free,
529                "free on GitHub: on a standard runner, of a public repository",
530            ),
531            (
532                self.services,
533                "not priced: their workflow is named as GitHub's Dependabot or Pages workflow, free on a standard \
534                 runner",
535            ),
536            (
537                self.unknown,
538                "not priced: GitHub has not said whether their repository is public",
539            ),
540            (
541                self.lost,
542                "not priced: lost, so how long they ran is not known",
543            ),
544            (self.untimed, "not priced: no start or end recorded"),
545        ] {
546            if jobs > 0 {
547                lines.push(format!("{} {why}", count(jobs, "job", "jobs")));
548            }
549        }
550        if self.cut {
551            lines.push(format!(
552                "The history keeps jobs since {}",
553                short_date(self.since)
554            ));
555        }
556        lines.push(
557            "An estimate from this machine's run times, at each profile's priced_as and each repository's \
558             visibility as they are now; on GitHub's runners the jobs would run longer or shorter"
559                .to_owned(),
560        );
561        lines
562    }
563}
564
565/// A per-minute rate to the tenth of a cent: `$0.006`.
566fn rate(rate: Usd) -> String {
567    let thousandths = rate.0 / 1000;
568    format!(
569        "${}.{:03}",
570        thousands(thousandths / 1000),
571        thousandths % 1000
572    )
573}
574
575/// `1 job`, `1,234 jobs`.
576fn count(number: u64, one: &str, many: &str) -> String {
577    format!(
578        "{} {}",
579        thousands(number),
580        if number == 1 { one } else { many }
581    )
582}
583
584/// A whole number with a comma every three digits: `1,234,567`.
585fn thousands(number: u64) -> String {
586    let digits = number.to_string();
587    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
588    for (index, digit) in digits.chars().enumerate() {
589        if index > 0 && (digits.len() - index).is_multiple_of(3) {
590            out.push(',');
591        }
592        out.push(digit);
593    }
594    out
595}
596
597#[cfg(test)]
598mod tests;