While launching this blog with Astro, I became slightly annoyed with having to manage dates (created, last updated) in frontmatter across various sections of the blog - index, archive, tags and detail.
After a quick search I came across the Remark plugin recipe. But while this works on post details, it fails across listing pages. Remark plugins only run during .render()
, not when fetching collections.
I wanted to use Git history as the single source of truth, but needed something that:
- Works across the entire site (listings, RSS, SEO metadata)
- Works in CI/CD environments with shallow clones
- Doesn’t require manual date management
Initial Approach
My first attempt was straightforward - extract git timestamps when fetching blog posts:
// src/utils/getBlogPosts.ts
function getGitTimestamps(filepath: string): {
created: Date | null;
modified: Date | null;
} {
try {
// Get first commit (creation date)
const firstCommit = execSync(
`git log --follow --format=%aI --reverse "${filepath}" | head -1`,
{ encoding: "utf8" },
).trim();
// Get last commit (modified date)
const lastCommit = execSync(`git log -1 --format=%aI "${filepath}"`, {
encoding: "utf8",
}).trim();
return {
created: firstCommit ? new Date(firstCommit) : null,
modified: lastCommit ? new Date(lastCommit) : null,
};
} catch (error) {
console.warn(`Git timestamps not available for ${filepath}`);
return { created: null, modified: null };
}
}
This worked perfectly locally where I had full git history. But it failed in production…
The Shallow Clone Problem
Initially, this worked great locally. But when deploying to Cloudflare Pages, the timestamps weren’t working. After investigation, I discovered that Cloudflare Pages performs shallow clones - they only fetch recent commits, not the full git history.
This is a common optimization in CI/CD environments to speed up builds, but it breaks any logic that depends on git history.
The Solution: Astro Integration
Instead of trying to configure the CI environment, I created an Astro integration that pre-generates timestamps during local development:
// src/integrations/git-timestamps.ts
export default function gitTimestamps(): AstroIntegration {
return {
name: "git-timestamps",
hooks: {
"astro:build:start": async ({ logger }) => {
// Skip in CI environments to preserve committed timestamps
const isCI =
process.env.CI === "true" || process.env.CLOUDFLARE_PAGES === "1";
if (isCI) {
logger.info(
"Running in CI environment - using pre-generated timestamps",
);
return;
}
// Generate timestamps from full git history
const timestampData = generateTimestamps();
// Save to JSON file that gets committed
writeFileSync(
"src/data/blog-timestamps.json",
JSON.stringify(timestampData, null, 2),
);
},
},
};
}
Then update getBlogPosts.ts
to use pre-generated timestamps first:
// Try pre-generated timestamps first (works in CI)
const preGeneratedTimestamps = loadPreGeneratedTimestamps();
if (preGeneratedTimestamps?.[post.id]) {
// Use pre-generated timestamps
} else if (gitAvailable) {
// Fall back to direct git operations (local dev)
}
Add the integration to your Astro config:
// astro.config.mjs
import gitTimestamps from "./src/integrations/git-timestamps";
export default defineConfig({
integrations: [mdx(), sitemap(), react(), gitTimestamps()],
});
Complete Implementation
The final solution combines three parts:
1. The Astro Integration
Generates timestamps during build and saves them to a JSON file.
2. Updated getBlogPosts
First checks for pre-generated timestamps, then falls back to git operations:
// src/utils/getBlogPosts.ts
export async function getBlogPosts() {
const posts = await getCollection("blog");
const preGeneratedTimestamps = loadPreGeneratedTimestamps();
const enrichedPosts = posts.map((post) => {
// Try pre-generated timestamps first
if (preGeneratedTimestamps?.[post.id]) {
// Use cached timestamps from JSON
} else if (isGitAvailable()) {
// Fall back to git operations
} else {
// Final fallback to filesystem
}
});
}
3. Display in Templates
Show modification dates when available:
<time datetime={post.data.date.toISOString()}>
{formatDate(post.data.date)}
</time>
{
post.data.updatedDate && (
<>
<span class="text-gray-600">•</span>
<span class="text-sm text-gray-400">
Updated:{" "}
<time datetime={post.data.updatedDate.toISOString()}>
{formatDate(post.data.updatedDate)}
</time>
</span>
</>
)
}
Usage
Replace all getCollection("blog")
calls:
// Before
const posts = await getCollection("blog");
// After
import { getBlogPosts } from "../utils/getBlogPosts";
const posts = await getBlogPosts();
The Result
This hybrid approach gives us the best of both worlds:
- Local development: Fresh timestamps from full git history
- CI/CD builds: Pre-generated timestamps from committed JSON file
- Zero maintenance: Timestamps update automatically when you build locally
- Works everywhere: No CI configuration needed
The blog-timestamps.json
file gets committed to your repo and updated whenever you build locally. In CI environments with shallow clones, the pre-generated timestamps are used instead.
Key Takeaways
- Cloudflare Pages uses shallow clones - don’t assume full git history in CI
- Always check for CI environment - prevent your integration from overwriting good data with shallow clone results
- Astro integrations are powerful - they can solve build-time challenges elegantly
- Pre-generation is your friend - compute expensive operations locally, use cached results in CI
You now have blog posts with accurate timestamps that update automatically. No more manual date management, and it works seamlessly with any deployment platform - shallow clones or not.
This solution now powers this blog and blogs I’m managing for clients at SocialTide. The approach handles git timestamps elegantly while working seamlessly with modern CI/CD platforms.