diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e366b2fc6..496941d18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -58,6 +58,15 @@ setting only to the vnext build, not the live build. If vnext builds a different source branch, set the intended label explicitly instead. Changing the badge on a deployed static site requires rebuilding its frontend assets. +## Incremental API builds + +API reference cache keys include the deployment commit and repository URL emitted +by the shared page head. Changing either value must regenerate cached API pages: +mixing old and new `git-commit-id` metadata makes the deployment guard correctly +fall back to full-page navigation. Builds with unchanged data and deployment +metadata can still reuse their API pages. Keep the head metadata and API cache +keys in sync when changing build identity handling. + ## 🆘 Getting help - **Issues**: Report bugs or request features via [GitHub Issues](https://github.com/microsoft/aspire.dev/issues) diff --git a/src/frontend/src/components/starlight/Head.astro b/src/frontend/src/components/starlight/Head.astro index ef3ae838a..7e220ceab 100644 --- a/src/frontend/src/components/starlight/Head.astro +++ b/src/frontend/src/components/starlight/Head.astro @@ -9,17 +9,13 @@ import AccessibleCodeButtons from '../AccessibleCodeButtons.astro'; import SiteTour from '../SiteTour.astro'; import { getStructuredData } from '@utils/structured-data'; import { getOgMetadata } from '@utils/page-metadata'; +import { deploymentMetadata } from '@utils/deployment-metadata'; // The Poppins weights that text in the first viewport renders with. The same files back the // @font-face rules in site.css, so these resolve to the already-emitted assets. const preloadedFonts = [poppinsRegular, poppinsSemiBold, poppinsBold]; -const repoUrl = ( - import.meta.env.PUBLIC_REPO_URL || - import.meta.env.REPO_URL || - 'https://github.com/microsoft/aspire.dev' -).replace(/\/$/, ''); -const commit = import.meta.env.PUBLIC_GIT_COMMIT_ID || import.meta.env.GIT_COMMIT_ID || ''; +const { commit, repoUrl } = deploymentMetadata; const route = Astro.locals?.starlightRoute; const entryId = route?.entry?.id; diff --git a/src/frontend/src/env.d.ts b/src/frontend/src/env.d.ts index 341130a95..e3e8366ca 100644 --- a/src/frontend/src/env.d.ts +++ b/src/frontend/src/env.d.ts @@ -1 +1,8 @@ declare const __ASPIRE_REDIRECT_PATHS__: readonly string[]; + +interface ImportMetaEnv { + readonly PUBLIC_GIT_COMMIT_ID?: string; + readonly GIT_COMMIT_ID?: string; + readonly PUBLIC_REPO_URL?: string; + readonly REPO_URL?: string; +} diff --git a/src/frontend/src/utils/api-cache-key.ts b/src/frontend/src/utils/api-cache-key.ts index b36f405c3..e02e2b90b 100644 --- a/src/frontend/src/utils/api-cache-key.ts +++ b/src/frontend/src/utils/api-cache-key.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto'; +import { deploymentMetadata } from './deployment-metadata'; const digests = new WeakMap(); @@ -7,13 +8,16 @@ const digests = new WeakMap(); * * Every page generated from a package or module document returns this value * from `getStaticPaths()`, so Astro can reuse a page from the previous build - * as long as the document it was rendered from (and the template code) is - * unchanged. The digest is memoized per document object. + * as long as its document, deployment metadata and template code are unchanged. + * The digest is memoized per document object. */ export function apiDocumentCacheKey(document: object): string { let digest = digests.get(document); if (!digest) { - digest = createHash('sha1').update(JSON.stringify(document)).digest('base64url'); + // Bun reads import.meta.env at render time, outside Astro's dependency hash. + digest = createHash('sha1') + .update(JSON.stringify([document, deploymentMetadata])) + .digest('base64url'); digests.set(document, digest); } return digest; diff --git a/src/frontend/src/utils/deployment-metadata.ts b/src/frontend/src/utils/deployment-metadata.ts new file mode 100644 index 000000000..f1c051faa --- /dev/null +++ b/src/frontend/src/utils/deployment-metadata.ts @@ -0,0 +1,8 @@ +export const deploymentMetadata = { + commit: import.meta.env.PUBLIC_GIT_COMMIT_ID || import.meta.env.GIT_COMMIT_ID || '', + repoUrl: ( + import.meta.env.PUBLIC_REPO_URL || + import.meta.env.REPO_URL || + 'https://github.com/microsoft/aspire.dev' + ).replace(/\/$/, ''), +}; diff --git a/src/frontend/tests/unit/api-cache-key.vitest.test.ts b/src/frontend/tests/unit/api-cache-key.vitest.test.ts new file mode 100644 index 000000000..3a4c99b43 --- /dev/null +++ b/src/frontend/tests/unit/api-cache-key.vitest.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +async function loadCacheKeys(commit: string, repoUrl = 'https://github.com/microsoft/aspire.dev') { + vi.stubEnv('PUBLIC_GIT_COMMIT_ID', commit); + vi.stubEnv('PUBLIC_REPO_URL', repoUrl); + vi.resetModules(); + return import('../../src/utils/api-cache-key'); +} + +describe('API incremental cache deployment identity', () => { + const document = { package: { name: 'Aspire.Hosting.Redis' }, types: [] }; + + test('retains the same key for unchanged data and deployment', async () => { + const first = await loadCacheKeys('build-a'); + const key = first.apiDocumentCacheKey(document); + expect(first.apiDocumentCacheKey(document)).toBe(key); + expect(first.apiDocumentCacheKey(structuredClone(document))).toBe(key); + const second = await loadCacheKeys('build-a'); + expect(second.apiDocumentCacheKey(document)).toBe(key); + expect(second.apiDocumentCacheKey({ ...document, types: ['RedisResource'] })).not.toBe(key); + }); + + test('invalidates unchanged API data when the deployment changes', async () => { + const first = await loadCacheKeys('build-a'); + const oldKey = first.apiDocumentCacheKey(document); + const second = await loadCacheKeys('build-b'); + expect(second.apiDocumentCacheKey(document)).not.toBe(oldKey); + }); + + test('invalidates source links when the repository changes, ignoring a trailing slash', async () => { + const first = await loadCacheKeys('build-a'); + const oldKey = first.apiDocumentCacheKey(document); + const equivalent = await loadCacheKeys('build-a', 'https://github.com/microsoft/aspire.dev/'); + expect(equivalent.apiDocumentCacheKey(document)).toBe(oldKey); + const changed = await loadCacheKeys('build-a', 'https://github.com/example/docs'); + expect(changed.apiDocumentCacheKey(document)).not.toBe(oldKey); + }); + + test('applies deployment-aware keys to every path for the owning package', async () => { + const { apiDocumentCacheKey, withApiCacheKeys } = await loadCacheKeys('build-a'); + const paths = ['type', 'member'].map((name) => ({ + params: { name }, + props: { packageName: document.package.name }, + })); + const result = withApiCacheKeys(paths, [{ data: document }]); + expect(result).toEqual(paths.map((path) => ({ + ...path, + cacheKey: apiDocumentCacheKey(document), + }))); + }); + + test('shares the head metadata fallback values', async () => { + await loadCacheKeys(''); + vi.stubEnv('GIT_COMMIT_ID', 'fallback-build'); + vi.stubEnv('PUBLIC_REPO_URL', ''); + vi.stubEnv('REPO_URL', 'https://github.com/example/docs/'); + vi.resetModules(); + const { deploymentMetadata } = await import('../../src/utils/deployment-metadata'); + expect(deploymentMetadata).toEqual({ + commit: 'fallback-build', + repoUrl: 'https://github.com/example/docs', + }); + }); +});