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    pub services: u64,
323    /// Jobs of repositories whose visibility GitHub has not given: not priced.
324    pub unknown: u64,
325    /// Lost jobs: not priced, as their end is when runnerd saw their runner gone.
326    pub lost: u64,
327    /// Completed jobs without a start or an end: not priced.
328    pub untimed: u64,
329}
330
331/// A window's jobs priced at one runner.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
333pub struct PricedRunner {
334    /// The runner.
335    pub sku: Sku,
336    /// Its jobs.
337    pub jobs: u64,
338    /// Their minutes.
339    pub minutes: u64,
340    /// What they would have cost.
341    pub cost: Usd,
342}
343
344/// What GitHub would have done with one job.
345enum Priced {
346    /// Billed this many minutes at this runner.
347    Billed {
348        sku: Sku,
349        minutes: u64,
350    },
351    Free,
352    Service,
353    Unknown,
354    Lost,
355    Untimed,
356}
357
358/// How many minutes GitHub bills a job that ran from `started` to `finished` (ms): each partial minute rounded up,
359/// and at least one.
360pub fn billed_minutes(started: u64, finished: u64) -> u64 {
361    finished.saturating_sub(started).div_ceil(60_000).max(1)
362}
363
364fn price(run: &PricedRun, history: &PricingHistory, pricing: &Pricing) -> Option<Priced> {
365    match run.state {
366        JobState::Completed => {}
367        JobState::Lost => return Some(Priced::Lost),
368        JobState::Queued | JobState::Running | JobState::Elsewhere => return None,
369    }
370    let (Some(started), Some(finished)) = (run.started_at, run.finished_at) else {
371        return Some(Priced::Untimed);
372    };
373    let sku = pricing.of(run.profile.as_deref());
374    if sku.standard() && run.named_like_service() {
375        return Some(Priced::Service);
376    }
377    Some(match history.visibility.get(&run.repository) {
378        None => Priced::Unknown,
379        Some(Visibility::Public) if sku.standard() => Priced::Free,
380        Some(_) => Priced::Billed {
381            sku,
382            minutes: billed_minutes(started, finished),
383        },
384    })
385}
386
387impl Savings {
388    /// What the jobs of `history` would have cost at `pricing`, at `now` (ms).
389    pub fn of(history: &PricingHistory, pricing: &Pricing, now: u64) -> Savings {
390        let until = now / 1000;
391        Savings {
392            until,
393            month: SavedWindow::of(Period::Month, month_start(until), history, pricing, now),
394            last_30_days: SavedWindow::of(
395                Period::Last30Days,
396                until.saturating_sub(THIRTY_DAYS),
397                history,
398                pricing,
399                now,
400            ),
401        }
402    }
403    /// The windows to show, the month first. The last 30 days are left out when they start where the month does,
404    /// as when the history is younger than both: they hold the same jobs.
405    pub fn windows(&self) -> Vec<&SavedWindow> {
406        if self.last_30_days.since == self.month.since {
407            vec![&self.month]
408        } else {
409            vec![&self.month, &self.last_30_days]
410        }
411    }
412}
413
414impl SavedWindow {
415    /// The window from `start` (seconds) to `now` (ms).
416    fn of(
417        period: Period,
418        start: u64,
419        history: &PricingHistory,
420        pricing: &Pricing,
421        now: u64,
422    ) -> SavedWindow {
423        let kept_since = history.kept_since / 1000;
424        let cut = kept_since > start;
425        let mut window = SavedWindow {
426            period,
427            since: start.max(kept_since),
428            cut,
429            jobs: 0,
430            minutes: 0,
431            cost: Usd(0),
432            runners: Vec::new(),
433            free: 0,
434            services: 0,
435            unknown: 0,
436            lost: 0,
437            untimed: 0,
438        };
439        let mut runners: BTreeMap<Sku, PricedRunner> = BTreeMap::new();
440        let from = start.saturating_mul(1000).max(history.kept_since);
441        for run in history
442            .runs
443            .iter()
444            .filter(|run| (from..=now).contains(&run.at()))
445        {
446            match price(run, history, pricing) {
447                None => {}
448                Some(Priced::Billed { sku, minutes }) => {
449                    let cost =
450                        minutes.saturating_mul(sku.rate().0.saturating_sub(SELF_HOSTED_RATE));
451                    let runner = runners.entry(sku).or_insert(PricedRunner {
452                        sku,
453                        jobs: 0,
454                        minutes: 0,
455                        cost: Usd(0),
456                    });
457                    runner.jobs += 1;
458                    runner.minutes += minutes;
459                    runner.cost.0 += cost;
460                    window.jobs += 1;
461                    window.minutes += minutes;
462                    window.cost.0 += cost;
463                }
464                Some(Priced::Free) => window.free += 1,
465                Some(Priced::Service) => window.services += 1,
466                Some(Priced::Unknown) => window.unknown += 1,
467                Some(Priced::Lost) => window.lost += 1,
468                Some(Priced::Untimed) => window.untimed += 1,
469            }
470        }
471        window.runners = runners.into_values().collect();
472        window
473    }
474
475    /// The jobs of the window not in its figure, free or not priced.
476    pub fn not_priced(&self) -> u64 {
477        self.free + self.services + self.unknown + self.lost + self.untimed
478    }
479
480    /// The figure with what it is before, for compact places, which show it whole or not at all: "This month: 1,234
481    /// job-minutes here, about $7.40 at GitHub's 2-core Linux rate before your plan's free minutes (3 jobs not
482    /// priced)". What it rests on, today's profiles and repository visibility, is the last of `details`.
483    pub fn headline(&self) -> String {
484        let when = if self.cut {
485            format!("Since {}", short_date(self.since))
486        } else {
487            match self.period {
488                Period::Month => "This month".to_owned(),
489                Period::Last30Days => "Last 30 days".to_owned(),
490            }
491        };
492        if self.jobs == 0 && self.not_priced() == 0 {
493            return format!("{when}: no job-minutes here yet");
494        }
495        let rate = match self.runners.as_slice() {
496            [one] => format!("at GitHub's {} rate", one.sku.runner()),
497            [] => "at GitHub's rates".to_owned(),
498            _ => "at GitHub's rates for each profile's runner".to_owned(),
499        };
500        let mut line = format!(
501            "{when}: {} here, about {} {rate} before your plan's free minutes",
502            count(self.minutes, "job-minute", "job-minutes"),
503            self.cost,
504        );
505        if self.not_priced() > 0 {
506            let _ = write!(
507                line,
508                " ({} not priced)",
509                count(self.not_priced(), "job", "jobs")
510            );
511        }
512        line
513    }
514
515    /// What the headline sums and leaves out, a line each: the runners priced at, the jobs not priced and why, and
516    /// what the figure rests on.
517    pub fn details(&self) -> Vec<String> {
518        let mut lines: Vec<String> = self
519            .runners
520            .iter()
521            .map(|runner| {
522                format!(
523                    "{} of {} at {} ({}, {} a minute): {}",
524                    count(runner.minutes, "minute", "minutes"),
525                    count(runner.jobs, "job", "jobs"),
526                    runner.sku.key(),
527                    runner.sku.runner(),
528                    rate(runner.sku.rate()),
529                    runner.cost,
530                )
531            })
532            .collect();
533        for (jobs, why) in [
534            (
535                self.free,
536                "free on GitHub: on a standard runner, of a public repository",
537            ),
538            (
539                self.services,
540                "not priced: their workflow is named as GitHub's Dependabot or Pages workflow, free on a standard \
541                 runner",
542            ),
543            (
544                self.unknown,
545                "not priced: GitHub has not said whether their repository is public",
546            ),
547            (
548                self.lost,
549                "not priced: lost, so how long they ran is not known",
550            ),
551            (self.untimed, "not priced: no start or end recorded"),
552        ] {
553            if jobs > 0 {
554                lines.push(format!("{} {why}", count(jobs, "job", "jobs")));
555            }
556        }
557        if self.cut {
558            lines.push(format!(
559                "The history keeps jobs since {}",
560                short_date(self.since)
561            ));
562        }
563        lines.push(
564            "An estimate from this machine's run times, at each profile's priced_as and each repository's \
565             visibility as they are now; on GitHub's runners the jobs would run longer or shorter"
566                .to_owned(),
567        );
568        lines
569    }
570}
571
572/// A per-minute rate to the tenth of a cent: `$0.006`.
573fn rate(rate: Usd) -> String {
574    let thousandths = rate.0 / 1000;
575    format!(
576        "${}.{:03}",
577        thousands(thousandths / 1000),
578        thousandths % 1000
579    )
580}
581
582/// `1 job`, `1,234 jobs`.
583fn count(number: u64, one: &str, many: &str) -> String {
584    format!(
585        "{} {}",
586        thousands(number),
587        if number == 1 { one } else { many }
588    )
589}
590
591/// A whole number with a comma every three digits: `1,234,567`.
592fn thousands(number: u64) -> String {
593    let digits = number.to_string();
594    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
595    for (index, digit) in digits.chars().enumerate() {
596        if index > 0 && (digits.len() - index).is_multiple_of(3) {
597            out.push(',');
598        }
599        out.push(digit);
600    }
601    out
602}
603
604#[cfg(test)]
605mod tests;