Repository: chipsalliance/caliptra-sw
Pull request #359 (2023-06-06 19:22:47)

Add firmware "Size History" workflow to  CI
Example analysis: https://github.com/chipsalliance/caliptra-sw/actions/runs/5196990992


Patch:
diff --git a/.github/workflows/size-history.yml b/.github/workflows/size-history.yml
new file mode 100644
index 00000000..44dc2d18
--- /dev/null
+++ b/.github/workflows/size-history.yml
@@ -0,0 +1,45 @@
+name: Size History
+
+on:
+  push:
+    branches: ["main"]
+  pull_request:
+
+  workflow_dispatch:
+
+jobs:
+  analyze:
+    runs-on: ubuntu-22.04
+
+    env:
+      RUST_TOOLCHAIN_VERSION: 1.68.0
+      CARGO_INCREMENTAL: 0
+      CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
+      PR_TITLE: ${{github.event.pull_request.title}}
+      PR_BASE_COMMIT: ${{github.event.pull_request.base.sha}}
+      GIT_AUTHOR_NAME: ${{github.event.pull_request.user.login}}
+      GIT_AUTHOR_EMAIL: ${{github.event.pull_request.user.id}}+${{github.event.pull_request.user.login}}@users.noreply.github.com
+      GIT_COMMITTER_NAME: ${{github.event.pull_request.user.login}}
+      GIT_COMMITTER_EMAIL: ${{github.event.pull_request.user.id}}+${{github.event.pull_request.user.login}}@users.noreply.github.com
+
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          fetch-depth: 0
+          ref: ${{ github.event.pull_request.head.sha }}
+
+      - name: Configure actions-cache environment variables
+        uses: actions/github-script@v6
+        with:
+          script: |
+            core.exportVariable('ACTIONS_CACHE_URL', process.env.ACTIONS_CACHE_URL || '');
+            core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env.ACTIONS_RUNTIME_TOKEN || '');
+
+      - name: Set Rust ${{ env.RUST_TOOLCHAIN_VERSION }} as the default toolchain.
+        run: |
+          rustup default ${RUST_TOOLCHAIN_VERSION}
+          rustup target add riscv32imc-unknown-none-elf
+
+      - name: Run size analysis (look at workflow "Summary" tab for results)
+        run: |
+          cargo run --release -p caliptra-size-history
diff --git a/Cargo.lock b/Cargo.lock
index 18c619a0..41de954d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -547,6 +547,16 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "caliptra-size-history"
+version = "0.1.0"
+dependencies = [
+ "caliptra-builder",
+ "serde",
+ "serde_json",
+ "tinytemplate",
+]
+
 [[package]]
 name = "caliptra-systemrdl"
 version = "0.1.0"
@@ -1570,6 +1580,16 @@ dependencies = [
  "winapi",
 ]
 
+[[package]]
+name = "tinytemplate"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
 [[package]]
 name = "tock-registers"
 version = "0.8.1"
