From b1fae6acad0b5e0df99940fb3186f7e87411941c Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 23 Jul 2026 00:15:59 -0700 Subject: [PATCH 1/4] release: add pinned Netlify hotfix gate --- IMPLEMENTATION_PLAN.md | 2 +- OFFICER_START_HERE.md | 2 +- OPERATIONS_RUNBOOK.md | 4 + README.md | 4 +- SECURITY.md | 2 +- SYSTEM_DESIGN.md | 12 +- config/netlify-production-release.json | 16 ++ docs/officers/ACCESS_CONTINUITY.md | 2 +- docs/officers/GLOSSARY.md | 3 + docs/officers/PUBLISH_AND_CHECK.md | 60 ++++- docs/officers/README.md | 2 +- docs/officers/REQUEST_A_CHANGE.md | 2 +- docs/officers/SYSTEM_MAPS.md | 9 +- netlify.toml | 24 +- scripts/netlify-ignore-build.js | 23 +- scripts/netlify-release-build.js | 308 +++++++++++++++++++++++ scripts/netlify-release-policy.js | 212 ++++++++++++++++ tests/release-workflow.test.js | 324 ++++++++++++++++++++++++- 18 files changed, 981 insertions(+), 30 deletions(-) create mode 100644 config/netlify-production-release.json create mode 100644 scripts/netlify-release-build.js create mode 100644 scripts/netlify-release-policy.js diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 6dd3200..80fbac7 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -28,7 +28,7 @@ MPRC can open a race or merchandise item for sale only when the platform can: 3. Schema changes are additive first. Backfills are idempotent, dry-runnable, and report counts/anomalies. 4. Backend/rules/indexes deploy before dependent clients. Legacy reads remain until migration evidence is complete. 5. No issue requires production secrets for development or CI. -6. Live-mode enabling is a separate protected operation. #135 supplies a manual, exact-commit, backend-first source gate and pauses Git-triggered Netlify production builds. Protected environment/OIDC configuration, staged/live proof, fail-closed lint, required checks, and a protected live-Netlify path remain open under #105/#133/#136/WEB-001. +6. Live-mode enabling is a separate protected operation. #135 supplies a manual, exact-commit, backend-first source gate and pauses ordinary Git-triggered Netlify production builds. WEB-UX-001A [#457](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/457) is a temporary web-only exception that requires one exact `main` parent and independently verifies one pinned source commit/tree; it must be disabled after verification and does not authorize Firebase, commerce, or a reusable release path. Protected environment/OIDC configuration, staged/live proof, fail-closed lint, required checks, and a protected live-Netlify path remain open under #105/#133/#136/WEB-001. 7. A provider Console setting is not complete until its non-secret configuration and verification evidence are recorded privately. 8. Security controls fail closed in hosted environments and remain developer-friendly only in explicit local/CI environments. 9. Do not trade payment integrity for UI responsiveness. Confirmation may say “processing”; it must not guess “paid.” diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 160a55d..3e5bb09 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -46,6 +46,6 @@ Use the club's approved password manager for access. Share only a public link or - **Website live:** Did Netlify identify that commit, and was the exact change then seen on [runmprc.com](https://runmprc.com)? - **Outside service verified:** If Stripe, Netlify, DNS, Google, or email changed, was that service checked separately? -As of **2026-07-13**, a merge runs checks but does not start the GitHub release. The protected release is **NOT AVAILABLE YET** until its short-lived cloud identity and named environment approvers are configured under issue #133. Git-triggered Netlify production builds are paused, and a protected way to publish the live Netlify site is also **NOT AVAILABLE YET**. GitHub Pages still reports `runmprc.com` as its custom domain even though Netlify serves that name; source removal is not provider proof. A green test or workflow does **not** by itself prove that GitHub Pages, `runmprc.com`, Firebase, or that domain setting changed. +As of **2026-07-22**, a merge runs checks but does not start the GitHub release. The protected release is **NOT AVAILABLE YET** until its short-lived cloud identity and named environment approvers are configured under issue #133. Ordinary Git-triggered Netlify production builds are paused. Issue #457 has one temporary platform-maintainer-only exception for its already reviewed page-header artifact; every other source, merge, and service remains blocked, and the exception must be disabled and its release source retired after live verification. This does not deploy Firebase or make commerce safe. GitHub Pages still reports `runmprc.com` as its custom domain even though Netlify serves that name; source removal is not provider proof. A green test or workflow does **not** by itself prove that GitHub Pages, `runmprc.com`, Firebase, or that domain setting changed. For the concise handbook, see [OFFICER_HANDBOOK.md](./OFFICER_HANDBOOK.md). The expanded task index is [docs/officers/README.md](./docs/officers/README.md). diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index b9e61e0..b2bb880 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -642,6 +642,10 @@ For expand-and-contract changes: The GitHub release workflow is now manual and exact-current-commit. After protected approval it rechecks `main`, the newest exact CI run, and the retained credential-free artifact before cloud authentication. It fails on missing protected configuration, uses a lockfile Firebase CLI, verifies reviewed Rules and two named profile Functions before publishing the Pages branch, and never gives cloud authority to website preparation or publication. Future Pages artifacts omit the `runmprc.com` CNAME, but the existing Pages provider setting still claims that Netlify-served name until #136/WEB-001 publish and read it back. The source gate is not usable until #133 configures protected environments/OIDC. It does not publish the live Netlify host. Treat the full pipeline as incomplete until #105 closes. +WEB-UX-001A [#457](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/457) uses a separate temporary **web-only** Netlify release. Its active manifest names the expected first parent of one `main` merge, release-specific source ref, exact reviewed source commit/tree, exact artifact file count/digest, and known rollback deploy. The release-control Deploy Preview and production both build that pinned source. Netlify continues production only for the exact-parent merge, fetches the public ref into a fresh temporary Git repository with provider/global Git configuration disabled, verifies both source hashes, installs the pinned lockfile with lifecycle scripts disabled, builds without the data-dependent sitemap prebuild or any React/provider variables, scans the output for credential markers, verifies the exact artifact digest, and publishes a public provenance marker. A wrong/missing context, branch, commit, parent, ref, tree, artifact, canonical manifest, build-hook marker, or inactive manifest stops without replacing the live deploy. This path changes no Firebase, Rules, Functions, provider configuration, or production data and does not complete WEB-001. + +For #457, prepare the manifest-disable pull request and exact previous-source Git rollback projection before merging the release. The current Git/Netlify identity cannot select Netlify's atomic restore control; a Netlify team owner remains the preferred fast rollback, while the pre-reviewed Git projection pinned to `e86a0f702cff6495f50630c5de3337290db8b8cb` is the available slower fallback. Freeze unrelated `main` merges until the Netlify deploy and public smoke checks finish, because a later merge invalidates the exact-parent gate and may supersede the provider build. Verify the pinned Deploy Preview marker and artifact digest before merge; after merge verify `/.well-known/run-mprc-release.json`, the Netlify deploy record, `/events`, `/shop`, and the signed-out `/account` route on phone and desktop sizes. If the release fails before publication, leave the prior deploy live and diagnose offline. If it publishes but verification fails, ask a Netlify team owner to restore deploy `6a54a3c93db9d300082e1f5f`; if that owner is unavailable, update the prepared rollback manifest's expected parent to the release merge, reverify its pinned previous-source preview, and merge it. A source-only disable is not rollback. After success, merge the disable pull request, confirm its production attempt is skipped, delete `codex/netlify-source-457-header`, and verify that ref is absent so a historical rebuild cannot fetch the hotfix source. The platform owner approves and performs this exception; officers do not run commands or handle credentials. + ### Production approvals Production commerce changes require: diff --git a/README.md b/README.md index 223b225..32bd85b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Historical developer/content/LLM guides remain under [`docs/`](./docs/README.md) - `.github/workflows/`: frontend, Functions, Rules CI and deployment automation. - `public/404.html`, `public/index.html`, and `public/spa-navigation.js`: current tested GitHub Pages callback handoff. It preserves safe same-origin path, query, and fragment state. -**Deployment reality checked 2026-07-13:** merges run CI but do not start the manual release workflow. The protected gate accepts one exact current merged commit, rechecks its newest CI run after approval, uses one fixed Firebase target set, fails when protected authority/configuration is missing, verifies Firebase before publishing GitHub Pages, and gives no server credential to website preparation or publication. Git-triggered Netlify production builds are paused. The source stops adding a Pages `CNAME`, but GitHub Pages currently still claims `runmprc.com` and its default URL redirects there; only a controlled #136/WEB-001 publication and provider readback can clear that conflict. Protected publication to the live Netlify-served `runmprc.com` is not configured yet. Treat GitHub Pages, Netlify, `runmprc.com`, Firebase, and outside providers as separate states. +**Deployment reality checked 2026-07-22:** merges run CI but do not start the manual release workflow. The protected gate accepts one exact current merged commit, rechecks its newest CI run after approval, uses one fixed Firebase target set, fails when protected authority/configuration is missing, verifies Firebase before publishing GitHub Pages, and gives no server credential to website preparation or publication. Ordinary Git-triggered Netlify production builds are paused. Issue #457 has one temporary exact-parent, exact-artifact web-only exception; it does not deploy Firebase and must be disabled and have its release source retired after verification. The source stops adding a Pages `CNAME`, but GitHub Pages currently still claims `runmprc.com` and its default URL redirects there; only a controlled #136/WEB-001 publication and provider readback can clear that conflict. Reusable protected publication to the live Netlify-served `runmprc.com` is not configured yet. Treat GitHub Pages, Netlify, `runmprc.com`, Firebase, and outside providers as separate states. ## Local setup status @@ -95,4 +95,4 @@ Business owners—not coding agents—must approve legal text, waiver/insurance ## Deployment warning -Pushing to `main` runs CI only. It does not start `.github/workflows/deploy.yml`. The manual workflow is fixed to an exact merged commit and reviewed target set, but it is **NOT AVAILABLE YET** until #133 configures protected environments and short-lived cloud authority. Git-triggered Netlify production builds are paused; a protected live-Netlify publication path is also **NOT AVAILABLE YET**. Production changes still require protected approval, a compatible backend-first rollout, separate live-host proof, a named observer, and the runbook's post-deploy/reconciliation steps. +Pushing to `main` runs CI and does not start `.github/workflows/deploy.yml`. The manual workflow is fixed to an exact merged commit and reviewed target set, but it is **NOT AVAILABLE YET** until #133 configures protected environments and short-lived cloud authority. Ordinary Git-triggered Netlify production builds are paused; #457 temporarily permits only its exact-parent, exact-artifact frontend release, and a reusable protected live-Netlify publication path is still **NOT AVAILABLE YET**. Production changes still require protected approval, a compatible backend-first rollout, separate live-host proof, a named observer, and the runbook's post-deploy/reconciliation steps. diff --git a/SECURITY.md b/SECURITY.md index 9b4961a..7a971a9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -83,7 +83,7 @@ The findings below describe the repository at the start of the 2026-07-12 assess | RISK-033 | CSV export mitigates formula injection, but roster export has no explicit re-auth, download audit, row limit/streaming limit, or data-minimization profiles. | Add scoped exports, reason/re-auth, audit, minimum columns, safe filename IDs, and large-export controls. | | RISK-034 | Webhook error response includes the Stripe library's signature error detail. | Return generic client errors; keep sanitized structured diagnostics server-side. | | RISK-035 | The deterministic frontend Jest suite and standalone SPA callback suite run as separate blocking hosted CI steps. CI-001B4/#186 merged a non-mutating frontend lint gate as `bec7d5e365eacb418563a172029f241f660d9768`; exact PR and post-merge runs passed. CI-001B4A [#227](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/227) removes one reviewed `arrow-body-style` error, CI-001B4B [#239](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/239) removes one stale `AdminMembers` unknown-rule suppression record, and PAY-004C1 [#359](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/359) retires one `no-alert` warning plus two label-association errors with the unsafe reusable-link controls. Reviewed functional changes in [PR #391](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/391) and [PR #392](https://github.com/Run-MPRC/Run-MPRC.github.io/pull/392) add one and two TypeScript files respectively while retiring two TSX errors each. CI-001B4C [#449](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/449) replaces the four remaining stale `react-hooks/exhaustive-deps` directives with ordinary same-line comments, without changing executable code or the gate. The current baseline scans 111 files and records 113 configured errors and 6 warnings after the lint process disables the repository's severity-masking `eslint-plugin-only-warn` hook. Branch protection, remaining lint-debt cleanup, and broader domain/integration coverage remain incomplete. | Continue reducing the reviewed finding baseline in focused changes, prove required branch checks, and add domain/integration coverage. Never regenerate the baseline merely to make CI green. | -| RISK-036 | #135 adds a manual exact-commit source gate, fixed profile-recovery targets, backend-first order, missing-config failure, and Git-triggered Netlify production containment. Protected environments/OIDC, isolated staging, live-Netlify publication, and rollback evidence are still absent. | Complete #133 and #136, provision isolated staging, protect the Netlify release path under WEB-001, and rehearse rollback/safe roll-forward before production. | +| RISK-036 | #135 adds a manual exact-commit source gate, fixed profile-recovery targets, backend-first order, missing-config failure, and ordinary Git-triggered Netlify production containment. WEB-UX-001A [#457](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/457) adds a temporary web-only exception that accepts one exact `main` parent, fetches one release-specific public ref in a clean credential-free Git repository, verifies its pinned commit/tree and exact artifact count/digest, passes no React/provider or server credential inputs, scans the artifact, rejects build-hook metadata, and emits public provenance; every uncertain state stops. Its authorization is exact-merge scoped, so deletion of the release-specific source ref is required after verification to make later historical retries fail. Protected environments/OIDC, isolated staging, a reusable live-Netlify path, and dated publish/rollback evidence are still absent. | Use #457 only for its reviewed header hotfix, verify the exact pinned preview, public marker, and live pages, then disable its manifest and delete its release source ref. Keep the exact previous-source rollback ref and a reviewed Git rollback projection because the current identity cannot operate Netlify's atomic restore. Complete #133 and #136, provision isolated staging, protect the reusable Netlify release path under WEB-001, and rehearse provider rollback before broader production work. | | RISK-037 | Account/registration deletion, export, retention, backup, and restore procedures are incomplete. | Approve retention matrix, automate minimization, support access/deletion requests, and test backup restoration. | | RISK-038 | Source-controlled secret scan is ad hoc; no continuous secret scanner, dependency update bot, SBOM, provenance, or branch protection is documented. | Add secret/dependency/code scanning, reviewed lockfile updates, protected environments/branches, and artifact provenance appropriate to project scale. | | RISK-039 | Some authenticated accounts can lack `members/{uid}` after the Firebase cutover; the account screen hid the read failure and exposed an update that could only fail. Manual database/account repair could corrupt roles or private data. | Use an authenticated create-once server bootstrap, keep browser creation denied, fail the UI closed, and prove backend-first deployment with synthetic accounts. | diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 62d0beb..f3a7cc6 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -86,12 +86,12 @@ Text alternative: browsers can use the React app, Auth, Firestore, Functions, St | Operational data | Cloud Firestore | `src/services`, `firestore.rules`, `firestore.indexes.json` | Appropriate for current scale; counters and state transitions require transactional design. | | Server API | First-generation Firebase callable/HTTP/trigger functions | `functions/` | Prototype covers most workflows; validation, idempotency, and isolation are incomplete. | | Payments | Stripe Checkout Sessions, Payment Links, refunds, signed webhook | `functions/createCheckoutSession.js`, `createMerchCheckout.js`, `stripeWebhook.js` | Not ready for live payments until P0 issues are complete. | -| Hosting and release | Netlify currently answers `runmprc.com`. GitHub Pages still reports the same custom domain, so its default URL redirects to the Netlify-served name instead of providing an independent copy. #135 source stops automatic releases, pauses Git-triggered Netlify production builds, and removes the Pages CNAME from future protected artifacts. | `netlify.toml`, `.github/workflows/deploy.yml`, `public/404.html` | Split and conflicting as verified 2026-07-13. The existing Pages provider setting is not cleared until a controlled #136/WEB-001 release and readback prove it. #133 authority, #136 release evidence, Netlify provider triggers, DNS, headers, and rollback remain open. | +| Hosting and release | Netlify currently answers `runmprc.com`. GitHub Pages still reports the same custom domain, so its default URL redirects to the Netlify-served name instead of providing an independent copy. #135 source stops ordinary automatic releases, pauses Git-triggered Netlify production builds, and removes the Pages CNAME from future protected artifacts. WEB-UX-001A [#457](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/457) adds one temporary web-only exception: an active manifest names one expected `main` parent, one release-specific source ref, one independently reviewed source commit/tree, and the exact artifact count/digest; both its preview and production build that same artifact and refuse every mismatch. | `netlify.toml`, `config/netlify-production-release.json`, `scripts/netlify-release-policy.js`, `.github/workflows/deploy.yml`, `public/404.html` | Split and conflicting as verified 2026-07-13. The #457 exception can publish only its pinned frontend artifact and must be disabled and have its release-specific source ref deleted after dated live verification. It does not deploy Firebase or complete the general protected host, ownership, DNS, header, or rollback work under #133/#136/WEB-001. | | Email | Firestore `mail` outbox designed for the Firebase Trigger Email extension | `functions/sendConfirmationEmail.js` | Extension/provider deployment is unverified; outbox creation is not transactionally idempotent and HTML needs escaping. | | Observability | Optional Sentry; Firebase Analytics configuration remains but its runtime is not initialized by #139 source | `src/services/monitoring`, `src/services/analytics` | #134 source bounds Sentry payloads. #139 source removes every application runtime Firebase Analytics import, initialization, and emission while preserving no-op call compatibility. Website publication, provider collection/cookies and historical data, consent, retention, access, deletion, and vendor configuration remain unverified under #110/#111. | | Third-party fitness | Strava OAuth tokens and statistics | `functions/strava.js`, `src/services/strava` | Functional prototype. The #100 source Rules deny browser token access, but Firebase deployment is unproven and transactional refresh, scopes/revocation, IAM/encryption decision, and audit remain OAUTH-001. | -The former workflow automatically published Pages before attempting Firebase and could finish green after skipping Firebase. #135 replaces that source path with a manual exact-current-commit request, exact latest CI checks, one fixed backend plan, protected short-lived identity wiring, provider readback, and Firebase-before-Pages publication. Missing authority or failed/partial verification is red. Git-triggered Netlify production builds stop, while build hooks and protected Netlify publication remain unverified. No source test clears the current Pages custom-domain claim, configures #133, deploys #136, or proves `runmprc.com`; those remain separate provider states. The App Engine synchronization script is another surface that must be documented as active or retired. +The former workflow automatically published Pages before attempting Firebase and could finish green after skipping Firebase. #135 replaces that source path with a manual exact-current-commit request, exact latest CI checks, one fixed backend plan, protected short-lived identity wiring, provider readback, and Firebase-before-Pages publication. Missing authority or failed/partial verification is red. Ordinary Git-triggered Netlify production builds stop. The temporary #457 web-only path continues only for a production `main` merge with the exact first parent in the active manifest, then independently fetches and verifies the pinned source commit and tree before building; a later merge, disabled manifest, wrong branch/context/ref/tree/artifact, build-hook metadata, or any uncertainty stops. The pinned build receives no React/provider or server credential environment, and its release PR builds the same exact artifact as production. The authorization is exact-merge scoped rather than durable one-time state: duplicate attempts of the armed merge can build the same artifact until the release-specific source ref is deleted; deleting that ref after verification makes later retries fail at source fetch. Build hooks and a reusable protected Netlify publication path remain unverified. No source test clears the current Pages custom-domain claim, configures #133, deploys #136, or proves `runmprc.com`; those remain separate provider states. The App Engine synchronization script is another surface that must be documented as active or retired. ```mermaid flowchart TD @@ -106,10 +106,14 @@ flowchart TD Backend --> Verify{"Exact Rules and Function revisions verified?"} Verify -- "No" --> Stop Verify -- "Yes" --> Pages["Publish Pages branch without a CNAME"] - Merge -. "Git production build paused" .-> Netlify["Netlify / runmprc.com\nprotected publication not available"] + Merge -. "Ordinary Git production build paused" .-> Netlify["Netlify / runmprc.com\nreusable protected publication not available"] + Merge -. "Only active exact-parent manifest" .-> WebGate{"Pinned source commit and tree match?"} + WebGate -- "No" --> Stop + WebGate -- "Yes" --> WebOnly["Publish pinned web-only artifact"] + WebOnly --> Netlify ``` -Text alternative: a merge only runs CI; a separate request prepares a public artifact, waits for protected approval, rechecks current source and authority, verifies Firebase, and only then publishes a Pages branch that no longer claims the Netlify domain. +Text alternative: ordinary merges run CI and do not publish Netlify; the temporary web-only gate accepts only the exact manifest merge and pinned source tree, while the separate protected workflow still requires approval and verified Firebase before publishing its Pages copy. ### GitHub Pages callback handoff diff --git a/config/netlify-production-release.json b/config/netlify-production-release.json new file mode 100644 index 0000000..af56da0 --- /dev/null +++ b/config/netlify-production-release.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "active": true, + "releaseId": "WEB-UX-001A-2026-07-22", + "issueNumber": 457, + "previewBranch": "codex/issue-457-netlify-release", + "expectedProductionParent": "491cc22d80de1faae1960bd63c80ce62e648397b", + "sourceRepository": "https://github.com/Run-MPRC/Run-MPRC.github.io.git", + "sourceRef": "refs/heads/codex/netlify-source-457-header", + "sourceCommit": "ed1b0833f25822cee80c99ded8753722b5608a3f", + "sourceTree": "878c6628d961f4484cb49208aef53f1e9f2e3b47", + "previousSourceCommit": "e86a0f702cff6495f50630c5de3337290db8b8cb", + "rollbackDeployId": "6a54a3c93db9d300082e1f5f", + "expectedSiteFileCount": 60, + "expectedSiteFilesSha256": "7570955c2a00926e5813aef135f1799172cfd046072ac89fb4e492bed0797092" +} diff --git a/docs/officers/ACCESS_CONTINUITY.md b/docs/officers/ACCESS_CONTINUITY.md index 0594a56..f8cb465 100644 --- a/docs/officers/ACCESS_CONTINUITY.md +++ b/docs/officers/ACCESS_CONTINUITY.md @@ -84,7 +84,7 @@ For each system, record only: 10. Confirm a normal merge does not start the GitHub release. 11. Confirm missing release authority becomes a red failure before backend installation, cloud authentication, deployment, or website publication. A public website artifact may be prepared without cloud authority. 12. Confirm Firebase verification must finish before the GitHub Pages publication job can start. -13. Confirm Netlify Git-triggered production builds remain paused until a protected live-host path exists. +13. Confirm ordinary Netlify Git-triggered production builds remain paused. While #457 is active, confirm only its exact-parent, exact-artifact merge can publish and that the maintainer will disable it and retire its source after verification. 14. Confirm reviewers reject release requests older than 24 hours and request the current `main` commit again. 15. **NOT AVAILABLE YET:** complete the synthetic role-boundary drill below after the reviewed database, Function, and website revisions are safely available in protected staging. diff --git a/docs/officers/GLOSSARY.md b/docs/officers/GLOSSARY.md index 0bfdf8f..ed84a82 100644 --- a/docs/officers/GLOSSARY.md +++ b/docs/officers/GLOSSARY.md @@ -8,6 +8,9 @@ | Issue | A tracked request describing one problem or result. | | Branch | A separate working copy used to prepare one change safely. | | Commit | A named snapshot of source changes. It helps identify exactly which version was built. | +| Manifest | A small reviewed record naming exactly which source and built result one temporary release may use. | +| Source tree | The exact set and contents of files in one commit, identified by a Git hash. | +| Provenance marker | A public read-only file that identifies which reviewed source and built result a website deploy contains. | | Pull request | A review page showing a proposed change before it joins the main version. | | Review | A person or review agent checks the change for mistakes and risk. | | Merge | GitHub accepts an approved pull request into `main`. This does not always mean the change is live. | diff --git a/docs/officers/PUBLISH_AND_CHECK.md b/docs/officers/PUBLISH_AND_CHECK.md index e74cc52..aeb98c2 100644 --- a/docs/officers/PUBLISH_AND_CHECK.md +++ b/docs/officers/PUBLISH_AND_CHECK.md @@ -10,7 +10,7 @@ **Protected release status:** **NOT AVAILABLE YET.** Issue #135 provides the fail-closed source gate. Issue #133 must still configure protected `staging` and `production` environments, their named reviewers, and a short-lived cloud identity. Public browser build values must be named repository or organization variables because artifact preparation has no protected-environment access; #133/#136 must record and verify them separately. Do not add a long-lived Firebase key as a shortcut. -**Live Netlify publication status:** **NOT AVAILABLE YET.** Git-triggered production builds are paused by repository configuration. The Netlify owner, build hooks, manual trigger, exact-commit proof, and rollback path remain unverified provider work. GitHub Pages currently still claims the same custom domain; future source omits that claim, but #136/WEB-001 must publish and verify its removal. +**Live Netlify publication status:** a reusable protected release is **NOT AVAILABLE YET**. Ordinary Git-triggered production builds are paused by repository configuration. Issue #457 has one temporary, platform-maintainer-only web release pinned to one reviewed source commit, tree, and artifact digest; it cannot deploy Firebase or authorize commerce, and it must be disabled and have its release source retired after dated live verification. GitHub Pages currently still claims the same custom domain; future source omits that claim, but #136/WEB-001 must publish and verify its removal. ## The release gate @@ -56,7 +56,7 @@ As of **2026-07-13**, with the internal tooling note below checked from source o - `runmprc.com` is served by Netlify, not GitHub Pages. - GitHub Pages currently reports `runmprc.com` as its custom domain and redirects its normal address there. It is not an independently reachable copy today. - Future source stops writing that Pages domain claim. Only provider readback after #136/WEB-001 can prove it cleared. -- Git-triggered Netlify production builds are paused. Netlify build hooks are not controlled by that repository rule and remain unverified. +- Ordinary Git-triggered Netlify production builds are paused. The active #457 manifest permits only one exact-parent merge and one pinned source/artifact. Build hooks bypass Netlify's ignore check, so the production wrapper separately rejects build-hook metadata and every wrong commit or inactive manifest. A duplicate attempt of the armed merge can build the same artifact until the maintainer deletes its release-specific source ref. - Live race signup, merchandise payments, and refunds remain unavailable. - CONFIG-001B1 [#151](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/151) adds source enforcement for a server-only commerce pause. It is not in the fixed profile-recovery release plan, is not deployed, and has no approved officer control. A future reviewed plan must deploy the complete guarded Function set with the deploy ceiling and every runtime/resource flag off, then prove signed webhooks still work. Do not widen the current plan by hand. @@ -112,8 +112,58 @@ If a member or officer sees **Server configuration is unavailable**: 3. Wait for that commit's CI jobs. 4. Confirm all five named jobs are green again: Frontend, Functions, commerce command journal, test artifact scrubber, and Firestore Rules. 5. Mark the result **merged — not released**. -6. Do not expect GitHub Pages, Firebase, Netlify, or `runmprc.com` to change from the merge. -7. If Netlify unexpectedly publishes the merge, stop and treat it as a hosting incident. +6. Do not expect GitHub Pages, Firebase, Netlify, or `runmprc.com` to change from the merge unless it is the explicitly armed #457 web-only release. +7. For any other merge, if Netlify unexpectedly publishes, stop and treat it as a hosting incident. + +## Temporary #457 Netlify web release + +**Purpose:** publish only the already reviewed Events, Shop, and My Account header tree without publishing the unrelated application work now on `main`. + +**Approver:** Dave Liu as platform owner. This is not an officer-operated control. + +**Prerequisites:** issue #457 approval; green checks; a successful pinned Netlify Deploy Preview; exact source commit `ed1b0833f25822cee80c99ded8753722b5608a3f`; exact source tree `878c6628d961f4484cb49208aef53f1e9f2e3b47`; exact 60-file artifact digest `7570955c2a00926e5813aef135f1799172cfd046072ac89fb4e492bed0797092`; prior live deploy `6a54a3c93db9d300082e1f5f`; release source `codex/netlify-source-457-header`; rollback source `codex/netlify-source-457-rollback`; a prepared manifest-disable pull request; a reviewed exact Git rollback projection; and no other `main` merge until verification ends. The current identity cannot click Netlify's atomic restore, so a Netlify team owner is preferred for fast rollback and the exact Git projection is the available fallback. + +```mermaid +flowchart TD + Preview["Build pinned release preview"] --> PreviewCheck{"Source and artifact marker match?"} + PreviewCheck -- "No" --> Stop["Stop — keep prior deploy live"] + PreviewCheck -- "Yes" --> Merge["Merge the reviewed #457 release control"] + Merge --> Parent{"Exact main parent and production context?"} + Parent -- "No" --> Stop["Stop — keep prior deploy live"] + Parent -- "Yes" --> Source{"Pinned source and artifact match?"} + Source -- "No" --> Stop + Source -- "Yes" --> Build["Build only the pinned frontend artifact"] + Build --> Proof["Publish provenance marker and Netlify deploy"] + Proof --> Check{"Public phone and desktop checks pass?"} + Check -- "No" --> Rollback["Team owner restores prior deploy, or merge exact Git rollback"] + Check -- "Yes" --> Disable["Disable manifest and retire release source"] +``` + +In words: the release preview and production must contain the same pinned frontend artifact; only the exact #457 merge may publish it; then the maintainer checks the live site and retires the exception, or a Netlify team owner restores the prior deploy, with the prepared exact Git rollback as fallback. + +1. Confirm the release pull request targets `main` and contains the exact active manifest. +2. Confirm another reviewer approved the source, gate, tests, and officer wording. +3. Open the release Deploy Preview's `/.well-known/run-mprc-release.json`. Confirm its source commit, source tree, file count, and artifact digest exactly match the prerequisites. +4. Stay signed out and check the three public routes in that pinned preview. Do not register, buy, sign in, or submit a form. +5. Confirm the manifest-disable pull request and exact previous-source rollback projection are ready. +6. Confirm `main` is still the exact parent named in the manifest. +7. Merge with a merge commit. Do not squash or rebase this release. +8. Watch the Netlify production attempt. Stop if its trigger commit is not that merge. +9. Open the live `/.well-known/run-mprc-release.json`. Confirm its source commit, tree, count, and digest equal the preview and prerequisites. Do not copy private logs or settings. +10. Stay signed out. Check `/events`, `/shop`, and `/account` at phone and normal computer sizes. Do not register, buy, sign in, or submit a form. +11. Confirm the pages have an image, one readable page title, and no title hidden behind the navigation. +12. Record the deploy URL, trigger commit, source commit/tree, public marker, date, browser sizes, and result. +13. If all checks pass, merge the manifest-disable pull request, confirm Netlify skips it without replacing the verified deploy, delete `codex/netlify-source-457-header`, and verify that release source no longer exists. + +**Expected result:** Netlify serves the pinned #457 frontend tree, the public marker proves its source, the three page headers are readable, Firebase and production data are unchanged, and later ordinary merges remain paused. + +**Stop conditions:** stop if the pinned preview is missing; `main` advanced; any hash, digest, count, ref, context, branch, or marker differs; Netlify builds the accumulated `main` application; neither a Netlify team owner nor the reviewed Git rollback projection is available; another production attempt starts; a page asks for private data; or any public check fails. + +**Success proof:** keep the public deploy link and marker, exact hashes, check date, phone/desktop sizes, and a redacted public screenshot. Keep settings, logs, credentials, and member data out of public evidence. + +**Undo:** if nothing published, leave the prior deploy live. If the wrong result published, ask a Netlify team owner to restore deploy `6a54a3c93db9d300082e1f5f`. If that owner is unavailable, set the prepared rollback manifest's expected parent to the release merge, verify its preview still pins previous source `e86a0f702cff6495f50630c5de3337290db8b8cb`, and merge that exact rollback projection. Disabling the source manifest prevents builds of later `main` commits but does not roll back an already published deploy; deleting the release-specific source ref makes a later rebuild of the old release commit fail its fetch. + +**Escalation:** platform owner first; website/content owner second. Escalate to the security owner if private data or an unexpected application version appears. ## Before a protected release — NOT AVAILABLE YET @@ -150,7 +200,7 @@ Do not use this section until #133 records that both GitHub environments are pro ## Verify every affected surface 1. Record whether the GitHub Pages branch published and whether provider readback shows its old `runmprc.com` claim is gone. -2. Ask the Netlify owner which commit, if any, Netlify published. +2. Ask the Netlify owner which commit, if any, Netlify published. For #457, also read the public `/.well-known/run-mprc-release.json` marker and verify its source commit/tree. 3. Open [runmprc.com](https://runmprc.com) in a private window. 4. Visit the exact changed public page. 5. Check one phone-sized view. diff --git a/docs/officers/README.md b/docs/officers/README.md index 19f52d3..14892a6 100644 --- a/docs/officers/README.md +++ b/docs/officers/README.md @@ -63,7 +63,7 @@ In words: approve the merge, request one exact release, and approve its protecte Never shorten several of these states to “done.” -Independent officer publishing to the live Netlify host is **NOT AVAILABLE YET**. Use a platform maintainer until the Netlify connection and rollback path are documented and tested. +Independent officer publishing to the live Netlify host is **NOT AVAILABLE YET**. Issue #457 has one temporary platform-maintainer-only, exact-artifact web release; it is not a general officer control and must be disabled and have its release source retired after verification. Use a platform maintainer until the Netlify connection and rollback path are documented and tested. The protected GitHub release is also **NOT AVAILABLE YET** until #133 configures the environment approvers and short-lived cloud identity. Missing authority stops the release with a red failure before Firebase or website publication. diff --git a/docs/officers/REQUEST_A_CHANGE.md b/docs/officers/REQUEST_A_CHANGE.md index aa169bb..c17734a 100644 --- a/docs/officers/REQUEST_A_CHANGE.md +++ b/docs/officers/REQUEST_A_CHANGE.md @@ -34,7 +34,7 @@ Have one of these ready: If you open GitHub yourself, use the [canonical repository on `main`](https://github.com/Run-MPRC/Run-MPRC.github.io/tree/main), then select **Issues**. `main` is now the repository default. Do not use the legacy `dev` branch as the source for a new request. -The GitHub release is manual. A `main` merge runs checks but does not publish the GitHub Pages copy or deploy Firebase. Git-triggered Netlify production builds are paused by repository configuration, but Netlify build hooks/provider settings remain unverified. Stop and escalate any unexpected publication. +The GitHub release is manual. A `main` merge runs checks but does not publish the GitHub Pages copy or deploy Firebase. Ordinary Git-triggered Netlify production builds are paused by repository configuration. Issue #457 temporarily permits only its exact-parent, exact-artifact web release; Netlify provider settings remain separately unverified. Stop and escalate any other publication. ## If you cannot open an AI assistant diff --git a/docs/officers/SYSTEM_MAPS.md b/docs/officers/SYSTEM_MAPS.md index 42a1d6f..d28ce35 100644 --- a/docs/officers/SYSTEM_MAPS.md +++ b/docs/officers/SYSTEM_MAPS.md @@ -58,13 +58,16 @@ flowchart TD Gate -- "Yes" --> Rules["Deploy reviewed Firestore Rules"] Rules --> Functions["Deploy and verify named Functions"] Functions --> Pages["Pages branch without Netlify's domain claim"] - Main -. "Git-triggered production build paused" .-> Netlify - Netlify["Netlify — current live host; protected publication unavailable"] --> Live["runmprc.com"] + Main -. "Ordinary Git production build paused" .-> Netlify + Main -. "Temporary #457 exact parent" .-> WebGate{"Pinned source and artifact match?"} + WebGate -- "No" --> Stop + WebGate -- "Yes" --> Netlify + Netlify["Netlify — current live host; reusable protected publication unavailable"] --> Live["runmprc.com"] Pages -. "existing provider claim still conflicts until verified clear" .-> Live Dev["dev — legacy branch"] -. "do not use for new release work" .-> PR ``` -In words: merge, release request, and protected approval are separate; a missing or failed Firebase gate publishes nothing; the future Pages branch must stop claiming the Netlify domain, and both hosts still need separate proof. +In words: merge, release request, and protected approval are separate; a missing or failed Firebase gate publishes nothing; ordinary merges cannot publish Netlify, while temporary #457 can publish only its pinned frontend artifact; the future Pages branch must stop claiming the Netlify domain, and both hosts still need separate proof. ## Account and permission ownership diff --git a/netlify.toml b/netlify.toml index e39ab53..7eebf97 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,15 +1,33 @@ [build] command = "npm run build" publish = "build" - # Git-triggered production publication is paused until WEB-001 records the - # Netlify owners, protected trigger, exact commit, and rollback path. + # Preview builds use the checked-out preview tree. Production is fail-closed + # except for the exact merge authorized by a reviewed release manifest. ignore = "node ./scripts/netlify-ignore-build.js" [build.environment] NODE_VERSION = "20" - NPM_FLAGS = "--legacy-peer-deps" + NPM_FLAGS = "--legacy-peer-deps --ignore-scripts" DISABLE_ESLINT_PLUGIN = "true" +[context.production] + # This wrapper archives and verifies the pinned hotfix tree; it never builds + # the accumulated application tree from the triggering main merge. + command = "node ./scripts/netlify-release-build.js" + +[context.deploy-preview] + # The #457 release-control PR builds the same pinned artifact as production. + # Other previews retain the ordinary checked-out preview build. + command = "node ./scripts/netlify-release-build.js --preview" + +[[headers]] + for = "/.well-known/run-mprc-release.json" + + [headers.values] + Cache-Control = "no-store, max-age=0" + Content-Type = "application/json; charset=utf-8" + X-Content-Type-Options = "nosniff" + [[redirects]] from = "/*" to = "index.html" diff --git a/scripts/netlify-ignore-build.js b/scripts/netlify-ignore-build.js index 11c5893..d1c3d9c 100644 --- a/scripts/netlify-ignore-build.js +++ b/scripts/netlify-ignore-build.js @@ -1,13 +1,32 @@ 'use strict'; // Netlify's ignore command uses 0 to stop a build and 1 to continue it. -// Keep public, read-only Deploy Previews available, but do not let a main -// branch merge silently publish runmprc.com before the protected host gate. +// Keep public, read-only Deploy Previews available. Production continues only +// for the exact merge authorized by the reviewed release manifest. Retiring the +// pinned source ref after verification prevents later retries of that merge. +// Every missing, malformed, stale, or otherwise uncertain state stops the build. +const { + authorizeProductionRelease, +} = require('./netlify-release-policy'); + const { CONTEXT: context } = process.env; if (context === 'deploy-preview' || context === 'branch-deploy') { console.log('Non-production Netlify build may continue for public review.'); process.exitCode = 1; +} else if (context === 'production') { + const authorization = authorizeProductionRelease(); + if (authorization.ok) { + console.log( + `Authorized exact production release ${authorization.manifest.releaseId}.`, + ); + process.exitCode = 1; + } else { + console.log( + `Git-triggered Netlify production build is paused (${authorization.reason}).`, + ); + process.exitCode = 0; + } } else { console.log('Git-triggered Netlify production builds are paused.'); process.exitCode = 0; diff --git a/scripts/netlify-release-build.js b/scripts/netlify-release-build.js new file mode 100644 index 0000000..b246b71 --- /dev/null +++ b/scripts/netlify-release-build.js @@ -0,0 +1,308 @@ +'use strict'; + +const crypto = require('node:crypto'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + authorizeProductionRelease, + loadManifest, +} = require('./netlify-release-policy'); + +const ROOT = path.resolve(__dirname, '..'); +const OUTPUT = path.join(ROOT, 'build'); +const RELEASE_MARKER = path.join( + OUTPUT, + '.well-known', + 'run-mprc-release.json', +); +const SHA_PATTERN = /^[0-9a-f]{40}$/; +const CREDENTIAL_MARKERS = Object.freeze([ + /-----BEGIN [A-Z ]*PRIVATE KEY-----/u, + /\b(?:FIREBASE_SERVICE_ACCOUNT|GOOGLE_APPLICATION_CREDENTIALS|NETLIFY_AUTH_TOKEN|SENTRY_AUTH_TOKEN|STRIPE_SECRET_KEY)\b/u, + /\b(?:github_pat_|ghp_|sk_live_|sk_test_|whsec_)[A-Za-z0-9_-]{8,}/u, +]); + +function safeBaseEnvironment(home, sourceEnvironment = process.env) { + const environment = { + CI: 'true', + DISABLE_ESLINT_PLUGIN: 'true', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', + GIT_TERMINAL_PROMPT: '0', + HOME: home, + LANG: 'C.UTF-8', + NPM_CONFIG_AUDIT: 'false', + NPM_CONFIG_FUND: 'false', + NPM_CONFIG_IGNORE_SCRIPTS: 'true', + NPM_CONFIG_USERCONFIG: '/dev/null', + PATH: sourceEnvironment.PATH || '/usr/local/bin:/usr/bin:/bin', + }; + if (sourceEnvironment.TMPDIR) environment.TMPDIR = sourceEnvironment.TMPDIR; + return environment; +} + +function buildEnvironment(home, sourceEnvironment = process.env) { + return safeBaseEnvironment(home, sourceEnvironment); +} + +function run(command, args, options = {}) { + execFileSync(command, args, { + ...options, + stdio: 'inherit', + }); +} + +function fetchAndVerifySource(manifest, temporaryDirectory) { + const temporaryRef = `refs/netlify-release/${manifest.releaseId}`; + const repository = path.join(temporaryDirectory, 'source.git'); + const gitEnvironment = safeBaseEnvironment( + path.join(temporaryDirectory, 'git-home'), + ); + fs.mkdirSync(gitEnvironment.HOME, { recursive: true }); + run('git', ['init', '--bare', '--quiet', repository], { + cwd: temporaryDirectory, + env: gitEnvironment, + }); + + run( + 'git', + [ + `--git-dir=${repository}`, + '-c', + 'credential.helper=', + '-c', + 'core.askPass=', + 'fetch', + '--no-tags', + '--force', + '--depth=1', + manifest.sourceRepository, + `${manifest.sourceRef}:${temporaryRef}`, + ], + { cwd: temporaryDirectory, env: gitEnvironment }, + ); + + const fetchedCommit = execFileSync( + 'git', + [`--git-dir=${repository}`, 'rev-parse', temporaryRef], + { + cwd: temporaryDirectory, + encoding: 'utf8', + env: gitEnvironment, + stdio: ['ignore', 'pipe', 'inherit'], + }, + ).trim(); + const fetchedTree = execFileSync( + 'git', + [`--git-dir=${repository}`, 'show', '-s', '--format=%T', fetchedCommit], + { + cwd: temporaryDirectory, + encoding: 'utf8', + env: gitEnvironment, + stdio: ['ignore', 'pipe', 'inherit'], + }, + ).trim(); + if (fetchedCommit !== manifest.sourceCommit + || fetchedTree !== manifest.sourceTree) { + throw new Error('Pinned release source did not match its reviewed commit and tree.'); + } + + const archive = path.join(temporaryDirectory, 'source.tar'); + const source = path.join(temporaryDirectory, 'source'); + fs.mkdirSync(source); + run( + 'git', + [ + `--git-dir=${repository}`, + 'archive', + `--output=${archive}`, + fetchedCommit, + ], + { cwd: temporaryDirectory, env: gitEnvironment }, + ); + run('tar', ['-xf', archive, '-C', source], { env: gitEnvironment }); + return source; +} + +function installAndBuild(source, temporaryDirectory) { + const home = path.join(temporaryDirectory, 'build-home'); + fs.mkdirSync(home); + const environment = buildEnvironment(home); + + run( + 'npm', + [ + 'ci', + '--legacy-peer-deps', + '--ignore-scripts', + '--no-audit', + '--no-fund', + ], + { cwd: source, env: environment }, + ); + run( + path.join(source, 'node_modules', '.bin', 'react-scripts'), + ['build'], + { cwd: source, env: environment }, + ); + return path.join(source, 'build'); +} + +function walkFiles(directory, relative = '') { + const absolute = path.join(directory, relative); + return fs.readdirSync(absolute, { withFileTypes: true }) + .sort((left, right) => { + if (left.name < right.name) return -1; + if (left.name > right.name) return 1; + return 0; + }) + .flatMap((entry) => { + const child = path.join(relative, entry.name); + if (entry.isSymbolicLink()) { + throw new Error('Release artifact must not contain symbolic links.'); + } + if (entry.isDirectory()) return walkFiles(directory, child); + if (!entry.isFile()) { + throw new Error('Release artifact contains an unsupported file type.'); + } + return [child]; + }); +} + +function verifyAndDigestArtifact(directory) { + const files = walkFiles(directory); + if (!files.includes('index.html') + || !files.some((file) => /^static\/js\/main\.[0-9a-f]+\.js$/u.test(file))) { + throw new Error('Release artifact is incomplete.'); + } + + const digest = crypto.createHash('sha256'); + files.forEach((relative) => { + const contents = fs.readFileSync(path.join(directory, relative)); + const text = contents.toString('latin1'); + if (CREDENTIAL_MARKERS.some((pattern) => pattern.test(text))) { + throw new Error('Release artifact safety scan found credential material.'); + } + digest.update(relative); + digest.update('\0'); + digest.update(contents); + digest.update('\0'); + }); + return Object.freeze({ + fileCount: files.length, + sha256: digest.digest('hex'), + }); +} + +function publishArtifact(sourceBuild, manifest, controlCommit) { + const artifact = verifyAndDigestArtifact(sourceBuild); + if (artifact.fileCount !== manifest.expectedSiteFileCount + || artifact.sha256 !== manifest.expectedSiteFilesSha256) { + throw new Error( + 'Release artifact did not match its reviewed file count and digest ' + + `(${artifact.fileCount}, ${artifact.sha256}).`, + ); + } + fs.rmSync(OUTPUT, { force: true, recursive: true }); + fs.cpSync(sourceBuild, OUTPUT, { recursive: true }); + fs.mkdirSync(path.dirname(RELEASE_MARKER), { recursive: true }); + fs.writeFileSync( + RELEASE_MARKER, + `${JSON.stringify({ + schemaVersion: 1, + releaseId: manifest.releaseId, + issueNumber: manifest.issueNumber, + controlCommit, + sourceCommit: manifest.sourceCommit, + sourceTree: manifest.sourceTree, + previousSourceCommit: manifest.previousSourceCommit, + rollbackDeployId: manifest.rollbackDeployId, + siteFileCount: artifact.fileCount, + siteFilesSha256: artifact.sha256, + }, null, 2)}\n`, + { encoding: 'utf8', mode: 0o644 }, + ); + return artifact; +} + +function main() { + const local = process.argv.length === 3 && process.argv[2] === '--local'; + const preview = process.argv.length === 3 && process.argv[2] === '--preview'; + if ((process.argv.length > 2 && !local && !preview) + || (local && process.env.NETLIFY === 'true')) { + throw new Error('Unsupported release-build invocation.'); + } + + let authorization; + if (preview) { + if (process.env.NETLIFY !== 'true' + || process.env.CONTEXT !== 'deploy-preview') { + throw new Error('Netlify pinned preview refused.'); + } + authorization = loadManifest(); + if (!authorization.ok) { + throw new Error(`Netlify pinned preview refused: ${authorization.reason}.`); + } + if (!authorization.manifest.active + || process.env.HEAD !== authorization.manifest.previewBranch) { + run('npm', ['run', 'build'], { cwd: ROOT }); + return; + } + if (!SHA_PATTERN.test(process.env.COMMIT_REF)) { + throw new Error('Netlify pinned preview commit is unavailable.'); + } + } else { + authorization = local + ? loadManifest() + : authorizeProductionRelease(); + } + if (!authorization.ok) { + throw new Error(`Netlify production release refused: ${authorization.reason}.`); + } + const { manifest } = authorization; + if (!manifest.active) { + throw new Error('Netlify production release is inactive.'); + } + + const controlCommit = local + ? 'LOCAL_VERIFICATION_ONLY' + : (preview ? process.env.COMMIT_REF : authorization.commit.sha); + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'run-mprc-netlify-release-'), + ); + try { + const source = fetchAndVerifySource(manifest, temporaryDirectory); + const sourceBuild = installAndBuild(source, temporaryDirectory); + const artifact = publishArtifact( + sourceBuild, + manifest, + controlCommit, + ); + console.log( + `Prepared ${manifest.releaseId} from ${manifest.sourceCommit} ` + + `(${artifact.fileCount} files, ${artifact.sha256}).`, + ); + } finally { + fs.rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : 'Release build failed.'); + process.exitCode = 1; + } +} + +module.exports = { + buildEnvironment, + fetchAndVerifySource, + publishArtifact, + safeBaseEnvironment, + verifyAndDigestArtifact, +}; diff --git a/scripts/netlify-release-policy.js b/scripts/netlify-release-policy.js new file mode 100644 index 0000000..f4bf2b8 --- /dev/null +++ b/scripts/netlify-release-policy.js @@ -0,0 +1,212 @@ +'use strict'; + +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const MANIFEST_PATH = path.join( + ROOT, + 'config', + 'netlify-production-release.json', +); +const SHA_PATTERN = /^[0-9a-f]{40}$/; +const RELEASE_ID_PATTERN = /^[A-Z0-9]+(?:-[A-Z0-9]+)*-\d{4}-\d{2}-\d{2}$/; +const SOURCE_REF_PATTERN = /^refs\/heads\/[A-Za-z0-9](?:[A-Za-z0-9._/-]{0,198}[A-Za-z0-9])?$/; +const EXPECTED_KEYS = Object.freeze([ + 'active', + 'expectedProductionParent', + 'expectedSiteFileCount', + 'expectedSiteFilesSha256', + 'issueNumber', + 'previewBranch', + 'previousSourceCommit', + 'releaseId', + 'rollbackDeployId', + 'schemaVersion', + 'sourceCommit', + 'sourceRef', + 'sourceRepository', + 'sourceTree', +].sort()); + +function isPlainRecord(value) { + return value !== null + && typeof value === 'object' + && Object.getPrototypeOf(value) === Object.prototype; +} + +function isExactKeySet(value) { + return JSON.stringify(Object.keys(value).sort()) === JSON.stringify(EXPECTED_KEYS); +} + +function validateManifest(value) { + if (!isPlainRecord(value) || !isExactKeySet(value)) { + return Object.freeze({ ok: false, reason: 'manifest_shape' }); + } + if (value.schemaVersion !== 1 || typeof value.active !== 'boolean') { + return Object.freeze({ ok: false, reason: 'manifest_version' }); + } + if (!RELEASE_ID_PATTERN.test(value.releaseId) + || !Number.isSafeInteger(value.issueNumber) + || value.issueNumber < 1 + || !/^codex\/[A-Za-z0-9](?:[A-Za-z0-9._/-]{0,198}[A-Za-z0-9])?$/u + .test(value.previewBranch)) { + return Object.freeze({ ok: false, reason: 'manifest_identity' }); + } + const invalidSha = [ + 'expectedProductionParent', + 'previousSourceCommit', + 'sourceCommit', + 'sourceTree', + ].some((key) => !SHA_PATTERN.test(value[key])); + if (invalidSha) { + return Object.freeze({ ok: false, reason: 'manifest_sha' }); + } + if (value.sourceRepository + !== 'https://github.com/Run-MPRC/Run-MPRC.github.io.git' + || !SOURCE_REF_PATTERN.test(value.sourceRef) + || !/^[0-9a-f]{24}$/.test(value.rollbackDeployId)) { + return Object.freeze({ ok: false, reason: 'manifest_source' }); + } + if (!Number.isSafeInteger(value.expectedSiteFileCount) + || value.expectedSiteFileCount < 1 + || value.expectedSiteFileCount > 512 + || !/^[0-9a-f]{64}$/.test(value.expectedSiteFilesSha256)) { + return Object.freeze({ ok: false, reason: 'manifest_artifact' }); + } + if (value.sourceCommit === value.previousSourceCommit + || value.sourceCommit === value.expectedProductionParent) { + return Object.freeze({ ok: false, reason: 'manifest_no_delta' }); + } + return Object.freeze({ ok: true, manifest: Object.freeze({ ...value }) }); +} + +function loadManifest(manifestPath = MANIFEST_PATH) { + let stat; + let contents; + try { + stat = fs.lstatSync(manifestPath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) { + return Object.freeze({ ok: false, reason: 'manifest_file' }); + } + contents = fs.readFileSync(manifestPath, 'utf8'); + } catch { + return Object.freeze({ ok: false, reason: 'manifest_unreadable' }); + } + + try { + const parsed = JSON.parse(contents); + const validated = validateManifest(parsed); + if (!validated.ok) return validated; + if (contents !== `${JSON.stringify(parsed, null, 2)}\n`) { + return Object.freeze({ ok: false, reason: 'manifest_canonical' }); + } + return validated; + } catch { + return Object.freeze({ ok: false, reason: 'manifest_json' }); + } +} + +function inspectCommit(commitRef, cwd = ROOT) { + if (!SHA_PATTERN.test(commitRef)) { + return Object.freeze({ ok: false, reason: 'commit_ref' }); + } + + try { + const head = execFileSync( + 'git', + ['rev-parse', 'HEAD'], + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim(); + const commit = execFileSync( + 'git', + ['cat-file', '-p', commitRef], + { + cwd, + encoding: 'utf8', + maxBuffer: 128 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }, + ); + const headers = commit.slice(0, commit.indexOf('\n\n')).split('\n'); + const treeLine = headers.find((line) => line.startsWith('tree ')); + const parents = headers + .filter((line) => line.startsWith('parent ')) + .map((line) => line.slice('parent '.length)); + + if (!SHA_PATTERN.test(head) + || !treeLine + || !SHA_PATTERN.test(treeLine.slice('tree '.length)) + || parents.some((parent) => !SHA_PATTERN.test(parent))) { + return Object.freeze({ ok: false, reason: 'commit_shape' }); + } + return Object.freeze({ + ok: true, + commit: Object.freeze({ + head, + parents: Object.freeze(parents), + sha: commitRef, + tree: treeLine.slice('tree '.length), + }), + }); + } catch { + return Object.freeze({ ok: false, reason: 'commit_unreadable' }); + } +} + +function evaluateProductionRelease({ env, manifest, commit }) { + if (env.NETLIFY !== 'true' + || env.CONTEXT !== 'production' + || env.BRANCH !== 'main' + || env.INCOMING_HOOK_TITLE !== undefined + || env.INCOMING_HOOK_URL !== undefined + || env.INCOMING_HOOK_BODY !== undefined) { + return Object.freeze({ ok: false, reason: 'deploy_context' }); + } + if (!SHA_PATTERN.test(env.COMMIT_REF) + || !manifest.active + || commit.sha !== env.COMMIT_REF + || commit.head !== env.COMMIT_REF) { + return Object.freeze({ ok: false, reason: 'deploy_identity' }); + } + if (!Array.isArray(commit.parents) + || commit.parents.length !== 2 + || commit.parents[0] !== manifest.expectedProductionParent + || commit.parents[1] === manifest.expectedProductionParent) { + return Object.freeze({ ok: false, reason: 'deploy_parent' }); + } + return Object.freeze({ ok: true, reason: 'release_authorized' }); +} + +function authorizeProductionRelease({ + env = process.env, + cwd = ROOT, + manifestPath = MANIFEST_PATH, +} = {}) { + const loaded = loadManifest(manifestPath); + if (!loaded.ok) return loaded; + const inspected = inspectCommit(env.COMMIT_REF, cwd); + if (!inspected.ok) return inspected; + const evaluated = evaluateProductionRelease({ + commit: inspected.commit, + env, + manifest: loaded.manifest, + }); + if (!evaluated.ok) return evaluated; + return Object.freeze({ + ok: true, + commit: inspected.commit, + manifest: loaded.manifest, + reason: evaluated.reason, + }); +} + +module.exports = { + MANIFEST_PATH, + authorizeProductionRelease, + evaluateProductionRelease, + inspectCommit, + loadManifest, + validateManifest, +}; diff --git a/tests/release-workflow.test.js b/tests/release-workflow.test.js index ba2ec24..2d71216 100644 --- a/tests/release-workflow.test.js +++ b/tests/release-workflow.test.js @@ -3,6 +3,7 @@ const assert = require('node:assert/strict'); const { spawnSync } = require('node:child_process'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const test = require('node:test'); @@ -10,10 +11,27 @@ const ROOT = path.resolve(__dirname, '..'); const WORKFLOW_PATH = path.join(ROOT, '.github/workflows/deploy.yml'); const NETLIFY_CONFIG_PATH = path.join(ROOT, 'netlify.toml'); const NETLIFY_GATE_PATH = path.join(ROOT, 'scripts/netlify-ignore-build.js'); +const NETLIFY_BUILD_PATH = path.join(ROOT, 'scripts/netlify-release-build.js'); +const NETLIFY_MANIFEST_PATH = path.join( + ROOT, + 'config', + 'netlify-production-release.json', +); const GITIGNORE_PATH = path.join(ROOT, '.gitignore'); const PUBLIC_CNAME_PATH = path.join(ROOT, 'public/CNAME'); +const { + authorizeProductionRelease, + evaluateProductionRelease, + loadManifest, + validateManifest, +} = require('../scripts/netlify-release-policy'); +const { + buildEnvironment, +} = require('../scripts/netlify-release-build'); + const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8'); const netlifyConfig = fs.readFileSync(NETLIFY_CONFIG_PATH, 'utf8'); +const netlifyBuild = fs.readFileSync(NETLIFY_BUILD_PATH, 'utf8'); const gitignore = fs.readFileSync(GITIGNORE_PATH, 'utf8'); function runNetlifyGate(context) { @@ -155,21 +173,317 @@ test('frontend preparation and publication receive no cloud identity', () => { assert.equal(fs.existsSync(PUBLIC_CNAME_PATH), false); }); -test('Netlify production Git builds stop while previews remain available', () => { +test('Netlify production is a pinned one-release build while previews remain available', () => { assert.match( netlifyConfig, /ignore = "node \.\/scripts\/netlify-ignore-build\.js"/, ); + assert.match( + netlifyConfig, + /\[context\.production\][\s\S]*command = "node \.\/scripts\/netlify-release-build\.js"/, + ); + assert.match( + netlifyConfig, + /\[context\.deploy-preview\][\s\S]*command = "node \.\/scripts\/netlify-release-build\.js --preview"/, + ); + assert.match(netlifyConfig, /NPM_FLAGS = "--legacy-peer-deps --ignore-scripts"/); + assert.match( + netlifyConfig, + /for = "\/\.well-known\/run-mprc-release\.json"/, + ); - for (const context of [undefined, 'production', 'future-unknown-context']) { + [undefined, 'production', 'future-unknown-context'].forEach((context) => { const result = runNetlifyGate(context); assert.equal(result.status, 0); - assert.match(result.stdout, /production builds are paused/); - } + assert.match(result.stdout, /production build(?:s| is) (?:are )?paused/); + }); - for (const context of ['deploy-preview', 'branch-deploy']) { + ['deploy-preview', 'branch-deploy'].forEach((context) => { const result = runNetlifyGate(context); assert.equal(result.status, 1); assert.match(result.stdout, /may continue/); + }); +}); + +test('Netlify manifest pins the reviewed hotfix source and rollback', () => { + const loaded = loadManifest(NETLIFY_MANIFEST_PATH); + assert.equal(loaded.ok, true); + assert.equal(loaded.manifest.active, true); + assert.equal(loaded.manifest.issueNumber, 457); + assert.equal( + loaded.manifest.expectedProductionParent, + '491cc22d80de1faae1960bd63c80ce62e648397b', + ); + assert.equal( + loaded.manifest.sourceCommit, + 'ed1b0833f25822cee80c99ded8753722b5608a3f', + ); + assert.equal( + loaded.manifest.sourceTree, + '878c6628d961f4484cb49208aef53f1e9f2e3b47', + ); + assert.equal( + loaded.manifest.rollbackDeployId, + '6a54a3c93db9d300082e1f5f', + ); + assert.equal( + loaded.manifest.sourceRef, + 'refs/heads/codex/netlify-source-457-header', + ); + assert.equal( + loaded.manifest.previewBranch, + 'codex/issue-457-netlify-release', + ); + assert.equal(loaded.manifest.expectedSiteFileCount, 60); + assert.equal( + loaded.manifest.expectedSiteFilesSha256, + '7570955c2a00926e5813aef135f1799172cfd046072ac89fb4e492bed0797092', + ); + + const sourceTree = spawnSync( + 'git', + ['show', '-s', '--format=%T', loaded.manifest.sourceCommit], + { cwd: ROOT, encoding: 'utf8' }, + ); + const hotfixTree = spawnSync( + 'git', + ['show', '-s', '--format=%T', 'b9f77ba9dfb153ff8ba203811dfc9c81f7cac31f'], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.equal(sourceTree.status, 0); + assert.equal(hotfixTree.status, 0); + assert.equal(sourceTree.stdout.trim(), loaded.manifest.sourceTree); + assert.equal(hotfixTree.stdout.trim(), loaded.manifest.sourceTree); + + const mainOnlyFile = spawnSync( + 'git', + ['cat-file', '-e', `${loaded.manifest.sourceCommit}:functions/commerceState.js`], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.notEqual(mainOnlyFile.status, 0); +}); + +test('Netlify production authorization is exact-merge scoped', () => { + const loaded = loadManifest(NETLIFY_MANIFEST_PATH); + assert.equal(loaded.ok, true); + const { manifest } = loaded; + const mergeCommit = { + sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + head: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + tree: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + parents: [ + manifest.expectedProductionParent, + 'cccccccccccccccccccccccccccccccccccccccc', + ], + }; + const environment = { + NETLIFY: 'true', + CONTEXT: 'production', + BRANCH: 'main', + COMMIT_REF: mergeCommit.sha, + }; + assert.deepEqual( + evaluateProductionRelease({ + commit: mergeCommit, + env: environment, + manifest, + }), + { ok: true, reason: 'release_authorized' }, + ); + + const failures = [ + { env: { ...environment, NETLIFY: 'false' }, commit: mergeCommit }, + { env: { ...environment, CONTEXT: 'deploy-preview' }, commit: mergeCommit }, + { env: { ...environment, BRANCH: 'release' }, commit: mergeCommit }, + { + env: { ...environment, INCOMING_HOOK_TITLE: 'unverified-hook' }, + commit: mergeCommit, + }, + { env: { ...environment, COMMIT_REF: 'd'.repeat(40) }, commit: mergeCommit }, + { env: environment, commit: { ...mergeCommit, head: 'd'.repeat(40) } }, + { env: environment, commit: { ...mergeCommit, parents: [] } }, + { + env: environment, + commit: { + ...mergeCommit, + parents: ['d'.repeat(40), mergeCommit.parents[1]], + }, + }, + { + env: environment, + commit: { + ...mergeCommit, + parents: [manifest.expectedProductionParent], + }, + }, + ]; + failures.forEach((failure) => { + assert.equal( + evaluateProductionRelease({ + ...failure, + manifest, + }).ok, + false, + ); + }); + assert.equal( + evaluateProductionRelease({ + commit: mergeCommit, + env: environment, + manifest: { ...manifest, active: false }, + }).ok, + false, + ); +}); + +test('Netlify production authorization survives a shallow merge checkout and blocks its successor', () => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'mprc-netlify-policy-'), + ); + const source = path.join(temporaryRoot, 'source'); + const shallow = path.join(temporaryRoot, 'shallow'); + const runGit = (cwd, args) => { + const result = spawnSync('git', args, { cwd, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + try { + fs.mkdirSync(source); + runGit(source, ['init', '-b', 'main']); + runGit(source, ['config', 'user.name', 'Synthetic Release Test']); + runGit(source, ['config', 'user.email', 'synthetic@example.test']); + fs.writeFileSync(path.join(source, 'base.txt'), 'base\n'); + runGit(source, ['add', 'base.txt']); + runGit(source, ['commit', '-m', 'Synthetic production base']); + const base = runGit(source, ['rev-parse', 'HEAD']); + + runGit(source, ['switch', '-c', 'release']); + const manifest = JSON.parse(fs.readFileSync(NETLIFY_MANIFEST_PATH, 'utf8')); + manifest.expectedProductionParent = base; + fs.mkdirSync(path.join(source, 'config')); + fs.writeFileSync( + path.join(source, 'config', 'netlify-production-release.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + runGit(source, ['add', 'config/netlify-production-release.json']); + runGit(source, ['commit', '-m', 'Arm synthetic release']); + runGit(source, ['switch', 'main']); + runGit(source, ['merge', '--no-ff', 'release', '-m', 'Merge synthetic release']); + const merge = runGit(source, ['rev-parse', 'HEAD']); + + runGit( + temporaryRoot, + ['clone', '--depth=1', `file://${source}`, shallow], + ); + const shallowAuthorization = authorizeProductionRelease({ + cwd: shallow, + env: { + NETLIFY: 'true', + CONTEXT: 'production', + BRANCH: 'main', + COMMIT_REF: merge, + }, + manifestPath: path.join( + shallow, + 'config', + 'netlify-production-release.json', + ), + }); + assert.equal(shallowAuthorization.ok, true); + + fs.writeFileSync(path.join(source, 'later.txt'), 'later\n'); + runGit(source, ['add', 'later.txt']); + runGit(source, ['commit', '-m', 'Unrelated later merge']); + const later = runGit(source, ['rev-parse', 'HEAD']); + const laterAuthorization = authorizeProductionRelease({ + cwd: source, + env: { + NETLIFY: 'true', + CONTEXT: 'production', + BRANCH: 'main', + COMMIT_REF: later, + }, + manifestPath: path.join( + source, + 'config', + 'netlify-production-release.json', + ), + }); + assert.equal(laterAuthorization.ok, false); + assert.equal(laterAuthorization.reason, 'deploy_parent'); + } finally { + fs.rmSync(temporaryRoot, { force: true, recursive: true }); + } +}); + +test('Netlify release manifest rejects malformed or expanded authority', () => { + const manifest = JSON.parse(fs.readFileSync(NETLIFY_MANIFEST_PATH, 'utf8')); + [ + null, + { ...manifest, extraAuthority: true }, + { ...manifest, sourceCommit: 'not-a-commit' }, + { ...manifest, sourceRef: 'refs/tags/unreviewed' }, + { ...manifest, sourceRepository: 'https://example.com/other.git' }, + { ...manifest, rollbackDeployId: 'not-a-deploy' }, + { ...manifest, previewBranch: 'main' }, + { ...manifest, expectedSiteFileCount: 0 }, + { ...manifest, expectedSiteFilesSha256: 'not-an-artifact-digest' }, + { ...manifest, sourceCommit: manifest.previousSourceCommit }, + ].forEach((candidate) => { + assert.equal(validateManifest(candidate).ok, false); + }); +}); + +test('Netlify release builder isolates source, environment, and public proof', () => { + assert.match(netlifyBuild, /git[\s\S]*fetch[\s\S]*--depth=1/); + assert.match(netlifyBuild, /fetchedCommit !== manifest\.sourceCommit/); + assert.match(netlifyBuild, /fetchedTree !== manifest\.sourceTree/); + assert.match(netlifyBuild, /npm[\s\S]*ci[\s\S]*--ignore-scripts/); + assert.match(netlifyBuild, /expectedSiteFilesSha256/); + assert.doesNotMatch(netlifyBuild, /env:\s*process\.env/); + assert.doesNotMatch(netlifyBuild, /npm run build/); + assert.match(netlifyBuild, /run-mprc-release\.json/); + assert.match(netlifyBuild, /siteFilesSha256/); + assert.match(netlifyBuild, /CREDENTIAL_MARKERS/); +}); + +test('Netlify pinned build receives no provider or React application variables', () => { + const environment = buildEnvironment('/tmp/synthetic-home', { + PATH: '/synthetic/bin', + TMPDIR: '/tmp/synthetic', + REACT_APP_RECAPTCHA_SITE_KEY: 'public-site-key', + REACT_APP_SENTRY_ENV: 'production', + REACT_APP_UNREVIEWED_VALUE: 'blocked', + FIREBASE_SERVICE_ACCOUNT: 'blocked', + STRIPE_SECRET_KEY: 'blocked', + }); + assert.equal(environment.REACT_APP_RECAPTCHA_SITE_KEY, undefined); + assert.equal(environment.REACT_APP_SENTRY_ENV, undefined); + assert.equal(environment.REACT_APP_UNREVIEWED_VALUE, undefined); + assert.equal(environment.FIREBASE_SERVICE_ACCOUNT, undefined); + assert.equal(environment.STRIPE_SECRET_KEY, undefined); + assert.equal(environment.PATH, '/synthetic/bin'); + assert.equal(environment.HOME, '/tmp/synthetic-home'); +}); + +test('Netlify manifest file rejects duplicate-key or noncanonical JSON', () => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'mprc-netlify-manifest-'), + ); + const duplicatePath = path.join(temporaryRoot, 'duplicate.json'); + const canonical = fs.readFileSync(NETLIFY_MANIFEST_PATH, 'utf8'); + try { + fs.writeFileSync( + duplicatePath, + canonical.replace( + ' "active": true,', + ' "active": false,\n "active": true,', + ), + ); + const loaded = loadManifest(duplicatePath); + assert.equal(loaded.ok, false); + assert.equal(loaded.reason, 'manifest_canonical'); + } finally { + fs.rmSync(temporaryRoot, { force: true, recursive: true }); } }); From 49036146194d0e96e20017de356a7a8fdd0afbf7 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 23 Jul 2026 00:24:24 -0700 Subject: [PATCH 2/4] test: support shallow release checkouts --- tests/release-workflow.test.js | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/tests/release-workflow.test.js b/tests/release-workflow.test.js index 2d71216..70c30c5 100644 --- a/tests/release-workflow.test.js +++ b/tests/release-workflow.test.js @@ -222,6 +222,10 @@ test('Netlify manifest pins the reviewed hotfix source and rollback', () => { loaded.manifest.sourceTree, '878c6628d961f4484cb49208aef53f1e9f2e3b47', ); + assert.equal( + loaded.manifest.previousSourceCommit, + 'e86a0f702cff6495f50630c5de3337290db8b8cb', + ); assert.equal( loaded.manifest.rollbackDeployId, '6a54a3c93db9d300082e1f5f', @@ -240,27 +244,6 @@ test('Netlify manifest pins the reviewed hotfix source and rollback', () => { '7570955c2a00926e5813aef135f1799172cfd046072ac89fb4e492bed0797092', ); - const sourceTree = spawnSync( - 'git', - ['show', '-s', '--format=%T', loaded.manifest.sourceCommit], - { cwd: ROOT, encoding: 'utf8' }, - ); - const hotfixTree = spawnSync( - 'git', - ['show', '-s', '--format=%T', 'b9f77ba9dfb153ff8ba203811dfc9c81f7cac31f'], - { cwd: ROOT, encoding: 'utf8' }, - ); - assert.equal(sourceTree.status, 0); - assert.equal(hotfixTree.status, 0); - assert.equal(sourceTree.stdout.trim(), loaded.manifest.sourceTree); - assert.equal(hotfixTree.stdout.trim(), loaded.manifest.sourceTree); - - const mainOnlyFile = spawnSync( - 'git', - ['cat-file', '-e', `${loaded.manifest.sourceCommit}:functions/commerceState.js`], - { cwd: ROOT, encoding: 'utf8' }, - ); - assert.notEqual(mainOnlyFile.status, 0); }); test('Netlify production authorization is exact-merge scoped', () => { From 308905c72a41c1f11f79027046ace22393e85988 Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 23 Jul 2026 00:27:46 -0700 Subject: [PATCH 3/4] test: clarify exact artifact release scope --- tests/release-workflow.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/release-workflow.test.js b/tests/release-workflow.test.js index 70c30c5..a641ece 100644 --- a/tests/release-workflow.test.js +++ b/tests/release-workflow.test.js @@ -173,7 +173,7 @@ test('frontend preparation and publication receive no cloud identity', () => { assert.equal(fs.existsSync(PUBLIC_CNAME_PATH), false); }); -test('Netlify production is a pinned one-release build while previews remain available', () => { +test('Netlify production is an exact-artifact release while previews remain available', () => { assert.match( netlifyConfig, /ignore = "node \.\/scripts\/netlify-ignore-build\.js"/, From 8947255091c1d3c4c509d6cc363a3c8e4878e35d Mon Sep 17 00:00:00 2001 From: Dave Liu <7david12liu@gmail.com> Date: Thu, 23 Jul 2026 00:30:26 -0700 Subject: [PATCH 4/4] release: bind final Netlify preview branch --- config/netlify-production-release.json | 2 +- tests/release-workflow.test.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config/netlify-production-release.json b/config/netlify-production-release.json index af56da0..60cdb9d 100644 --- a/config/netlify-production-release.json +++ b/config/netlify-production-release.json @@ -3,7 +3,7 @@ "active": true, "releaseId": "WEB-UX-001A-2026-07-22", "issueNumber": 457, - "previewBranch": "codex/issue-457-netlify-release", + "previewBranch": "codex/issue-457-netlify-release-final", "expectedProductionParent": "491cc22d80de1faae1960bd63c80ce62e648397b", "sourceRepository": "https://github.com/Run-MPRC/Run-MPRC.github.io.git", "sourceRef": "refs/heads/codex/netlify-source-457-header", diff --git a/tests/release-workflow.test.js b/tests/release-workflow.test.js index a641ece..450fe7b 100644 --- a/tests/release-workflow.test.js +++ b/tests/release-workflow.test.js @@ -236,7 +236,7 @@ test('Netlify manifest pins the reviewed hotfix source and rollback', () => { ); assert.equal( loaded.manifest.previewBranch, - 'codex/issue-457-netlify-release', + 'codex/issue-457-netlify-release-final', ); assert.equal(loaded.manifest.expectedSiteFileCount, 60); assert.equal(