05Release process
Preview, then smoke checks, then live. The same steps every time, written down as they happen.
- Merge. The PR is green in CI and merged with a normal merge commit.
- Build from a clean copy of main. Run the build scripts and confirm a rebuild changes nothing.
- Record the rollback target. Write down the live version ID before touching anything.
- Deploy a preview channel. Hosting only, with an expiry.
- Run the smoke checks on the preview URL. Key pages load, the error page returns 404, redirects return 301, security headers are present, sitemaps and robots are right, no JavaScript errors, forms work, layout shift stays low, and the copy rules hold.
- Get the yes. Live needs an explicit approval for this release.
- Clone the preview to live, then run the same checks on the real domain.
- Log it. Version, time, commit, check results and rollback command go in the ledger.
Stop-on-failure rule: if any check fails, stop at preview. Don't promote, don't patch live by hand. Write down what failed, fix it on a branch, and start the release again from step one.
08Starter template for a new project
Copy this shape for every new project so the hosting, tests and release steps work on day one.
Repo layout
my-project/
├── public/ # what gets hosted (or the build output folder)
│ ├── index.html
│ ├── 404.html
│ ├── offline.html
│ ├── manifest.webmanifest
│ └── sw.js
├── src/ # source, if there's a build step
├── functions/ # only if the project needs server code
├── scripts/ # build and smoke-check scripts
├── tests/ # node --test files
├── .github/workflows/ci.yml
├── firebase.json
├── .firebaserc # project alias: <project-id>
├── .gitignore # .env, node_modules, build output, any key files
├── .env.example # setting names, never values
├── package.json
└── README.md # run, test, preview, release, roll back
firebase.json
{
"hosting": {
"public": "public",
"cleanUrls": true,
"trailingSlash": false,
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"headers": [
{ "source": "**", "headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(self)" },
{ "key": "Content-Security-Policy", "value": "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" }
] },
{ "source": "/sw.js", "headers": [ { "key": "Cache-Control", "value": "no-cache" } ] },
{ "source": "/assets/**", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] }
]
}
}
Only use the long immutable cache on fingerprinted asset names (for example app.3f9c.js). Widen the CSP one source at a time as the project needs it.
CI workflow (.github/workflows/ci.yml)
name: CI
on: [pull_request, push]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm test
- run: npm run build
secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Pushing this file needs the workflow scope (see section 03). Take action versions from each action's README.
Preview-first release, start to finish
npm ci && npm test && npm run build
# rollback target first
firebase hosting:channel:list --site <site-id> --project <project-id> --account <your-account>
# preview (hosting only, expires on its own)
firebase hosting:channel:deploy release-<date> --only hosting \
--project <project-id> --expires 7d --account <your-account>
# smoke checks against the preview URL the command printed
node scripts/smoke.mjs https://<preview-url>
# stop here if anything failed. With an explicit yes:
firebase hosting:clone <site-id>:release-<date> <site-id>:live --account <your-account>
node scripts/smoke.mjs https://<your-domain>.com
# if live misbehaves
firebase hosting:clone <site-id>@<previous-version-id> <site-id>:live --account <your-account>