Phase 1 — Test hardening
Complete before any migration work. Steps 1e and 1f are hard prerequisites for Phase 4 — they decouple the component library from Next.js and i18n so Phase 4 only touches page files, not components.
1a — Local visual regression via Playwright in Docker
Chromatic runs cloud-only; there is no local visual regression. @storybook/test-runner
is already installed — it drives a built Storybook with Playwright and
toHaveScreenshot(). Running inside a pinned Docker image pins the browser
and font stack, making screenshots reproducible across macOS, Linux, and CI.
@playwright/test is already at 1.57.0; use the matching image.
Add .storybook/test-runner.ts to take screenshots per story, reusing the
Chromatic viewport modes:
// .storybook/test-runner.ts
import { getStoryContext } from '@storybook/test-runner';
import { expect } from '@playwright/test';
export const postVisit = async (page, context) => {
const storyContext = await getStoryContext(page, context);
const modes = storyContext.parameters?.chromatic?.modes;
if (modes) {
for (const [name, cfg] of Object.entries(modes)) {
if (cfg.viewport) await page.setViewportSize(cfg.viewport);
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(
`${context.id}-${name}.png`,
{ maxDiffPixelRatio: 0.02 }
);
}
} else {
await expect(page).toHaveScreenshot(
`${context.id}.png`,
{ maxDiffPixelRatio: 0.02 }
);
}
};
Add two scripts to package.json (build Storybook, serve it, run the test
runner in Docker). On macOS Docker Desktop replace localhost with
host.docker.internal.
"test-storybook:visual": "yarn build-storybook && \
npx serve storybook-static -p 6006 -n & \
docker run --rm --network host \
-v \"$(pwd):/work\" -w /work \
mcr.microsoft.com/playwright:v1.57.0-noble \
yarn test-storybook --url http://localhost:6006",
"test-storybook:visual:update": "yarn build-storybook && \
npx serve storybook-static -p 6006 -n & \
docker run --rm --network host \
-v \"$(pwd):/work\" -w /work \
mcr.microsoft.com/playwright:v1.57.0-noble \
yarn test-storybook --url http://localhost:6006 --update-snapshots"
Run test-storybook:visual:update once to generate baselines and commit
them. Chromatic continues on PRs for reviewer-facing diffs; the Docker run is the local
and CI gate.
1b — Fix Playwright local setup
playwright.config.ts has both baseURL and
webServer commented out. The three existing browser test specs hardcode
http://localhost:3001 and require a manually started server. Playwright
supports an array of webServer entries; configure it to start both Wiremock
and Next.js before the suite runs:
// playwright.config.ts
webServer: [
{
command: 'yarn wiremock',
url: 'http://localhost:8080/__admin/health',
reuseExistingServer: !process.env.CI,
},
{
command: 'API_SERVER=http://localhost:8080 yarn start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
],
use: {
baseURL: 'http://localhost:3000',
},
Replace all hardcoded http://localhost:3001 URLs in existing specs with
relative paths. Tests then run identically against local dev, CI, and staging.
1c — Add E2E tests for critical flows
Article tabs
Tests asserting correct content per URL survive both the current router-based tabs and the Phase 3 static HTML files:
test('fulltext tab is active by default', async ({ page }) => {
await page.goto('/reviewed-preprints/85111');
await expect(page.locator('[aria-current="page"]')).toHaveText('Full text');
});
test('figures tab renders figures', async ({ page }) => {
await page.goto('/reviewed-preprints/85111/figures');
await expect(page.locator('[aria-current="page"]')).toHaveText('Figures');
});
URL redirects
The same assertions verify the Caddy configuration in step 3c:
test('articles path redirects to reviewed-preprints', async ({ request }) => {
const response = await request.get('/articles/85111', { maxRedirects: 0 });
expect(response.status()).toBe(301);
expect(response.headers()['location']).toContain('/reviewed-preprints/85111');
});
VOR redirect
Add a VOR Wiremock fixture, then assert that a VOR article at
/reviewed-preprints/ redirects to /articles/. The test
documents the requirement so whichever step 3a approach is chosen can be verified
against it.
Both EPP flavours
The Wiremock fixture biophysics-colab-111111 exists but no browser test
uses it. Add an E2E test asserting flavour-specific strings —
"Curators" not "Editors", "Curated Preprint" not "Reviewed Preprint" — before step 1f
refactors i18n.
1d — Unit test getServerSideProps logic
getServerSideProps in [...path].page.tsx (lines 218–317) has
four branches: VOR redirect, preprint redirect, unknown msid → 404, and PDF variant
pre-fetching imgInfo. No unit tests exist. Mock fetchVersion
via fetch-mock (already installed); assert the returned shape
({ redirect }, { notFound: true }, { props })
for each branch.
1e — Decouple components from next/*
@storybook/nextjs shims next/image, next/router,
and next/navigation. Phase 4 requires @storybook/react, which
has no shims. Removing the Next.js dependencies now means story files don't change
during the Astro migration.
Replace next/image in components
Seven components use next/image for logos and icons:
site-header, site-header-biophysics-colab,
biophysics-colab-site-footer, footer-main,
investors, error-messages, page-not-found.
Replace each with a plain <img>. Don't introduce a wrapper —
the Phase 4 replacement is astro:assets <Image>, a different API.
Replace useRouter() with a prop
[...path].page.tsx reads the active tab from useRouter().
Derive it in getServerSideProps from the request path and pass it down as
an activeTab prop instead.
Swap @storybook/nextjs → @storybook/react
Once no component imports from next/*, update .storybook/main.js
and confirm baselines are unchanged. Add an explicit i18n decorator to
preview.tsx (removed again in step 1f).
1f — Replace i18n namespace abuse with typed SiteConfig
i18n has three namespaces (default, elife,
biophysics_colab) holding site config — publisher name, editor labels,
timeline titles, URLs — selected via NEXT_PUBLIC_SITE_NAME. There is one
language. Problems: nested $t() references are invisible to TypeScript; all
17 components calling useTranslation() require I18nextProvider
to render, making isolation testing awkward and Astro island integration unnecessarily
complex.
Replace with a typed SiteConfig object and a single React context:
// src/site-config.ts
export type SiteConfig = {
publisherShort: string;
publisherLong: string;
processUrl: string;
aboutAssessmentsUrl: string;
aboutAssessmentsDescription: string;
editorsAndReviewersTitle: string;
timelineVersionTitle: string;
timelineVersionTitleWithEvaluation: string;
reviewProcessReviewed: string;
reviewProcessRevised: string;
canonicalUrl: (msid: string) => string;
// ... one field per flavour-varying value
};
export const elifeConfig: SiteConfig = {
publisherShort: 'eLife',
editorsAndReviewersTitle: 'Editors',
canonicalUrl: (msid) => `https://elifesciences.org/reviewed-preprints/${msid}`,
// ...
};
export const biophysicsColabConfig: SiteConfig = {
publisherShort: 'Biophysics Colab',
editorsAndReviewersTitle: 'Curators',
canonicalUrl: (msid) => `/reviewed-preprints/${msid}`,
// ...
};
useSiteConfig() replaces every useTranslation() call. Set the
context once in _app.page.tsx from config.siteName. Nested
$t() references become plain string composition; interpolated values
(e.g. URL templates) become functions, as shown with canonicalUrl above.
TypeScript surfaces any missed call sites at compile time.
Update Storybook stories to pass both configs explicitly — this gives Chromatic and the Docker visual tests flavour coverage on every PR without needing the i18n provider or an environment variable.
Phase 2 — Extract /api, eliminate proxies
Remove all server-side concerns from this repo. After this phase the client has zero API routes. The Playwright specs covering the API endpoints migrate to the server repo along with the code they test, where they become integration tests for the server.
2a — Move reviewed-preprints API to enhanced-preprint-server
/api/reviewed-preprints and /api/reviewed-preprints/[msid]
are a format adapter: they take the EPP internal schema and emit
application/vnd.elife.reviewed-preprint+json; version=1. The server already
owns the data; it should own the format too.
Move the ~400-line transformation in reviewed-preprints.page.ts to
enhanced-preprint-server. Logic to port:
- Author-line generation and date formatting
- eLife Assessment extraction from evaluation HTML (
findTermsregex for significance/strength terms) x-total-countheader, pagination, and date-range query parameters- Full-text content serialisation for
indexContent
Move api-reviewed-preprints.spec.ts with the code. Co-ordinate with the
external consumer team before retiring the client-side route.
2b — Move citation proxy to server
/api/citations/[msid]/bibtex and /ris exist because the
browser knows the msid but the upstream citation endpoint requires a DOI.
The proxy resolves this by looking up the article, extracting the DOI, then fetching
the citation and returning it with a Content-Disposition: attachment
header. The server already resolves msid → DOI; move the proxy there and update
article pages to link directly to the server citation endpoints.
2c — Replace download proxies with direct links
/api/downloads/[msid]/pdf and /xml proxy files from upstream
to attach a Content-Disposition: attachment; filename=… header.
-
PDF:
article.pdfUrlis present in article data at render time. Render<a href="{pdfUrl}" download="{msid}-v{version}.pdf">. Thedownloadattribute forces a save dialog with the chosen filename. -
XML:
generateArticleXmlUri(msid, versionIdentifier)deterministically constructs the URL. Same approach.
The download attribute is restricted by browser security to same-origin
requests. If files are served from a different origin, the fix is a
Content-Disposition: attachment header on the CDN distribution — a
configuration change, not a proxy.
2d — Collapse operational endpoints
robots.txt becomes a static file in
public/robots.txt. Maintain separate variants per environment and select
the correct one at deploy time. Delete robots.page.ts.
/ping is a health check. A static
public/ping file returning 200 satisfies any check that verifies only HTTP
status. Delete ping.page.ts.
/status checks whether the upstream API is reachable —
a concern that belongs to the server, which owns that dependency. Move it there and
delete status.page.ts.
Phase 3 — Static export, served by Caddy
Eliminate the Node.js runtime entirely. After Phase 2 the client has no API routes,
making output: 'export' possible. The Phase 1 E2E tests verify the Caddy
configuration — run the same Playwright suite against the Caddy-served static output and
it should pass unchanged.
3a — Convert SSR pages to static generation
Both getServerSideProps functions switch to static generation.
index.page.tsx is a straightforward conversion — call
fetchVersions() in getStaticProps instead of at request time.
[...path].page.tsx requires getStaticPaths to enumerate all
articles at build time. Generate only the base /msid path; Caddy's
try_files serves the same file for sub-paths like /msid/figures,
and the active tab is determined client-side from the URL (as a prop after step 1e):
export const getStaticPaths: GetStaticPaths = async () => {
const { items } = await fetchVersions();
return {
paths: items.map(({ msid }) => ({ params: { path: [msid] } })),
fallback: false,
};
};
The VOR redirect. The redirect logic at lines 270–291 of
[...path].page.tsx cannot live in a static HTML file. It must be resolved
before this step ships. Three options in order of preference:
-
Unify the URL spaces. Serve everything under
/reviewed-preprints/, add<link rel="canonical">for SEO, and retire the/articles/path for preprints. No server round-trip required. - Have Caddy proxy the decision to a lightweight endpoint on enhanced-preprint-server that returns the canonical URL for a given msid. Caddy performs the redirect.
- Defer to Phase 4, where Astro middleware can resolve it at build time by writing redirect HTML stubs during the static build.
3b — Static export config and image handling
// next.config.js
module.exports = {
output: 'export',
images: { unoptimized: true },
// existing config...
};
images: { unoptimized: true } disables the /_next/image
server-side resize endpoint. It has no visible effect here: the seven components using
next/image are all SVG/PNG logos; article figures use IIIF URL parameters
for sizing independently of Next.js. Phase 4 replaces logos with
astro:assets <Image> for build-time WebP generation.
3c — URL rewrites to Caddy
The rewrite rules in next.config.js move to the Caddyfile. The Phase 1
redirect tests run against the Caddy-served output and verify correctness:
epp.elifesciences.org {
root * /srv/out
@articles path_regexp art ^/articles/(.+)$
redir @articles /reviewed-preprints/{re.art.1} 301
@previews path_regexp prev ^/previews/(.+)$
redir @previews /reviewed-preprints/{re.prev.1} 301
@numeric path_regexp num ^/(\d+)(\.bib|\.ris|\.pdf|\.xml)?$
redir @numeric /reviewed-preprints/{re.num.1}{re.num.2} 301
file_server
try_files {path} {path}.html {path}/index.html =404
}
3d — Rebuild webhook
With fallback: false, new articles return 404 until a rebuild completes.
Configure enhanced-preprint-server to emit a webhook on article publication that triggers
the CI/CD pipeline. The pipeline runs next build, then updates Caddy's
document root atomically via a symlink swap or rolling deploy.
Add a scheduled rebuild (for example, hourly) as a fallback in case a webhook delivery fails silently. The scheduled rebuild bounds the worst-case delay for a new article becoming visible.
Phase 4 — Migrate to Astro
Introduces incremental builds: only re-render pages for changed or new articles, not the full corpus. Because Phase 1 decoupled the component library from Next.js-specific imports and replaced i18n with a typed config, this phase focuses on Astro page files and content collections. The 147 React components and their stories do not change.
4a — Project setup and content collections
Astro's content layer (Astro 5) is what enables incremental builds.
It replaces getStaticProps and getStaticPaths with a
centralised data-fetching layer that caches results between builds in
.astro/data-store.json. Each entry carries an id and a
digest. On every build Astro compares digests against the cache; only
entries whose digest changed get their pages re-rendered. One new article = one changed
digest = only that article's pages rebuilt.
// src/content/config.ts
import { defineCollection } from 'astro:content';
const articles = defineCollection({
loader: async () => {
const { items } = await fetch(
`${import.meta.env.API_SERVER}/api/preprints`
).then(r => r.json());
return items.map((item) => ({
id: item.msid,
digest: `${item.msid}-v${item.versionIdentifier}-${item.published}`,
...item,
}));
},
});
export const collections = { articles };
For the incremental cache to work in CI, .astro/data-store.json must be
persisted between runs. Without this, every CI build is a full rebuild:
# GitHub Actions
- uses: actions/cache@v4
with:
path: .astro
key: astro-${{ github.ref_name }}-${{ github.run_id }}
restore-keys: astro-${{ github.ref_name }}-
4b — Page files and BaseLayout
Astro pages live in src/pages/ as .astro files. The existing
React components are used directly — no component rewrites required.
---
// src/pages/reviewed-preprints/[msid].astro
import { getCollection, getEntry } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { ArticlePage } from '../../components/pages/article/article-page';
import { elifeConfig, biophysicsColabConfig } from '../../site-config';
export async function getStaticPaths() {
const articles = await getCollection('articles');
return articles.map((entry) => ({ params: { msid: entry.id } }));
}
const { msid } = Astro.params;
const entry = await getEntry('articles', msid);
const siteName = import.meta.env.PUBLIC_SITE_NAME ?? 'elife';
const siteConfig = siteName === 'biophysics-colab'
? biophysicsColabConfig : elifeConfig;
---
<BaseLayout title={entry.data.title} siteName={siteName}>
<ArticlePage article={entry.data} activeTab="fulltext"
siteConfig={siteConfig} />
</BaseLayout>
_app.page.tsx and _document.page.tsx become
src/layouts/BaseLayout.astro, handling GTM, Cookiebot, font variables, and
the layout switch. Replace next/font/google with
@fontsource/noto-serif / @fontsource/noto-sans.
NEXT_PUBLIC_* → PUBLIC_* via import.meta.env.
No I18nextProvider needed — SiteConfig is a plain prop.
4c — Split catch-all into per-tab pages
In Astro, each tab is a separate .astro file. Tab navigation becomes plain
<a href> links between static HTML files — no JavaScript required,
works without JS, indexed independently by crawlers:
src/pages/reviewed-preprints/
[msid].astro → fulltext (canonical)
[msid]/figures.astro → figures tab
[msid]/reviews.astro → peer review tab
Both /reviewed-preprints/msid and /msid/fulltext currently
show fulltext. Make [msid].astro canonical; add a one-line Caddy 301 from
/msid/fulltext → /msid. The PDF tab can become a static page
or be retired in favour of the direct PDF link from step 2c.
4d — Audit client:* directives
Astro renders React components to static HTML at build time and ships zero JavaScript
for them by default. A component only hydrates on the client when opted into with a
client:* directive. Missing a directive on an interactive component
produces no error — the component renders correctly but ignores user events.
| Directive | Hydrates when | Use for |
|---|---|---|
client:load |
Page loads immediately | Components needed on first interaction |
client:idle |
Browser reaches idle | Lower-priority interactive components |
client:visible |
Scrolled into viewport | Components below the fold |
client:only="react" |
Never SSR’d | Components that cannot render server-side |
Most of the 147 components are presentational and need no directive. Interactive
components identified by Phase 1 E2E tests: authors toggle
(client:visible), clipboard copy (client:visible),
jump-to menu (client:idle), modal (client:load).
4e — Build-time image optimisation
Replace the plain logo <img> tags (from step 1e) with
astro:assets <Image> for build-time WebP generation:
---
import { Image } from 'astro:assets';
import elifeLogo from '../../images/elife-logo.svg';
---
<Image src={elifeLogo} width={120} height={40} alt="eLife" />
4f — Verify visual regression baselines
Run the Docker visual regression suite against the Astro build. Run Phase 1 E2E tests against the Caddy-served Astro output. Both should pass unchanged.
Phase 5 — Eliminate the container
Upload out/ to Fastly Object Storage; Fastly CDN serves directly from its
own network. Delete the k8s Deployment, Service, and IngressRoute. Retire the Caddy
Dockerfile. No cross-cloud dependency.
5a — Provision Fastly Object Storage bucket and configure the Fastly service
Create a Fastly Object Storage bucket via the Fastly API or CLI. No "static website hosting" mode — the CDN handles URL routing via VCL. Auth is a Fastly API token; no IAM or cross-cloud credentials needed.
Point the Fastly service backend at the bucket. Add VCL for:
- Directory-style URLs — append
index.htmlwhen path has no file extension - 404 handling — serve
404/index.htmlwith status 404 on missing objects - Surrogate keys — tag HTML responses by msid for per-article cache purge
# Example Fastly VCL fragment — directory index rewriting
sub vcl_recv {
if (req.url !~ "\.[a-zA-Z0-9]+$") {
set req.url = req.url + "index.html";
}
}
Test before DNS moves: point the Fastly service at Object Storage while Caddy stays
live in k8s. Use a Fastly staging service or Fastly-Debug: 1 to verify
article pages, homepage, and 404 handling.
5b — Update CI/CD: upload to Object Storage and purge Fastly cache
Replace Docker build + kubectl apply with an Object Storage upload and
Fastly purge. Fastly Object Storage is S3-compatible, so aws s3 sync
--endpoint-url works. Two passes for correct cache headers:
# Hashed assets — cache forever
aws s3 sync out/ s3://epp-static/ \
--endpoint-url "$FASTLY_STORAGE_ENDPOINT" \
--exclude "*.html" \
--cache-control "public, max-age=31536000, immutable" \
--delete
# HTML — always revalidate
aws s3 sync out/ s3://epp-static/ \
--endpoint-url "$FASTLY_STORAGE_ENDPOINT" \
--include "*.html" \
--cache-control "no-cache" \
--delete
After upload, purge by surrogate key (single article) or purge the whole service (full rebuild):
fastly purge --service-id "$FASTLY_SERVICE_ID" \
--surrogate-key "rp/$MSID vor/$MSID"
Run the hashed-asset pass first — HTML must not reference an asset hash before the asset is uploaded.
5c — VOR redirect in Fastly VCL
If the VOR redirect was not resolved in step 3a: use a Fastly Edge Dictionary mapping
each msid to its type (vor or rp), populated by CI via the
Fastly API. VCL issues a 301 before Object Storage is consulted:
# Populate dictionary during CI build (pseudocode)
for article in articles:
fastly dictionary upsert \
--dict-id "$DICT_ID" \
--item-key "$article.msid" \
--item-value "$article.type" # "vor" or "rp"
# VCL recv — redirect if URL prefix does not match article type
sub vcl_recv {
declare local var.msid STRING;
declare local var.type STRING;
set var.msid = regsuball(req.url, "^/(articles|reviewed-preprints)/([^/?]+).*", "\2");
set var.type = table.lookup(article_types, var.msid, "");
if (var.type == "vor" && req.url ~ "^/reviewed-preprints/") {
return(synth(301, "/articles/" + var.msid));
}
if (var.type == "rp" && req.url ~ "^/articles/") {
return(synth(301, "/reviewed-preprints/" + var.msid));
}
}
5d — Remove k8s manifests and cut over DNS
Lower DNS TTL to 60s at least 24 hours before. Switch the Fastly backend from the k8s ingress to Object Storage; verify via Fastly's hostname. If DNS already points to Fastly, the backend switch is the entire cutover — DNS does not move.
After a monitoring period, delete from the k8s manifests repo:
Deployment— EPP client pods and Caddy containerService— ClusterIP or NodePort for the Caddy containerIngressRoute(Traefik) — routing rule from Traefik to the service- Any
HorizontalPodAutoscalertargeting the EPP deployment
Remove the Caddy Dockerfile and Caddyfile. URL rewrite rules are now in Fastly VCL (step 5c) or were already removed in step 3c.
Step overview and risk register
| Step | What changes | Key risk |
|---|---|---|
| 1a | Playwright screenshot assertions on Storybook stories run inside pinned Docker image; baseline screenshots committed | Medium — Pixel-diff threshold must be calibrated against real rendering noise before the check becomes gating. |
| 1b | Playwright webServer config starts both Wiremock and Next.js automatically; hardcoded URLs replaced with baseURL |
Low — No VOR Wiremock fixture exists; VOR redirect test in 1c is blocked until one is added. |
| 1c | E2E tests for article tabs, URL redirects, VOR redirect, homepage, 404, and both EPP flavours | Medium — VOR Wiremock fixture is a prerequisite. Biophysics-colab flavour assertions depend on i18n being correctly initialised until step 1f replaces it. |
| 1d | Unit tests for all four getServerSideProps branches |
Low — Standard unit testing with mockable dependencies. |
| 1e | Seven components: next/image → <img>. Page component: useRouter() → activeTab prop. Storybook: @storybook/nextjs → @storybook/react |
Medium — Framework swap breaks stories that still use router context. Complete the prop refactor first. |
| 1f | i18n namespace abuse replaced with typed SiteConfig; both flavours explicitly covered in Storybook and E2E tests |
Medium — 17 components to update; nested |
| 2a | Reviewed-preprints format adapter (~400 lines) moves to enhanced-preprint-server; API specs move with it; external consumers re-pointed | High — Complex transform logic; server may be a different language; external consumer coordination requires another team. |
| 2b | Citation proxy moves to server; article pages link directly to server citation endpoints | Low — Server already resolves msid → DOI. 30 lines of proxy logic. |
| 2c | PDF and XML download proxies removed; article pages use download attribute direct links |
Medium — Browser ignores |
| 2d | robots.txt becomes a static file; /ping becomes a static file; /status moves to server |
Low — Confirm nothing monitors |
| 3a | getServerSideProps → getStaticProps + getStaticPaths; VOR redirect decision made and implemented |
Medium — Build time scales with article count; benchmark early. VOR redirect logic must be resolved before this step ships. |
| 3b | output: 'export' added; images: { unoptimized: true } set |
Low — No user-visible effect for this project's image types (SVG logos, IIIF figures). |
| 3c | URL rewrites move from next.config.js to Caddyfile; Phase 1 redirect tests verify the result |
Medium — Caddy rewrite syntax differs from Next.js. Dynamic VOR redirect cannot be a static rule; requires a resolution from 3a. |
| 3d | EPP server emits rebuild webhook on article publication; CI rebuilds and redeploys; scheduled fallback rebuild added | Medium — Silent webhook failure leaves new articles returning 404. Scheduled rebuild is the fallback. Endpoint must be authenticated. |
| 4a | Astro project initialised with React integration; content collections with digest caching; CI configured to persist .astro/data-store.json |
High — CI cache must be explicitly persisted or every build is a full rebuild. React 19 + |
| 4b | Astro page files and BaseLayout.astro written; SiteConfig passed as prop (no i18n provider); Noto fonts via @fontsource |
Medium — GTM and Cookiebot script placement differs from Next.js |
| 4c | Catch-all replaced by per-tab .astro files; tab navigation becomes plain <a href> links |
Low — Fulltext URL duplication resolved by a one-line Caddy redirect. Phase 1 tab tests verify correctness. |
| 4d | client:* directives added to interactive components: authors toggle, clipboard, jump-to-menu, modal |
High — Missing a directive silently breaks interactivity with no error. Only detectable through E2E interaction tests. Phase 1 interaction tests must be complete first. |
| 4e | Logo <img> tags replaced with astro:assets <Image> for build-time WebP generation |
Low — SVGs pass through unchanged. Check for externally-referenced SVGs before enabling. |
| 4f | Docker visual regression suite re-run against Astro output; Phase 1 E2E suite run against Caddy-served Astro build | Low — Confirmation step. Main failure mode is a missed |
| 5a | Fastly Object Storage bucket provisioned; Fastly service backend switched to Object Storage with directory-index VCL, 404 handling, and per-article surrogate keys | Low — Newer product; confirm it is available on the account plan. Review the Fastly static-site tutorial for current bucket and backend syntax before starting. |
| 5b | CI/CD replaces Docker build and kubectl apply with aws s3 sync --endpoint-url to Fastly Object Storage (separate passes for HTML and hashed assets) and Fastly surrogate-key purge |
Medium — The |
| 5c | Fastly Edge Dictionary populated by CI with msid→type mappings; VCL recv issues 301 if URL prefix does not match article type — resolves the VOR redirect deferred in step 3a |
Medium — Dictionary must be updated after the Object Storage upload completes, not before. A race between the dictionary update and the upload would cause a correctly-redirected URL to 404 transiently. |
| 5d | k8s Deployment, Service, and IngressRoute deleted; Caddy Dockerfile retired; DNS TTL lowered before cutover; Fastly backend switch used as the cutover mechanism | Low — If Fastly already fronts the cluster, cutover is a single backend change with instant rollback. Confirm Fastly's position in the stack before planning the sequence. |