diff --git a/Cargo.toml b/Cargo.toml
index 62189fee..2256c3fa 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,6 +15,7 @@ exclude = [
 members = [
   "builder",
   "ci-tools/file-header-fix",
+  "ci-tools/size-history",
   "common",
   "cpu",
   "drivers",
diff --git a/builder/src/lib.rs b/builder/src/lib.rs
index d46e3397..4df0af3f 100644
--- a/builder/src/lib.rs
+++ b/builder/src/lib.rs
@@ -21,28 +21,34 @@ mod elf_symbols;
 pub use elf_symbols::{elf_symbols, Symbol, SymbolBind, SymbolType, SymbolVisibility};
 use once_cell::sync::Lazy;
 
+pub const THIS_WORKSPACE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
+
 pub const ROM: FwId = FwId {
     crate_name: "caliptra-rom",
     bin_name: "caliptra-rom",
     features: &[],
+    workspace_dir: None,
 };
 
 pub const ROM_WITH_UART: FwId = FwId {
     crate_name: "caliptra-rom",
     bin_name: "caliptra-rom",
     features: &["emu"],
+    workspace_dir: None,
 };
 
 pub const FMC_WITH_UART: FwId = FwId {
     crate_name: "caliptra-fmc",
     bin_name: "caliptra-fmc",
     features: &["emu"],
+    workspace_dir: None,
 };
 
 pub const APP_WITH_UART: FwId = FwId {
     crate_name: "caliptra-runtime",
     bin_name: "caliptra-runtime",
     features: &["emu"],
+    workspace_dir: None,
 };
 
 fn other_err(e: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> io::Error {
@@ -100,10 +106,12 @@ pub struct FwId<'a> {
 
     // The features to use the build the binary
     pub features: &'a [&'a str],
+
+    // Path to the workspace dir to build from; defaults to this workspace.
+    pub workspace_dir: Option<&'a Path>,
 }
 
 pub fn build_firmware_elf_uncached(id: &FwId) -> io::Result<Vec<u8>> {
-    const WORKSPACE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/..");
     const TARGET: &str = "riscv32imc-unknown-none-elf";
     const PROFILE: &str = "firmware";
 
@@ -115,8 +123,11 @@ pub fn build_firmware_elf_uncached(id: &FwId) -> io::Result<Vec<u8>> {
         features_csv.push_str("riscv");
     }
 
+    let workspace_dir = id
+        .workspace_dir
+        .unwrap_or_else(|| Path::new(THIS_WORKSPACE_DIR));
     let mut cmd = Command::new(env!("CARGO"));
-    cmd.current_dir(WORKSPACE_DIR);
+    cmd.current_dir(workspace_dir);
     if option_env!("GITHUB_ACTIONS").is_some() {
         // In continuous integration, warnings are always errors.
         cmd.arg("--config")
@@ -139,7 +150,7 @@ pub fn build_firmware_elf_uncached(id: &FwId) -> io::Result<Vec<u8>> {
             .arg(id.bin_name),
     )?;
     fs::read(
-        Path::new(WORKSPACE_DIR)
+        Path::new(workspace_dir)
             .join("target")
             .join(TARGET)
             .join(PROFILE)
@@ -213,6 +224,27 @@ pub fn elf2rom(elf_bytes: &[u8]) -> io::Result<Vec<u8>> {
     Ok(result)
 }
 
+pub fn elf_size(elf_bytes: &[u8]) -> io::Result<u64> {
+    let elf = elf::ElfBytes::<LittleEndian>::minimal_parse(elf_bytes).map_err(other_err)?;
+    let Some(segments) = elf.segments() else {
+        return Err(other_err("ELF file has no segments"))
+    };
+    let mut min_addr = u64::MAX;
+    let mut max_addr = u64::MIN;
+    for segment in segments {
+        if segment.p_type != elf::abi::PT_LOAD || segment.p_filesz == 0 {
+            continue;
+        }
+        min_addr = min_addr.min(segment.p_paddr);
+        max_addr = max_addr.max(segment.p_paddr + segment.p_filesz);
+    }
+    Ok(if max_addr >= min_addr {
+        max_addr - min_addr
+    } else {
+        0
+    })
+}
+
 pub struct ImageOptions {
     pub fmc_min_svn: u32,
     pub fmc_svn: u32,
@@ -296,6 +328,7 @@ mod test {
             crate_name: "caliptra-drivers-test-bin",
             bin_name: "test_success",
             features: &[],
+            workspace_dir: None,
         };
         // Ensure that we can build the ELF and elf2rom can parse it
         build_firmware_rom(&FWID).unwrap();
@@ -307,6 +340,14 @@ mod test {
         assert_eq!(&rom_bytes, include_bytes!("testdata/example.rom.golden"));
     }
 
+    #[test]
+    fn test_elf_size() {
+        assert_eq!(
+            elf_size(include_bytes!("testdata/example.elf")).unwrap(),
+            4096
+        );
+    }
+
     #[test]
     fn test_image_revision_from_str() {
         assert_eq!(
diff --git a/ci-tools/size-history/Cargo.toml b/ci-tools/size-history/Cargo.toml
new file mode 100644
index 00000000..76d11c69
--- /dev/null
+++ b/ci-tools/size-history/Cargo.toml
@@ -0,0 +1,14 @@
+# Licensed under the Apache-2.0 license
+
+[package]
+name = "caliptra-size-history"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+caliptra-builder = { path = "../../builder" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+tinytemplate = "1.1"
\ No newline at end of file
diff --git a/ci-tools/size-history/src/cache.rs b/ci-tools/size-history/src/cache.rs
new file mode 100644
index 00000000..f3ddb659
--- /dev/null
+++ b/ci-tools/size-history/src/cache.rs
@@ -0,0 +1,33 @@
+// Licensed under the Apache-2.0 license
+
+use std::{fs, io, path::PathBuf};
+
+use crate::util::hex;
+
+pub trait Cache {
+    fn set(&self, key: &str, val: &[u8]) -> io::Result<()>;
+    fn get(&self, key: &str) -> io::Result<Option<Vec<u8>>>;
+}
+
+pub struct FsCache {
+    dir: PathBuf,
+}
+impl FsCache {
+    pub fn new(dir: PathBuf) -> io::Result<Self> {
+        fs::create_dir_all(&dir)?;
+        Ok(Self { dir })
+    }
+}
+impl Cache for FsCache {
+    fn set(&self, key: &str, val: &[u8]) -> io::Result<()> {
+        fs::write(self.dir.join(hex(key.as_bytes())), val)
+    }
+
+    fn get(&self, key: &str) -> io::Result<Option<Vec<u8>>> {
+        match fs::read(self.dir.join(hex(key.as_bytes()))) {
+            Ok(data) => Ok(Some(data)),
+            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
+            Err(e) => Err(e),
+        }
+    }
+}
diff --git a/ci-tools/size-history/src/cache_gha.rs b/ci-tools/size-history/src/cache_gha.rs
new file mode 100644
index 00000000..7c3b9b64
--- /dev/null
+++ b/ci-tools/size-history/src/cache_gha.rs
@@ -0,0 +1,116 @@
+// Licensed under the Apache-2.0 license
+
+use serde::{Deserialize, Serialize};
+
+use crate::{
+    http::{self, Content, HttpResponse},
+    util::{hex, other_err},
+    Cache,
+};
+
+const VERSION: &str = "caliptra-size-history-cache0";
+
+pub struct GithubActionCache {
+    prefix: String,
+}
+impl GithubActionCache {
+    pub fn new() -> std::io::Result<Self> {
+        let wrap_err = |_| other_err("ACTIONS_CACHE_URL environment variable not set".to_string());
+        let prefix = format!(
+            "{}/_apis/artifactcache",
+            std::env::var("ACTIONS_CACHE_URL").map_err(wrap_err)?
+        );
+        Ok(Self { prefix })
+    }
+}
+impl Cache for GithubActionCache {
+    fn set(&self, key: &str, val: &[u8]) -> std::io::Result<()> {
+        let response = http::api_post(
+            &format!("{}/caches", self.prefix),
+            Some(&Content::json(&CacheReserveRequest {
+                key: format_key(key),
+                version: VERSION.into(),
+            })),
+        )?;
+        let response: CacheReserveResponse = json_response(&response)?;
+        let cache_url = &format!("{}/caches/{}", self.prefix, response.cache_id);
+        empty_response(http::api_patch(cache_url, 0, &Content::octet_stream(val))?)?;
+        empty_response(http::api_post(
+            cache_url,
+            Some(&Content::json(&CacheFinalize {
+                size: val.len() as u64,
+            })),
+        )?)?;
+        Ok(())
+    }
+
+    fn get(&self, key: &str) -> std::io::Result<Option<Vec<u8>>> {
+        let url_key = format_key(key);
+        let response = http::api_get(&format!(
+            "{}/cache?keys={url_key}&version={VERSION}",
+            self.prefix
+        ))?;
+        if response.status == 204 {
+            return Ok(None);
+        }
+        let response: CacheResponse = json_response(&response)?;
+        if response.cache_key != url_key {
+            return Err(other_err(format!(
+                "Expected key {url_key:?}, was {:?}",
+                response.cache_key
+            )));
+        }
+        let response = http::raw_get(&response.archive_location)?;
+        Ok(Some(response.data))
+    }
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct CacheResponse {
+    cache_key: String,
+    #[allow(dead_code)]
+    scope: String,
+    archive_location: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct CacheReserveRequest {
+    key: String,
+    version: String,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct CacheReserveResponse {
+    cache_id: u64,
+}
+
+#[derive(Serialize)]
+struct CacheFinalize {
+    size: u64,
+}
+
+fn empty_response(response: HttpResponse) -> std::io::Result<()> {
+    if !(200..300).contains(&response.status) {
+        return Err(other_err(format!(
+            "Unexpected response from server {response:?}"
+        )));
+    }
+    Ok(())
+}
+
+fn json_response<'a, T: Deserialize<'a>>(response: &'a HttpResponse) -> std::io::Result<T> {
+    if !(200..300).contains(&response.status) {
+        return Err(other_err(format!(
+            "Unexpected response from server {response:?}"
+        )));
+    }
+    serde_json::from_slice(&response.data)
+        .map_err(|_| other_err(format!("Unable to parse response {response:?}")))
+}
+
+fn format_key(key: &str) -> String {
+    format!("size-history-{}", hex(key.as_bytes()))
+}
diff --git a/ci-tools/size-history/src/git.rs b/ci-tools/size-history/src/git.rs
new file mode 100644
index 00000000..ff61611d
--- /dev/null
+++ b/ci-tools/size-history/src/git.rs
@@ -0,0 +1,226 @@
+// Licensed under the Apache-2.0 license
+
+use std::{io, path::Path, process::Command};
+
+use serde::{Deserialize, Serialize};
+
+use crate::{
+    process::{run_cmd, run_cmd_stdout},
+    util::{bytes_to_string, expect_line, expect_line_with_prefix, other_err},
+};
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct CommitInfo {
+    pub id: String,
+    pub author: String,
+    pub title: String,
+}
+impl CommitInfo {
+    fn parse_multiple(s: &str) -> io::Result<Vec<CommitInfo>> {
+        let mut lines = s.lines();
+        let mut result = vec![];
+        'outer: loop {
+            let Some(line) = lines.next() else {
+                break;
+            };
+            let commit_id = expect_line_with_prefix("commit ", Some(line))?;
+            let author = expect_line_with_prefix("Author: ", lines.next())?;
+            expect_line("", lines.next())?;
+            let mut title = expect_line_with_prefix("    ", lines.next())?.to_string();
+            'inner: loop {
+                let Some(line) = lines.next() else {
+                    result.push(CommitInfo{
+                        id: commit_id.into(),
+                        author: author.into(),
+                        title,
+                    });
+                    break 'outer;
+                };
+                if line.is_empty() {
+                    break 'inner;
+                }
+                title.push('\n');
+                title.push_str(expect_line_with_prefix("    ", Some(line))?);
+            }
+            result.push(CommitInfo {
+                id: commit_id.into(),
+                author: author.into(),
+                title,
+            });
+        }
+        Ok(result)
+    }
+}
+
+fn to_utf8(bytes: Vec<u8>) -> io::Result<String> {
+    String::from_utf8(bytes).map_err(|_| other_err("git output is not utf-8"))
+}
+
+pub struct WorkTree<'a> {
+    pub path: &'a Path,
+}
+impl<'a> WorkTree<'a> {
+    pub fn new(path: &'a Path) -> io::Result<Self> {
+        run_cmd(Command::new("git").arg("worktree").arg("add").arg(path))?;
+        Ok(Self { path })
+    }
+
+    pub fn is_log_linear(&self) -> io::Result<bool> {
+        let stdout = to_utf8(run_cmd_stdout(
+            Command::new("git")
+                .current_dir(self.path)
+                .arg("rev-list")
+                .arg("--min-parents=2")
+                .arg("--count")
+                .arg("HEAD"),
+            None,
+        )?)?;
+        Ok(stdout.trim() == "0")
+    }
+
+    pub fn commit_log(&self) -> io::Result<Vec<CommitInfo>> {
+        CommitInfo::parse_multiple(&bytes_to_string(run_cmd_stdout(
+            Command::new("git")
+                .current_dir(self.path)
+                .arg("log")
+                .arg("--pretty=short")
+                .arg("--decorate=no"),
+            None,
+        )?)?)
+    }
+
+    pub fn checkout(&self, commit_id: &str) -> io::Result<()> {
+        run_cmd_stdout(
+            Command::new("git")
+                .current_dir(self.path)
+                .arg("checkout")
+                .arg("--no-recurse-submodule")
+                .arg("--quiet")
+                .arg(commit_id),
+            None,
+        )?;
+        Ok(())
+    }
+    pub fn head_commit_id(&self) -> io::Result<String> {
+        Ok(to_utf8(run_cmd_stdout(
+            Command::new("git")
+                .current_dir(self.path)
+                .arg("rev-parse")
+                .arg("HEAD"),
+            None,
+        )?)?
+        .trim()
+        .into())
+    }
+    pub fn reset_hard(&self, commit_id: &str) -> io::Result<()> {
+        run_cmd_stdout(
+            Command::new("git")
+                .current_dir(self.path)
+                .arg("reset")
+                .arg("--hard")
+                .arg(commit_id),
+            None,
+        )?;
+        Ok(())
+    }
+    pub fn set_fs_contents(&self, commit_id: &str) -> io::Result<()> {
+        run_cmd_stdout(
+            Command::new("git")
+                .current_dir(self.path)
+                .arg("checkout")
+                .arg(commit_id)
+                .arg("--")
+                .arg("."),
+            None,
+        )?;
+        Ok(())
+    }
+    pub fn commit(&self, message: &str) -> io::Result<()> {
+        run_cmd_stdout(
+            Command::new("git")
+                .current_dir(self.path)
+                .arg("commit")
+                .arg("-a")
+                .arg("-m")
+                .arg(message),
+            None,
+        )?;
+        Ok(())
+    }
+    pub fn is_ancestor(&self, possible_ancestor: &str, commit: &str) -> io::Result<bool> {
+        Ok(Command::new("git")
+            .current_dir(self.path)
+            .arg("merge-base")
+            .arg("--is-ancestor")
+            .arg(possible_ancestor)
+            .arg(commit)
+            .status()?
+            .code()
+            == Some(0))
+    }
+    pub fn merge_log(&self) -> io::Result<Vec<Vec<String>>> {
+        let stdout = to_utf8(run_cmd_stdout(
+            Command::new("git")
+                .current_dir(self.path)
+                .arg("log")
+                .arg("--merges")
+                .arg("--pretty=%P"),
+            None,
+        )?)?;
+        let mut result = vec![];
+        for line in stdout.lines() {
+            let parents: Vec<String> = line.split(' ').map(|s| s.to_string()).collect();
+            if !parents.is_empty() {
+                result.push(parents);
+            }
+        }
+        Ok(result)
+    }
+}
+impl Drop for WorkTree<'_> {
+    fn drop(&mut self) {
+        let _ = run_cmd(
+            Command::new("git")
+                .arg("worktree")
+                .arg("remove")
+                .arg(self.path),
+        );
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_commit_info_parse() {
+        let text = r#"commit e1b1e3c566b6bd7cdef0310dc88480034f0aa29f
+Author: Vishal Mhatre <38512878+mhatrevi@users.noreply.github.com>
+
+    [fix] Vendor signature should not include owner signed data (#319)
+
+commit bd306c4809f54426a357ff01507ef660291e2b91
+Author: Kor Nielsen <kor@google.com>
+
+    Remove RUSTFLAGS from legacy ROM makefile. (#318)
+    Multiline title
+"#;
+        assert_eq!(
+            CommitInfo::parse_multiple(text).unwrap(),
+            vec![
+                CommitInfo {
+                    id: "e1b1e3c566b6bd7cdef0310dc88480034f0aa29f".into(),
+                    author: "Vishal Mhatre <38512878+mhatrevi@users.noreply.github.com>".into(),
+                    title: "[fix] Vendor signature should not include owner signed data (#319)"
+                        .into()
+                },
+                CommitInfo {
+                    id: "bd306c4809f54426a357ff01507ef660291e2b91".into(),
+                    author: "Kor Nielsen <kor@google.com>".into(),
+                    title: "Remove RUSTFLAGS from legacy ROM makefile. (#318)\nMultiline title"
+                        .into()
+                }
+            ]
+        );
+    }
+}
diff --git a/ci-tools/size-history/src/html.rs b/ci-tools/size-history/src/html.rs
new file mode 100644
index 00000000..91f4c4bf
--- /dev/null
+++ b/ci-tools/size-history/src/html.rs
@@ -0,0 +1,136 @@
+// Licensed under the Apache-2.0 license
+
+use std::{cmp::Ordering, fmt::Write, io};
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use tinytemplate::TinyTemplate;
+
+use crate::{git, SizeRecord, Sizes};
+
+// The GitHub "HTML sanitizer" is incredibly sensitive to whitespace; do not attempt to break newlines.
+static TEMPLATE: &str = r#"
+<table>
+  <tr><th>Commit</th><th>Author</th><th>Commit</th><th>ROM prod size</th><th>ROM with-uart size</th></tr>
+{{ for record in records }}
+  <tr>
+    <td><a href="https://github.com/chipsalliance/caliptra-sw/commit/{ record.commit.id }">{ record.commit.id | trim_8 }</a></td>
+    <td>{ record.commit.author | name_only }</td>
+    {{ if record.important }}<td><strong>{ record.commit.title }</strong></td>{{ else }}<td>{ record.commit.title }</td>{{ endif }}
+    {{ if record.sizes }}<td>{ record.sizes.size_prod.total }&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<br>({ record.sizes.size_prod.delta | delta_format })</td><td>{ record.sizes.size_with_uart.total }&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<br>({ record.sizes.size_with_uart.delta | delta_format })</td>{{ else }}<td colspan="2">Error building commit</td>{{ endif }}
+  </tr>
+{{ endfor }}
+</table>
+
+"#;
+
+pub(crate) fn format_records(records: &[SizeRecord]) -> io::Result<String> {
+    let mut extended_records = vec![];
+    let mut last_sizes = Sizes {
+        size_with_uart: 0,
+        size_prod: 0,
+    };
+    for record in records.iter().rev() {
+        let ext_sizes = record.sizes.map(|sizes| ExtendedSizes {
+            size_prod: ExtendedSizeInfo::from_change(last_sizes.size_prod, sizes.size_prod),
+            size_with_uart: ExtendedSizeInfo::from_change(
+                last_sizes.size_with_uart,
+                sizes.size_with_uart,
+            ),
+        });
+        let mut ext_record = ExtendedRecord {
+            commit: record.commit.clone(),
+            important: is_important(ext_sizes.as_ref()),
+            sizes: ext_sizes,
+        };
+        ext_record.commit.title.truncate(80);
+        extended_records.push(ext_record);
+        if let Some(sizes) = record.sizes {
+            last_sizes = sizes;
+        }
+    }
+    extended_records.reverse();
+
+    let mut tt = TinyTemplate::new();
+    tt.add_formatter("name_only", |val, out| {
+        if let Some(s) = val.as_str() {
+            out.write_str(name_only(s))?;
+        }
+        Ok(())
+    });
+    tt.add_template("index", TEMPLATE).unwrap();
+    tt.add_formatter("trim_8", |val, out| {
+        if let Some(s) = val.as_str() {
+            out.write_str(s.get(..8).unwrap_or(s))?;
+        }
+        Ok(())
+    });
+    tt.add_formatter("delta_format", |val, out| {
+        if let Value::Number(delta) = val {
+            if let Some(delta) = delta.as_i64() {
+                match delta.cmp(&0) {
+                    Ordering::Greater => write!(out, "🟥 +{delta}")?,
+                    Ordering::Less => write!(out, "🟩 {delta}")?,
+                    Ordering::Equal => write!(out, "{delta}")?,
+                }
+            }
+        }
+        Ok(())
+    });
+
+    Ok(tt
+        .render(
+            "index",
+            &TemplateContext {
+                records: extended_records,
+            },
+        )
+        .unwrap())
+}
+
+fn is_important(sizes: Option<&ExtendedSizes>) -> bool {
+    let Some(sizes) = sizes else {
+        return false;
+    };
+    sizes.size_prod.delta != 0 || sizes.size_with_uart.delta != 0
+}
+fn name_only(val: &str) -> &str {
+    if let Some((name, _)) = val.split_once('<') {
+        name.trim()
+    } else {
+        val
+    }
+}
+
+#[derive(Serialize)]
+struct TemplateContext {
+    records: Vec<ExtendedRecord>,
+}
+
+#[derive(Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
+struct ExtendedSizeInfo {
+    total: u64,
+    delta: i64,
+}
+impl ExtendedSizeInfo {
+    fn from_change(prev: u64, current: u64) -> Self {
+        Self {
+            total: current,
+            delta: current.wrapping_sub(prev) as i64,
+        }
+    }
+}
+
+#[derive(Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
+struct ExtendedSizes {
+    size_prod: ExtendedSizeInfo,
+    size_with_uart: ExtendedSizeInfo,
+}
+
+#[derive(Clone, Eq, PartialEq, Serialize, Deserialize)]
+struct ExtendedRecord {
+    commit: git::CommitInfo,
+    important: bool,
+    // If None, there was a problem building the commit
+    sizes: Option<ExtendedSizes>,
+}
diff --git a/ci-tools/size-history/src/http.rs b/ci-tools/size-history/src/http.rs
new file mode 100644
index 00000000..6a88f0b3
--- /dev/null
+++ b/ci-tools/size-history/src/http.rs
@@ -0,0 +1,145 @@
+// Licensed under the Apache-2.0 license
+
+use std::{borrow::Cow, io, process::Command};
+
+use serde::Serialize;
+use std::str::FromStr;
+
+use crate::{
+    process::run_cmd_stdout,
+    util::{expect_line_with_prefix, other_err},
+};
+
+pub struct Content<'a> {
+    mime_type: Cow<'a, str>,
+    data: Cow<'a, [u8]>,
+}
+impl<'a> Content<'a> {
+    pub fn octet_stream(val: impl Into<Cow<'a, [u8]>>) -> Self {
+        Self {
+            mime_type: "application/octet-stream".into(),
+            data: val.into(),
+        }
+    }
+    pub fn json(val: &impl Serialize) -> Content {
+        Content {
+            mime_type: "application/json".into(),
+            data: serde_json::to_string(val).unwrap().into_bytes().into(),
+        }
+    }
+}
+
+pub struct HttpResponse {
+    pub status: u32,
+    pub content_type: String,
+    pub data: Vec<u8>,
+}
+impl HttpResponse {
+    fn parse(raw: Vec<u8>) -> io::Result<Self> {
+        use std::io::BufRead;
+        let mut raw = &raw[..];
+        let mut line = String::new();
+        raw.read_line(&mut line)?;
+        let status_str = expect_line_with_prefix("HTTP/1.1 ", Some(&line))
+            .or_else(|_| expect_line_with_prefix("HTTP/2 ", Some(&line)))?;
+        if status_str.len() < 3 {
+            return Err(other_err(format!("HTTP line too short: {line:?}")));
+        }
+        let status = u32::from_str(&status_str[..3])
+            .map_err(|_| other_err(format!("Bad HTTP status code: {line:?}")))?;
+        let mut content_type = String::new();
+        loop {
+            let mut line = String::new();
+            raw.read_line(&mut line)?;
+            if line == "\r\n" {
+                break;
+            }
+            if let Ok(header_val) = expect_line_with_prefix("Content-Type: ", Some(&line)) {
+                content_type = header_val.trim().into();
+            }
+        }
+        Ok(HttpResponse {
+            status,
+            content_type,
+            data: raw.into(),
+        })
+    }
+}
+impl std::fmt::Debug for HttpResponse {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("HttpResponse")
+            .field("status", &self.status)
+            .field("content_type", &self.content_type)
+            .field("data", &String::from_utf8_lossy(&self.data))
+            .finish()
+    }
+}
+
+fn auth_header() -> io::Result<String> {
+    let token = std::env::var("ACTIONS_RUNTIME_TOKEN")
+        .map_err(|_| other_err("env-var ACTIONS_RUNTIME_TOKEN must be set"))?;
+    Ok(format!("Authorization: Bearer {token}"))
+}
+
+pub fn raw_get(url: &str) -> io::Result<HttpResponse> {
+    HttpResponse::parse(run_cmd_stdout(
+        Command::new("curl").arg("-sSi").arg(url),
+        None,
+    )?)
+}
+
+pub fn api_get(url: &str) -> io::Result<HttpResponse> {
+    HttpResponse::parse(run_cmd_stdout(
+        Command::new("curl")
+            .arg("-sSi")
+            .arg(url)
+            .arg("-H")
+            .arg(auth_header()?)
+            .arg("-H")
+            .arg("Accept: application/json;api-version=6.0-preview.1"),
+        None,
+    )?)
+}
+
+pub fn api_post(url: &str, content: Option<&Content>) -> io::Result<HttpResponse> {
+    let mut cmd = Command::new("curl");
+    cmd.arg("-sSi");
+    cmd.arg("-X").arg("POST");
+    cmd.arg("-H").arg(auth_header()?);
+    cmd.arg("-H")
+        .arg("Accept: application/json;api-version=6.0-preview.1");
+    if let Some(content) = content {
+        cmd.arg("-H")
+            .arg(format!("Content-Type: {}", content.mime_type));
+        cmd.arg("--data-binary").arg("@-");
+        cmd.arg(url);
+        HttpResponse::parse(run_cmd_stdout(&mut cmd, Some(&content.data))?)
+    } else {
+        cmd.arg(url);
+        HttpResponse::parse(run_cmd_stdout(&mut cmd, None)?)
+    }
+}
+
+pub fn api_patch(url: &str, offset: u64, content: &Content) -> io::Result<HttpResponse> {
+    HttpResponse::parse(run_cmd_stdout(
+        Command::new("curl")
+            .arg("-sSi")
+            .arg("-H")
+            .arg(format!("Content-Type: {}", content.mime_type))
+            .arg("-H")
+            .arg(format!(
+                "Content-Range: bytes {offset}-{}/*",
+                offset + (content.data.len() as u64)
+            ))
+            .arg("-H")
+            .arg("Accept: application/json;api-version=6.0-preview.1")
+            .arg("-H")
+            .arg(auth_header()?)
+            .arg("-X")
+            .arg("PATCH")
+            .arg("--data-binary")
+            .arg("@-")
+            .arg(url),
+        Some(&content.data),
+    )?)
+}
diff --git a/ci-tools/size-history/src/main.rs b/ci-tools/size-history/src/main.rs
new file mode 100644
index 00000000..fefb9cb4
--- /dev/null
+++ b/ci-tools/size-history/src/main.rs
@@ -0,0 +1,193 @@
+// Licensed under the Apache-2.0 license
+
+use std::{
+    env::{self},
+    fs, io,
+    path::Path,
+};
+
+use caliptra_builder::{elf_size, FwId};
+use serde::{Deserialize, Serialize};
+
+mod cache;
+mod cache_gha;
+mod git;
+mod html;
+mod http;
+mod process;
+mod util;
+
+use crate::cache::{Cache, FsCache};
+use crate::{cache_gha::GithubActionCache, util::other_err};
+
+// Increment with non-backwards-compatible changes are made to the cache record
+// format
+const CACHE_FORMAT_VERSION: &str = "v0";
+
+#[derive(Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
+struct Sizes {
+    size_with_uart: u64,
+    size_prod: u64,
+}
+
+#[derive(Clone, Eq, PartialEq, Serialize, Deserialize)]
+struct SizeRecord {
+    commit: git::CommitInfo,
+    sizes: Option<Sizes>,
+}
+
+fn main() {
+    if let Err(e) = real_main() {
+        println!("Fatal Error: {e}");
+        std::process::exit(1);
+    }
+}
+
+fn real_main() -> io::Result<()> {
+    let cache = GithubActionCache::new().map(box_cache).or_else(|e| {
+        let fs_cache_path = "/tmp/caliptra-size-cache";
+        println!(
+            "Unable to create github action cache: {e}; using fs-cache instead at {fs_cache_path}"
+        );
+        FsCache::new(fs_cache_path.into()).map(box_cache)
+    })?;
+
+    let worktree = git::WorkTree::new(Path::new("/tmp/caliptra-size-history-wt"))?;
+    let head_commit = worktree.head_commit_id()?;
+
+    if !worktree.is_log_linear()? {
+        println!("git history is not linear; attempting to squash PR");
+        let (Ok(pull_request_title), Ok(base_ref)) = (env::var("PR_TITLE"), env::var("PR_BASE_COMMIT")) else {
+            return Err(other_err("non-linear history not supported outside of a PR"));
+        };
+        let mut rebase_onto: String = base_ref;
+        for merge_parents in worktree.merge_log()? {
+            for parent in merge_parents {
+                if worktree.is_ancestor(&parent, "remotes/origin/main")?
+                    && !worktree.is_ancestor(&parent, &rebase_onto)?
+                {
+                    println!(
+                        "Found more recent merge from main; will rebase onto {}",
+                        parent
+                    );
+                    rebase_onto = parent;
+                }
+            }
+        }
+        println!("Resetting to {}", rebase_onto);
+        worktree.reset_hard(&rebase_onto)?;
+        println!("Set fs contents to {}", head_commit);
+        worktree.set_fs_contents(&head_commit)?;
+        println!("Committing squashed commit {pull_request_title:?}");
+        worktree.commit(&pull_request_title)?;
+
+        if !worktree.is_log_linear()? {
+            return Err(other_err("history still non-linear after squash; aborting"));
+        }
+    }
+
+    let git_commits = worktree.commit_log()?;
+
+    env::set_current_dir(worktree.path)?;
+
+    let mut records = vec![];
+
+    let mut cached_commit = None;
+    for commit in git_commits.iter() {
+        match cache.get(&format_cache_key(&commit.id)) {
+            Ok(Some(cached_records)) => {
+                if let Ok(cached_records) =
+                    serde_json::from_slice::<Vec<SizeRecord>>(&cached_records)
+                {
+                    println!("Found cache entry for remaining commits at {}", commit.id);
+                    records.extend(cached_records);
+                    cached_commit = Some(commit.id.clone());
+                    break;
+                } else {
+                    println!(
+                        "Error parsing cache entry {:?}",
+                        String::from_utf8_lossy(&cached_records)
+                    );
+                }
+            }
+            Ok(None) => {} // not found
+            Err(e) => println!("Error reading from cache: {e}"),
+        }
+        println!(
+            "Building firmware at commit {}: {}",
+            commit.id, commit.title
+        );
+        worktree.checkout(&commit.id)?;
+
+        let sizes = match compute_size(
+            &worktree,
+            &caliptra_builder::ROM_WITH_UART,
+            &caliptra_builder::ROM,
+        ) {
+            Ok(size_info) => Some(size_info),
+            Err(err) => {
+                println!("Error building commit {}: {err}", commit.id);
+                None
+            }
+        };
+
+        records.push(SizeRecord {
+            commit: commit.clone(),
+            sizes,
+        });
+    }
+    for (i, record) in records.iter().enumerate() {
+        if Some(&record.commit.id) == cached_commit.as_ref() {
+            break;
+        }
+        if let Err(e) = cache.set(
+            &format_cache_key(&record.commit.id),
+            &serde_json::to_vec(&records[i..]).unwrap(),
+        ) {
+            println!(
+                "Unable to write to cache for commit {}: {e}",
+                record.commit.id
+            );
+        }
+    }
+
+    let html = html::format_records(&records)?;
+
+    if let Ok(file) = env::var("GITHUB_STEP_SUMMARY") {
+        fs::write(file, &html)?;
+    } else {
+        println!("{html}");
+    }
+
+    Ok(())
+}
+
+fn compute_size(
+    worktree: &git::WorkTree,
+    with_uart_fwid: &FwId,
+    prod_fwid: &FwId,
+) -> io::Result<Sizes> {
+    // TODO: consider using caliptra_builder from the same repo as the firmware
+    let rom_with_uart_elf = caliptra_builder::build_firmware_elf_uncached(&FwId {
+        workspace_dir: Some(worktree.path),
+        ..*with_uart_fwid
+    })?;
+
+    let rom_prod_elf = caliptra_builder::build_firmware_elf_uncached(&FwId {
+        workspace_dir: Some(worktree.path),
+        ..*prod_fwid
+    })?;
+
+    Ok(Sizes {
+        size_with_uart: elf_size(&rom_with_uart_elf)?,
+        size_prod: elf_size(&rom_prod_elf)?,
+    })
+}
+
+fn box_cache(val: impl Cache + 'static) -> Box<dyn Cache> {
+    Box::new(val)
+}
+
+fn format_cache_key(commit: &str) -> String {
+    format!("{CACHE_FORMAT_VERSION}-{commit}")
+}
diff --git a/ci-tools/size-history/src/process.rs b/ci-tools/size-history/src/process.rs
new file mode 100644
index 00000000..046c2093
--- /dev/null
+++ b/ci-tools/size-history/src/process.rs
@@ -0,0 +1,49 @@
+// Licensed under the Apache-2.0 license
+
+use std::{
+    io,
+    process::{Command, Stdio},
+};
+
+pub fn run_cmd(cmd: &mut Command) -> io::Result<()> {
+    let status = cmd.status()?;
+    if status.success() {
+        Ok(())
+    } else {
+        Err(io::Error::new(
+            io::ErrorKind::Other,
+            format!(
+                "Process {:?} {:?} exited with status code {:?}",
+                cmd.get_program(),
+                cmd.get_args(),
+                status.code()
+            ),
+        ))
+    }
+}
+
+pub fn run_cmd_stdout(cmd: &mut Command, input: Option<&[u8]>) -> io::Result<Vec<u8>> {
+    cmd.stdin(Stdio::piped());
+    cmd.stdout(Stdio::piped());
+
+    let mut child = cmd.spawn()?;
+    if let (Some(mut stdin), Some(input)) = (child.stdin.take(), input) {
+        std::io::Write::write_all(&mut stdin, input)?;
+    }
+    let out = child.wait_with_output()?;
+    if out.status.success() {
+        Ok(out.stdout)
+    } else {
+        Err(io::Error::new(
+            io::ErrorKind::Other,
+            format!(
+                "Process {:?} {:?} exited with status code {:?} stdout {} stderr {}",
+                cmd.get_program(),
+                cmd.get_args(),
+                out.status.code(),
+                String::from_utf8_lossy(&out.stdout),
+                String::from_utf8_lossy(&out.stderr)
+            ),
+        ))
+    }
+}
diff --git a/ci-tools/size-history/src/util.rs b/ci-tools/size-history/src/util.rs
new file mode 100644
index 00000000..628eb225
--- /dev/null
+++ b/ci-tools/size-history/src/util.rs
@@ -0,0 +1,45 @@
+// Licensed under the Apache-2.0 license
+
+use std::io;
+
+pub fn expect_line(expected_val: &str, line: Option<&str>) -> io::Result<()> {
+    if let Some(line) = line {
+        if line == expected_val {
+            return Ok(());
+        }
+    }
+    Err(io::Error::new(
+        io::ErrorKind::Other,
+        format!("Expected line with contents {expected_val:?}; was {line:?}"),
+    ))
+}
+
+pub fn expect_line_with_prefix<'a>(prefix: &str, line: Option<&'a str>) -> io::Result<&'a str> {
+    if let Some(line) = line {
+        if let Some(stripped) = line.strip_prefix(prefix) {
+            return Ok(stripped);
+        }
+    };
+    Err(io::Error::new(
+        io::ErrorKind::Other,
+        format!("Expected line with prefix {prefix:?}; was {line:?}"),
+    ))
+}
+
+pub fn hex(bytes: &[u8]) -> String {
+    use std::fmt::Write;
+
+    let mut result = String::new();
+    for b in bytes {
+        write!(&mut result, "{:02x}", b).unwrap();
+    }
+    result
+}
+
+pub fn bytes_to_string(vec: Vec<u8>) -> io::Result<String> {
+    String::from_utf8(vec).map_err(other_err)
+}
+
+pub fn other_err(e: impl Into<Box<dyn std::error::Error + Send + Sync>>) -> io::Error {
+    io::Error::new(io::ErrorKind::Other, e)
+}
diff --git a/hw-model/src/lib.rs b/hw-model/src/lib.rs
index 31e30524..fe05940a 100644
--- a/hw-model/src/lib.rs
+++ b/hw-model/src/lib.rs
@@ -774,6 +774,7 @@ mod tests {
             crate_name: "caliptra-hw-model-test-fw",
             bin_name: "mailbox_responder",
             features: &["emu"],
+            ..Default::default()
         })
         .unwrap();
 
@@ -833,6 +834,7 @@ mod tests {
             crate_name: "caliptra-hw-model-test-fw",
             bin_name: "mailbox_sender",
             features: &["emu"],
+            ..Default::default()
         })
         .unwrap();
 
@@ -887,6 +889,7 @@ mod tests {
             crate_name: "caliptra-hw-model-test-fw",
             bin_name: "mailbox_responder",
             features: &["emu"],
+            ..Default::default()
         })
         .unwrap();
 


Test patch:
diff --git a/drivers/tests/integration_tests.rs b/drivers/tests/integration_tests.rs
index e8934077..9a12556c 100644
--- a/drivers/tests/integration_tests.rs
+++ b/drivers/tests/integration_tests.rs
@@ -11,6 +11,7 @@ fn start_driver_test(test_bin_name: &'static str) -> Result<DefaultHwModel, Box<
         crate_name: "caliptra-drivers-test-bin",
         bin_name: test_bin_name,
         features: &["emu"],
+        ..Default::default()
     })
     .unwrap();
     caliptra_hw_model::new(BootParams {
@@ -332,6 +333,7 @@ fn test_csrng() {
         crate_name: "caliptra-drivers-test-bin",
         bin_name: "csrng",
         features: &["emu"],
+        ..Default::default()
     })
     .unwrap();
 
diff --git a/fmc/tests/test_hand_off.rs b/fmc/tests/test_hand_off.rs
index fb339c0e..e9e6c62d 100644
--- a/fmc/tests/test_hand_off.rs
+++ b/fmc/tests/test_hand_off.rs
@@ -10,6 +10,7 @@ fn test_hand_off() {
         crate_name: "caliptra-fmc-mock-rt",
         bin_name: "caliptra-fmc-mock-rt",
         features: &["emu"],
+        workspace_dir: None,
     };
 
     let image = caliptra_builder::build_and_sign_image(
diff --git a/hw-model/tests/model_tests.rs b/hw-model/tests/model_tests.rs
index 50c17396..812db083 100644
--- a/hw-model/tests/model_tests.rs
+++ b/hw-model/tests/model_tests.rs
@@ -8,6 +8,7 @@ const BASE_FWID: FwId = FwId {
     crate_name: "caliptra-hw-model-test-fw",
     bin_name: "",
     features: &["emu"],
+    workspace_dir: None,
 };
 
 fn run_fw_elf(elf: &[u8]) -> DefaultHwModel {
diff --git a/rom/dev/tests/rv32_unit_tests.rs b/rom/dev/tests/rv32_unit_tests.rs
index 66f2aa09..48976474 100644
--- a/rom/dev/tests/rv32_unit_tests.rs
+++ b/rom/dev/tests/rv32_unit_tests.rs
@@ -7,6 +7,7 @@ pub const FWID: FwId = FwId {
     crate_name: "caliptra-rom",
     bin_name: "",
     features: &["emu"],
+    workspace_dir: None,
 };
 
 #[test]
diff --git a/rom/dev/tests/test_fmcalias_derivation.rs b/rom/dev/tests/test_fmcalias_derivation.rs
index d1a70a66..54bcf019 100644
--- a/rom/dev/tests/test_fmcalias_derivation.rs
+++ b/rom/dev/tests/test_fmcalias_derivation.rs
@@ -75,6 +75,7 @@ fn test_pcr_log() {
         crate_name: "caliptra-rom-test-fmc",
         bin_name: "caliptra-rom-test-fmc",
         features: &["emu"],
+        workspace_dir: None,
     };
 
     let fuses = Fuses {
@@ -211,6 +212,7 @@ fn test_fuse_log() {
         crate_name: "caliptra-rom-test-fmc",
         bin_name: "caliptra-rom-test-fmc",
         features: &["emu"],
+        workspace_dir: None,
     };
 
     let rom = caliptra_builder::build_firmware_rom(&ROM_WITH_UART).unwrap();
@@ -333,6 +335,7 @@ fn test_fht_info() {
         crate_name: "caliptra-rom-test-fmc",
         bin_name: "caliptra-rom-test-fmc",
         features: &["emu"],
+        workspace_dir: None,
     };
     let rom = caliptra_builder::build_firmware_rom(&ROM_WITH_UART).unwrap();
     let mut hw = caliptra_hw_model::new(BootParams {
diff --git a/rom/dev/tests/test_image_validation.rs b/rom/dev/tests/test_image_validation.rs
index 89355e94..cc1330f9 100644
--- a/rom/dev/tests/test_image_validation.rs
+++ b/rom/dev/tests/test_image_validation.rs
@@ -1200,6 +1200,7 @@ fn cert_test_with_custom_dates() {
         crate_name: "caliptra-rom-test-fmc",
         bin_name: "caliptra-rom-test-fmc",
         features: &["emu"],
+        workspace_dir: None,
     };
 
     let fuses = Fuses::default();
@@ -1279,6 +1280,7 @@ fn cert_test() {
         crate_name: "caliptra-rom-test-fmc",
         bin_name: "caliptra-rom-test-fmc",
         features: &["emu"],
+        workspace_dir: None,
     };
 
     let fuses = Fuses::default();
diff --git a/runtime/tests/integration_tests.rs b/runtime/tests/integration_tests.rs
index e23a99ee..2baa8dc3 100644
--- a/runtime/tests/integration_tests.rs
+++ b/runtime/tests/integration_tests.rs
@@ -15,6 +15,7 @@ fn run_rom_test(test_bin_name: &'static str) -> DefaultHwModel {
         crate_name: "caliptra-runtime-test-bin",
         bin_name: test_bin_name,
         features: FEATURES,
+        ..Default::default()
     };
 
     let rom = caliptra_builder::build_firmware_rom(&runtime_fwid).unwrap();
@@ -37,6 +38,7 @@ fn run_rt_test(test_bin_name: Option<&'static str>) -> DefaultHwModel {
             crate_name: "caliptra-runtime-test-bin",
             bin_name: bin,
             features: &["emu", "riscv", "runtime"],
+            ..Default::default()
         },
         None => APP_WITH_UART,
     };